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

lukaszlenart pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/struts-intellij-plugin.git


The following commit(s) were added to refs/heads/main by this push:
     new cca8586  Show Diagram: fix stale result path after Dom edit (#126) 
(#130)
cca8586 is described below

commit cca858690de914ab4872b92e8b831e723ea8ba82
Author: Lukasz Lenart <[email protected]>
AuthorDate: Sun Jul 26 09:17:31 2026 +0200

    Show Diagram: fix stale result path after Dom edit (#126) (#130)
    
    * docs: design Show Diagram stale result path fix (#126)
    
    Co-authored-by: Cursor <[email protected]>
    
    * docs: plan Show Diagram stale result path fix (#126)
    
    Co-authored-by: Cursor <[email protected]>
    
    * fix(diagram): identify Show Diagram nodes by PSI pointer (#126)
    
    Co-authored-by: Cursor <[email protected]>
    
    * fix(diagram): harden pointer equals/hashCode contract (#126)
    
    Co-authored-by: Cursor <[email protected]>
    
    * fix(diagram): merge Show Diagram presentables on Dom refresh (#126)
    
    Co-authored-by: Cursor <[email protected]>
    
    * docs: note Show Diagram stale result path fix (#126)
    
    Co-authored-by: Cursor <[email protected]>
    
    * fix(diagram): tighten #126 refresh tests and merge keys
    
    Co-authored-by: Cursor <[email protected]>
    
    * fix(diagram): recreate Show Diagram labels when result path changes (#126)
    
    Pointer-only identifying equality kept createLabelNode chrome after Dom
    edits. Include presentable name in StrutsDiagramItem equality so smart
    mode replaces path-changed nodes.
    
    Co-authored-by: Cursor <[email protected]>
    
    * fix(diagram): drop Show Diagram DomEvent auto-refresh (#126)
    
    Rebuild only via Refresh Data Model so XML edits stay snappy and path
    labels update when the user explicitly refreshes.
    
    Co-authored-by: Cursor <[email protected]>
    
    ---------
    
    Co-authored-by: Cursor <[email protected]>
---
 CHANGELOG.md                                       |   5 +
 .../2026-07-26-show-diagram-stale-result-path.md   | 607 +++++++++++++++++++++
 ...-07-26-show-diagram-stale-result-path-design.md | 138 +++++
 .../struts2/diagram/model/StrutsDiagramNode.java   |  26 +-
 .../diagram/provider/StrutsDiagramDataModel.java   |  49 +-
 .../diagram/provider/StrutsDiagramDomRefresh.java  |   4 +-
 .../diagram/provider/StrutsDiagramItem.java        |  12 +-
 .../diagram/StrutsConfigDiagramModelTest.java      |  91 +++
 .../diagram/StrutsDiagramDataModelMappingTest.java | 153 +++++-
 9 files changed, 1025 insertions(+), 60 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 38eb72f..e12a852 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -17,8 +17,13 @@
 - Show Diagram Struts config nodes use compact icon+label chrome instead of 
empty UML class boxes 
([#120](https://github.com/apache/struts-intellij-plugin/issues/120))
 - Show Diagram uses a left-to-right hierarchic layout by default for Struts 
config graphs 
([#122](https://github.com/apache/struts-intellij-plugin/issues/122))
 - Show Diagram action → result edges use directed arrowheads while keeping 
package → action as dashed dependencies 
([#125](https://github.com/apache/struts-intellij-plugin/issues/125))
+- Show Diagram no longer auto-refreshes on `struts.xml` Dom edits; use the 
platform Refresh Data Model action after editing 
([#126](https://github.com/apache/struts-intellij-plugin/issues/126))
 - Dependencies - hard-depend on `com.intellij.diagram` (Ultimate Diagrams)
 
+### Fixed
+
+- Fix Show Diagram stale result path labels after Refresh Data Model following 
copy-paste / path edits 
([#126](https://github.com/apache/struts-intellij-plugin/issues/126))
+
 ## [261.19027.1] - 2026-07-13
 
 ### Added
diff --git 
a/docs/superpowers/plans/2026-07-26-show-diagram-stale-result-path.md 
b/docs/superpowers/plans/2026-07-26-show-diagram-stale-result-path.md
new file mode 100644
index 0000000..a0ae77c
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-26-show-diagram-stale-result-path.md
@@ -0,0 +1,607 @@
+# Show Diagram Stale Result Path Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use 
superpowers:subagent-driven-development (recommended) or 
superpowers:executing-plans to implement this plan task-by-task. Steps use 
checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Fix Show Diagram live Dom refresh so edited/copy-pasted result paths 
update node labels without resetting soft layout preference from #122.
+
+**Architecture:** Make snapshot/API node identity use `SmartPsiElementPointer` 
(`navigationPointer`) instead of `kind@textOffset`. On Dom-triggered live 
updates, merge fresh presentable data onto retained `StrutsDiagramApiNode` 
instances by that identity, then call `refreshDataModelInSmartMode` so layout 
algorithm/positions stay. Initial `refreshDataModel()` remains a full replace.
+
+**Tech Stack:** IntelliJ IDEA Ultimate 2026.2 (262), `com.intellij.diagram` 
(`DiagramDataModel.refreshDataModelInSmartMode`), IntelliJ 
`SmartPsiElementPointer`, JUnit 4 light tests 
(`BasicLightHighlightingTestCase`).
+
+**Spec:** 
`docs/superpowers/specs/2026-07-26-show-diagram-stale-result-path-design.md`
+
+## Global Constraints
+
+- Target platform remains IntelliJ IDEA **2026.2** / build **262** only 
(`pluginSinceBuild=262`, `pluginUntilBuild=262.*`).
+- Host in scope is **Show Diagram** (`StrutsDiagramDataModel`) only — do 
**not** modify or remove the Swing Diagram tab (`diagram.fileEditor` / 
`diagram.ui`).
+- Soft layout preference from #122 must remain: keep 
`refreshDataModelInSmartMode`; do **not** call 
`GraphSettings.setCurrentLayouter` or change 
`StrutsDiagramExtras.getCustomLayouter`.
+- Identity source of truth is **`SmartPsiElementPointer`** on the XML element 
(`navigationPointer`), not path text and not `kind@textOffset`.
+- Snapshot graph shape semantics (package → action → result, chain/redirect) 
stay unchanged.
+- Tests gate: `./gradlew test -x rat --tests "com.intellij.struts2.diagram.*"`.
+- Prefer editing existing files; only add a tiny package-private helper if 
merge logic needs isolation.
+
+---
+
+## File Structure
+
+| File | Action | Responsibility |
+|---|---|---|
+| 
`src/test/java/com/intellij/struts2/diagram/StrutsConfigDiagramModelTest.java` 
| Modify | Identity tests: pointer equality survives offset shift; same-path 
results remain unequal |
+| `src/main/java/com/intellij/struts2/diagram/model/StrutsDiagramNode.java` | 
Modify | `equals`/`hashCode` by `navigationPointer` when present; fallback to 
`id` |
+| 
`src/main/java/com/intellij/struts2/diagram/provider/StrutsDiagramApiNode.java` 
| Modify | Allow updating identifying `StrutsDiagramItem` in place |
+| 
`src/main/java/com/intellij/struts2/diagram/provider/StrutsDiagramApiEdge.java` 
| Modify | Retain `StrutsDiagramEdge` for edge remap during merge |
+| 
`src/main/java/com/intellij/struts2/diagram/provider/StrutsDiagramDataModel.java`
 | Modify | Live merge-by-identity before smart mode |
+| 
`src/test/java/com/intellij/struts2/diagram/StrutsDiagramDataModelMappingTest.java`
 | Modify | Live Dom refresh regression: path edit updates title; retained 
`DiagramNode` instance; copy-paste distinct paths |
+| `CHANGELOG.md` | Modify | Unreleased Fixed note for #126 |
+
+No `plugin.xml` / Gradle dependency changes. No layouter / extras changes. 
Fixture XML can stay as-is (`struts-diagram.xml`, 
`struts-duplicate-names.xml`); document edits happen in tests.
+
+---
+
+### Task 1: Pointer-based node identity
+
+**Files:**
+- Modify: 
`src/test/java/com/intellij/struts2/diagram/StrutsConfigDiagramModelTest.java`
+- Modify: 
`src/main/java/com/intellij/struts2/diagram/model/StrutsDiagramNode.java`
+- Test: 
`src/test/java/com/intellij/struts2/diagram/StrutsConfigDiagramModelTest.java`
+
+**Interfaces:**
+- Consumes: `StrutsConfigDiagramModel.build(XmlFile)`, 
`StrutsDiagramNode.getNavigationPointer()`, `StrutsDiagramNode.getName()`, 
`StrutsDiagramNode.getId()`
+- Produces: `StrutsDiagramNode.equals`/`hashCode` treat same 
`navigationPointer` as same node even when text offsets change; different 
elements remain unequal even when display names match
+
+- [ ] **Step 1: Write the failing identity tests**
+
+In `StrutsConfigDiagramModelTest.java`, in the "Duplicate name and identity 
tests" section, add:
+
+```java
+    public void testResultNodeIdentitySurvivesOffsetShift() {
+        createStrutsFileSet("struts-diagram.xml");
+        VirtualFile vf = myFixture.findFileInTempDir("struts-diagram.xml");
+        assertNotNull(vf);
+        XmlFile xml = (XmlFile) 
PsiManager.getInstance(getProject()).findFile(vf);
+        assertNotNull(xml);
+
+        StrutsConfigDiagramModel before = ReadAction.nonBlocking(
+                () -> 
StrutsConfigDiagramModel.build(xml)).executeSynchronously();
+        assertNotNull(before);
+        StrutsDiagramNode resultBefore = before.getNodes().stream()
+                .filter(n -> n.getKind() == StrutsDiagramNode.Kind.RESULT)
+                .findFirst()
+                .orElseThrow();
+        assertNotNull(resultBefore.getNavigationPointer());
+
+        Document document = 
PsiDocumentManager.getInstance(getProject()).getDocument(xml);
+        assertNotNull(document);
+        WriteCommandAction.runWriteCommandAction(getProject(), () -> {
+            // Insert ahead of the only result so its textOffset changes.
+            String updated = document.getText().replace(
+                    "<action name=\"testAction\"",
+                    "<!-- pad -->\n    <action name=\"testAction\"");
+            document.setText(updated);
+            
PsiDocumentManager.getInstance(getProject()).commitDocument(document);
+        });
+
+        StrutsConfigDiagramModel after = ReadAction.nonBlocking(
+                () -> 
StrutsConfigDiagramModel.build(xml)).executeSynchronously();
+        assertNotNull(after);
+        StrutsDiagramNode resultAfter = after.getNodes().stream()
+                .filter(n -> n.getKind() == StrutsDiagramNode.Kind.RESULT)
+                .findFirst()
+                .orElseThrow();
+
+        assertFalse("Debug ids may still differ after offset shift",
+                resultBefore.getId().equals(resultAfter.getId()));
+        assertTrue("Pointer-based identity must survive offset shift",
+                resultBefore.equals(resultAfter));
+        assertEquals(resultBefore.hashCode(), resultAfter.hashCode());
+        assertEquals(resultBefore.getName(), resultAfter.getName());
+    }
+
+    public void testSamePathResultsRemainUnequalAcrossActions() {
+        createStrutsFileSet("struts-duplicate-names.xml");
+        VirtualFile vf = 
myFixture.findFileInTempDir("struts-duplicate-names.xml");
+        assertNotNull(vf);
+        XmlFile xml = (XmlFile) 
PsiManager.getInstance(getProject()).findFile(vf);
+        assertNotNull(xml);
+
+        // Two default results with different paths already exist; force a 
shared path on both.
+        Document document = 
PsiDocumentManager.getInstance(getProject()).getDocument(xml);
+        assertNotNull(document);
+        WriteCommandAction.runWriteCommandAction(getProject(), () -> {
+            String text = document.getText()
+                    .replace("/admin/index.jsp", "/shared/index.jsp")
+                    .replace("/public/index.jsp", "/shared/index.jsp");
+            document.setText(text);
+            
PsiDocumentManager.getInstance(getProject()).commitDocument(document);
+        });
+
+        StrutsConfigDiagramModel model = ReadAction.nonBlocking(
+                () -> 
StrutsConfigDiagramModel.build(xml)).executeSynchronously();
+        assertNotNull(model);
+        List<StrutsDiagramNode> shared = model.getNodes().stream()
+                .filter(n -> n.getKind() == StrutsDiagramNode.Kind.RESULT)
+                .filter(n -> n.getName().contains("/shared/index.jsp"))
+                .collect(Collectors.toList());
+        assertEquals(2, shared.size());
+        assertFalse(shared.get(0).equals(shared.get(1)));
+        assertFalse(shared.get(0).getId().equals(shared.get(1).getId()));
+    }
+```
+
+Add imports if missing:
+
+```java
+import com.intellij.openapi.command.WriteCommandAction;
+import com.intellij.openapi.editor.Document;
+import com.intellij.psi.PsiDocumentManager;
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run:
+
+```bash
+./gradlew test -x rat --tests 
"com.intellij.struts2.diagram.StrutsConfigDiagramModelTest.testResultNodeIdentitySurvivesOffsetShift"
 --tests 
"com.intellij.struts2.diagram.StrutsConfigDiagramModelTest.testSamePathResultsRemainUnequalAcrossActions"
+```
+
+Expected: `testResultNodeIdentitySurvivesOffsetShift` FAIL — current `equals` 
uses offset-based `id`, so after the pad insert the nodes are unequal (or the 
"ids may differ / equals must hold" pair fails on `assertTrue(...equals...)`). 
`testSamePathResultsRemainUnequalAcrossActions` may already PASS (different 
offsets); keep it as a regression guard.
+
+- [ ] **Step 3: Implement pointer-based `equals`/`hashCode`**
+
+In `StrutsDiagramNode.java`, replace `equals`/`hashCode` with:
+
+```java
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (!(o instanceof StrutsDiagramNode that)) return false;
+        if (navigationPointer != null && that.navigationPointer != null) {
+            return navigationPointer.equals(that.navigationPointer);
+        }
+        return id.equals(that.id);
+    }
+
+    @Override
+    public int hashCode() {
+        if (navigationPointer != null) {
+            return navigationPointer.hashCode();
+        }
+        return id.hashCode();
+    }
+```
+
+Leave `buildNodeId` / `id` field as-is for debug/`toString` and for the 
fallback when `navigationPointer` is null. Update the class javadoc sentence 
that says the stable id uniquely identifies the node so it states pointer-based 
equality is the identity source of truth when a navigation pointer exists.
+
+`StrutsDiagramItem.equals` already delegates to `snapshotNode.equals` — no 
change required there once node equality is pointer-based.
+
+- [ ] **Step 4: Run identity tests to verify they pass**
+
+Run:
+
+```bash
+./gradlew test -x rat --tests 
"com.intellij.struts2.diagram.StrutsConfigDiagramModelTest.testResultNodeIdentitySurvivesOffsetShift"
 --tests 
"com.intellij.struts2.diagram.StrutsConfigDiagramModelTest.testSamePathResultsRemainUnequalAcrossActions"
 --tests 
"com.intellij.struts2.diagram.StrutsConfigDiagramModelTest.testDuplicateActionNamesAcrossPackagesProduceDistinctNodes"
 --tests 
"com.intellij.struts2.diagram.StrutsConfigDiagramModelTest.testDuplicateResultPathsProduceDistinctNodes"
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add \
+  src/main/java/com/intellij/struts2/diagram/model/StrutsDiagramNode.java \
+  src/test/java/com/intellij/struts2/diagram/StrutsConfigDiagramModelTest.java
+git commit -m "$(cat <<'EOF'
+fix(diagram): identify Show Diagram nodes by PSI pointer (#126)
+
+EOF
+)"
+```
+
+---
+
+### Task 2: Live merge updates presentables on retained API nodes
+
+**Files:**
+- Modify: 
`src/main/java/com/intellij/struts2/diagram/provider/StrutsDiagramApiNode.java`
+- Modify: 
`src/main/java/com/intellij/struts2/diagram/provider/StrutsDiagramApiEdge.java`
+- Modify: 
`src/main/java/com/intellij/struts2/diagram/provider/StrutsDiagramDataModel.java`
+- Modify: 
`src/test/java/com/intellij/struts2/diagram/StrutsDiagramDataModelMappingTest.java`
+- Test: 
`src/test/java/com/intellij/struts2/diagram/StrutsDiagramDataModelMappingTest.java`
+
+**Interfaces:**
+- Consumes: Task 1 pointer equality; `StrutsDiagramDataModel` Dom debounce 
refresh; `DiagramProvider.findByID(StrutsDiagramProvider.ID)`
+- Produces:
+  - `StrutsDiagramApiNode.updateIdentifyingElement(StrutsDiagramItem)`
+  - `StrutsDiagramApiEdge.getSnapshotEdge()` (package-private)
+  - `StrutsDiagramDataModel.applyLiveUpdate` merges by identity before smart 
mode
+  - Live Dom path edit keeps the same `DiagramNode` instance and updates 
presentable title
+
+- [ ] **Step 1: Write the failing live-refresh regression tests**
+
+In `StrutsDiagramDataModelMappingTest.java`, add:
+
+```java
+    public void testDomPathEditUpdatesResultTitleOnRetainedApiNode() throws 
InterruptedException {
+        createStrutsFileSet("struts-diagram.xml");
+        VirtualFile vf = myFixture.findFileInTempDir("struts-diagram.xml");
+        assertNotNull(vf);
+        XmlFile xml = (XmlFile) 
PsiManager.getInstance(getProject()).findFile(vf);
+        assertNotNull(xml);
+
+        DiagramProvider<?> diagramProvider = 
DiagramProvider.findByID(StrutsDiagramProvider.ID);
+        assertInstanceOf(diagramProvider, StrutsDiagramProvider.class);
+        StrutsDiagramDataModel dataModel = new StrutsDiagramDataModel(
+                getProject(), (StrutsDiagramProvider) diagramProvider, 
StrutsDiagramItem.forFile(xml));
+        try {
+            ReadAction.run(dataModel::refreshDataModel);
+            DiagramNode<StrutsDiagramItem> resultNode = 
dataModel.getNodes().stream()
+                    .filter(n -> {
+                        StrutsDiagramNode snap = 
n.getIdentifyingElement().getSnapshotNode();
+                        return snap != null && snap.getKind() == 
StrutsDiagramNode.Kind.RESULT;
+                    })
+                    .findFirst()
+                    .orElseThrow();
+            String oldPath = 
resultNode.getIdentifyingElement().getSnapshotNode().getName();
+            assertTrue(oldPath.contains("test.jsp"));
+
+            Document document = 
PsiDocumentManager.getInstance(getProject()).getDocument(xml);
+            assertNotNull(document);
+            WriteCommandAction.runWriteCommandAction(getProject(), () -> {
+                document.setText(document.getText().replace("/pages/test.jsp", 
"/pages/delete.jsp"));
+                
PsiDocumentManager.getInstance(getProject()).commitDocument(document);
+            });
+
+            long deadline = System.currentTimeMillis() + 10_000;
+            boolean updated = false;
+            while (System.currentTimeMillis() < deadline) {
+                UIUtil.dispatchAllInvocationEvents();
+                StrutsDiagramNode snap = 
resultNode.getIdentifyingElement().getSnapshotNode();
+                if (snap != null && snap.getName().contains("delete.jsp")) {
+                    updated = true;
+                    break;
+                }
+                Thread.sleep(50);
+            }
+            assertTrue("Dom refresh must update retained API node presentable 
path", updated);
+
+            boolean sameInstance = dataModel.getNodes().stream().anyMatch(n -> 
n == resultNode);
+            assertTrue("Live merge must retain DiagramNode instance for soft 
layout", sameInstance);
+
+            // Original path must be gone from result titles.
+            boolean stale = dataModel.getNodes().stream()
+                    .map(DiagramNode::getIdentifyingElement)
+                    .map(StrutsDiagramItem::getSnapshotNode)
+                    .filter(Objects::nonNull)
+                    .filter(n -> n.getKind() == StrutsDiagramNode.Kind.RESULT)
+                    .anyMatch(n -> n.getName().contains("test.jsp"));
+            assertFalse(stale);
+        } finally {
+            Disposer.dispose(dataModel);
+        }
+    }
+
+    public void testCopyPasteResultGetsDistinctPathAfterDomRefresh() throws 
InterruptedException {
+        createStrutsFileSet("struts-diagram.xml");
+        VirtualFile vf = myFixture.findFileInTempDir("struts-diagram.xml");
+        assertNotNull(vf);
+        XmlFile xml = (XmlFile) 
PsiManager.getInstance(getProject()).findFile(vf);
+        assertNotNull(xml);
+
+        DiagramProvider<?> diagramProvider = 
DiagramProvider.findByID(StrutsDiagramProvider.ID);
+        assertInstanceOf(diagramProvider, StrutsDiagramProvider.class);
+        StrutsDiagramDataModel dataModel = new StrutsDiagramDataModel(
+                getProject(), (StrutsDiagramProvider) diagramProvider, 
StrutsDiagramItem.forFile(xml));
+        try {
+            ReadAction.run(dataModel::refreshDataModel);
+            int initialResults = (int) dataModel.getNodes().stream()
+                    .map(DiagramNode::getIdentifyingElement)
+                    .map(StrutsDiagramItem::getSnapshotNode)
+                    .filter(Objects::nonNull)
+                    .filter(n -> n.getKind() == StrutsDiagramNode.Kind.RESULT)
+                    .count();
+            assertEquals(1, initialResults);
+
+            Document document = 
PsiDocumentManager.getInstance(getProject()).getDocument(xml);
+            assertNotNull(document);
+            WriteCommandAction.runWriteCommandAction(getProject(), () -> {
+                String updated = document.getText().replace(
+                        "<result>/pages/test.jsp</result>",
+                        "<result name=\"success\">/pages/test.jsp</result>\n" +
+                                "      <result 
name=\"delete\">/pages/delete.jsp</result>");
+                document.setText(updated);
+                
PsiDocumentManager.getInstance(getProject()).commitDocument(document);
+            });
+
+            long deadline = System.currentTimeMillis() + 10_000;
+            Set<String> resultNames = Set.of();
+            while (System.currentTimeMillis() < deadline) {
+                UIUtil.dispatchAllInvocationEvents();
+                resultNames = dataModel.getNodes().stream()
+                        .map(DiagramNode::getIdentifyingElement)
+                        .map(StrutsDiagramItem::getSnapshotNode)
+                        .filter(Objects::nonNull)
+                        .filter(n -> n.getKind() == 
StrutsDiagramNode.Kind.RESULT)
+                        .map(StrutsDiagramNode::getName)
+                        .collect(Collectors.toSet());
+                if (resultNames.size() >= 2) {
+                    break;
+                }
+                Thread.sleep(50);
+            }
+
+            assertTrue("Expected success path present, got: " + resultNames,
+                    resultNames.stream().anyMatch(n -> 
n.contains("test.jsp")));
+            assertTrue("Expected delete path present, got: " + resultNames,
+                    resultNames.stream().anyMatch(n -> 
n.contains("delete.jsp")));
+            assertFalse("Delete must not reuse success path label",
+                    resultNames.size() == 1 && 
resultNames.iterator().next().contains("test.jsp"));
+
+            Set<String> edgeLabels = dataModel.getEdges().stream()
+                    .map(StrutsDiagramDataModelMappingTest::apiEdgeLabel)
+                    .collect(Collectors.toSet());
+            assertTrue(edgeLabels.contains("success"));
+            assertTrue(edgeLabels.contains("delete"));
+        } finally {
+            Disposer.dispose(dataModel);
+        }
+    }
+```
+
+Add imports if missing:
+
+```java
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+```
+
+(`WriteCommandAction`, `Document`, `PsiDocumentManager`, `UIUtil`, `Disposer` 
should already be present from existing tests.)
+
+- [ ] **Step 2: Run tests to verify the retention assert fails**
+
+Run:
+
+```bash
+./gradlew test -x rat --tests 
"com.intellij.struts2.diagram.StrutsDiagramDataModelMappingTest.testDomPathEditUpdatesResultTitleOnRetainedApiNode"
 --tests 
"com.intellij.struts2.diagram.StrutsDiagramDataModelMappingTest.testCopyPasteResultGetsDistinctPathAfterDomRefresh"
+```
+
+Expected: `testDomPathEditUpdatesResultTitleOnRetainedApiNode` FAIL on 
`sameInstance` (today `applyApiModel` replaces node objects). Path text may 
already update when `builder == null`; the retention assert is the intentional 
red. Copy-paste test may already PASS on path sets; keep it as acceptance 
coverage for #126.
+
+- [ ] **Step 3: Make `StrutsDiagramApiNode` updatable**
+
+Replace the `item` field handling in `StrutsDiagramApiNode.java` with:
+
+```java
+    private @NotNull StrutsDiagramItem item;
+
+    public StrutsDiagramApiNode(@NotNull DiagramProvider<StrutsDiagramItem> 
provider,
+                                @NotNull StrutsDiagramItem item) {
+        super(provider);
+        this.item = item;
+    }
+
+    /**
+     * Replaces presentable identifying data while keeping this API node 
instance for smart refresh.
+     */
+    void updateIdentifyingElement(@NotNull StrutsDiagramItem newItem) {
+        this.item = newItem;
+    }
+
+    @Override
+    public @NotNull StrutsDiagramItem getIdentifyingElement() {
+        return item;
+    }
+```
+
+Leave `getTooltip` / `getIcon` / `navigate` / `canNavigate` unchanged (they 
already read through `item`).
+
+- [ ] **Step 4: Retain snapshot edge on `StrutsDiagramApiEdge`**
+
+In `StrutsDiagramApiEdge.java`, store the snapshot edge for merge remapping:
+
+```java
+public final class StrutsDiagramApiEdge extends 
DiagramEdgeBase<StrutsDiagramItem> {
+
+    private final @NotNull StrutsDiagramEdge snapshotEdge;
+
+    public StrutsDiagramApiEdge(@NotNull DiagramNode<StrutsDiagramItem> source,
+                                @NotNull DiagramNode<StrutsDiagramItem> target,
+                                @NotNull StrutsDiagramEdge snapshotEdge) {
+        super(source, target, relationshipFor(snapshotEdge));
+        this.snapshotEdge = snapshotEdge;
+    }
+
+    @NotNull StrutsDiagramEdge getSnapshotEdge() {
+        return snapshotEdge;
+    }
+
+    // relationshipFor unchanged...
+}
+```
+
+- [ ] **Step 5: Implement merge-by-identity in `StrutsDiagramDataModel`**
+
+Replace `applyLiveUpdate` and add helpers in `StrutsDiagramDataModel.java`:
+
+```java
+    private void applyLiveUpdate(@NotNull ApiModel fresh) {
+        mergeApiModel(fresh);
+        DiagramBuilder builder = getUserData(DiagramDataKeys.GRAPH_BUILDER);
+        if (builder != null) {
+            DiagramDataModel.refreshDataModelInSmartMode(builder);
+        }
+    }
+
+    private void mergeApiModel(@NotNull ApiModel fresh) {
+        if (nodes.isEmpty()) {
+            applyApiModel(fresh);
+            return;
+        }
+
+        Map<Object, DiagramNode<StrutsDiagramItem>> existingByKey = new 
HashMap<>();
+        for (DiagramNode<StrutsDiagramItem> existing : nodes) {
+            existingByKey.put(identityKey(existing.getIdentifyingElement()), 
existing);
+        }
+
+        List<DiagramNode<StrutsDiagramItem>> mergedNodes = new ArrayList<>();
+        Map<DiagramNode<StrutsDiagramItem>, DiagramNode<StrutsDiagramItem>> 
freshToMerged =
+                new IdentityHashMap<>();
+
+        for (DiagramNode<StrutsDiagramItem> freshNode : fresh.nodes()) {
+            Object key = identityKey(freshNode.getIdentifyingElement());
+            DiagramNode<StrutsDiagramItem> existing = existingByKey.get(key);
+            if (existing instanceof StrutsDiagramApiNode apiNode) {
+                
apiNode.updateIdentifyingElement(freshNode.getIdentifyingElement());
+                mergedNodes.add(apiNode);
+                freshToMerged.put(freshNode, apiNode);
+            }
+            else {
+                mergedNodes.add(freshNode);
+                freshToMerged.put(freshNode, freshNode);
+            }
+        }
+
+        List<DiagramEdge<StrutsDiagramItem>> mergedEdges = new ArrayList<>();
+        for (DiagramEdge<StrutsDiagramItem> freshEdge : fresh.edges()) {
+            DiagramNode<StrutsDiagramItem> source = 
freshToMerged.get(freshEdge.getSource());
+            DiagramNode<StrutsDiagramItem> target = 
freshToMerged.get(freshEdge.getTarget());
+            if (source == null || target == null) {
+                continue;
+            }
+            if (freshEdge instanceof StrutsDiagramApiEdge apiEdge) {
+                mergedEdges.add(new StrutsDiagramApiEdge(source, target, 
apiEdge.getSnapshotEdge()));
+            }
+        }
+
+        nodes.clear();
+        nodes.addAll(mergedNodes);
+        edges.clear();
+        edges.addAll(mergedEdges);
+    }
+
+    private static @NotNull Object identityKey(@NotNull StrutsDiagramItem 
item) {
+        StrutsDiagramNode snapshotNode = item.getSnapshotNode();
+        if (snapshotNode == null) {
+            XmlFile file = item.getXmlFile();
+            return file != null && file.getVirtualFile() != null
+                    ? file.getVirtualFile().getUrl()
+                    : item;
+        }
+        if (snapshotNode.getNavigationPointer() != null) {
+            return snapshotNode.getNavigationPointer();
+        }
+        return snapshotNode.getId();
+    }
+```
+
+Add imports:
+
+```java
+import com.intellij.psi.xml.XmlFile;
+import java.util.HashMap;
+import java.util.IdentityHashMap;
+import java.util.Set; // only if needed; may omit
+```
+
+Keep `applyApiModel` for `refreshDataModel()` full replace. Do **not** change 
Dom debounce delay, listener registration, or layouter/settings.
+
+- [ ] **Step 6: Run live-refresh tests to verify they pass**
+
+Run:
+
+```bash
+./gradlew test -x rat --tests 
"com.intellij.struts2.diagram.StrutsDiagramDataModelMappingTest.testDomPathEditUpdatesResultTitleOnRetainedApiNode"
 --tests 
"com.intellij.struts2.diagram.StrutsDiagramDataModelMappingTest.testCopyPasteResultGetsDistinctPathAfterDomRefresh"
 --tests 
"com.intellij.struts2.diagram.StrutsDiagramDataModelMappingTest.testSameFileDomEventRefreshesLiveDataModel"
 --tests 
"com.intellij.struts2.diagram.StrutsDiagramDataModelMappingTest.testRefreshMapsSnapshotNodesAndEdges"
+```
+
+Expected: PASS.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add \
+  
src/main/java/com/intellij/struts2/diagram/provider/StrutsDiagramApiNode.java \
+  
src/main/java/com/intellij/struts2/diagram/provider/StrutsDiagramApiEdge.java \
+  
src/main/java/com/intellij/struts2/diagram/provider/StrutsDiagramDataModel.java 
\
+  
src/test/java/com/intellij/struts2/diagram/StrutsDiagramDataModelMappingTest.java
+git commit -m "$(cat <<'EOF'
+fix(diagram): merge Show Diagram presentables on Dom refresh (#126)
+
+EOF
+)"
+```
+
+---
+
+### Task 3: Changelog + full diagram suite
+
+**Files:**
+- Modify: `CHANGELOG.md`
+- Test: `src/test/java/com/intellij/struts2/diagram/*`
+
+**Interfaces:**
+- Consumes: Tasks 1–2 behavior
+- Produces: Unreleased changelog entry for #126; green diagram test suite
+
+- [ ] **Step 1: Add changelog entry**
+
+Under `## [Unreleased]`, ensure a `### Fixed` subsection exists in the 
Unreleased block (add it if missing). Add:
+
+```markdown
+- Fix Show Diagram stale result path labels after Dom edit / copy-paste 
refresh ([#126](https://github.com/apache/struts-intellij-plugin/issues/126))
+```
+
+Do not invent a release version section.
+
+- [ ] **Step 2: Run full diagram suite**
+
+Run:
+
+```bash
+./gradlew test -x rat --tests "com.intellij.struts2.diagram.*"
+```
+
+Expected: BUILD SUCCESSFUL; all diagram tests PASS.
+
+- [ ] **Step 3: Manual smoke check (recommended)**
+
+Run:
+
+```bash
+./gradlew runIde
+```
+
+Open a Struts config → Show Diagram → copy-paste a `success` result → rename 
to `delete` and change path → after ~300 ms both nodes show correct paths. If a 
non-custom toolbar layout was selected earlier, Dom refresh must not reset it 
to custom LTR.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add CHANGELOG.md
+git commit -m "$(cat <<'EOF'
+docs: note Show Diagram stale result path fix (#126)
+
+EOF
+)"
+```
+
+---
+
+## Spec coverage checklist
+
+| Spec requirement | Task |
+|---|---|
+| Pointer-based stable identity (not offset/path) | Task 1 |
+| Presentable merge on retained API nodes before smart mode | Task 2 |
+| Keep `refreshDataModelInSmartMode` / soft layout | Task 2 (no 
GraphSettings/layouter changes) |
+| Initial `refreshDataModel()` full replace | Task 2 (`applyApiModel` 
unchanged for that path) |
+| Path edit updates label after Dom refresh | Task 2 test + impl |
+| Copy-paste name+path → distinct correct nodes | Task 2 test |
+| No same-path identity collision | Task 1 + Task 2 tests |
+| Swing tab / LTR extras untouched | Global constraints; no tasks touch those 
files |
+| Changelog | Task 3 |
+| Manual `runIde` check | Task 3 |
+
+## Self-review notes
+
+- No TBD/placeholder steps; concrete test code and production snippets 
included.
+- `identityKey` uses the same pointer object that `StrutsDiagramNode.equals` 
uses, so merge matching stays consistent with smart-mode identifying-element 
equality.
+- Unit tests intentionally assert retained `DiagramNode` instance identity 
because `DiagramBuilder` is null in light tests; that is the merge contract 
smart mode needs. Path correctness alone is insufficient to prove the fix.
diff --git 
a/docs/superpowers/specs/2026-07-26-show-diagram-stale-result-path-design.md 
b/docs/superpowers/specs/2026-07-26-show-diagram-stale-result-path-design.md
new file mode 100644
index 0000000..d9b87e7
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-26-show-diagram-stale-result-path-design.md
@@ -0,0 +1,138 @@
+# Show Diagram: Stale Result Path After Dom Edit / Copy-Paste
+
+**Date:** 2026-07-26  
+**Status:** Approved for implementation planning  
+**Related:** 
[#126](https://github.com/apache/struts-intellij-plugin/issues/126); follow-up 
to [#122](https://github.com/apache/struts-intellij-plugin/issues/122) / 
[#124](https://github.com/apache/struts-intellij-plugin/pull/124)
+
+## Problem
+
+After adding a new result by copy-pasting an existing `success` result and 
renaming it to `delete` (and changing the path to `delete.jsp`), Show Diagram 
still shows the `delete` result pointing at the previous path (e.g. 
`/WEB-INF/examples/index.jsp`) instead of the updated `delete.jsp` path.
+
+Confirmed reproduction detail: **reopening** Show Diagram shows the correct 
path. Only **live Dom refresh** is stale. Soft preference / LTR layout from 
#122 must remain unchanged (refresh must not reset the user’s layout algorithm 
choice or needlessly reshuffle node positions).
+
+## Goals
+
+1. Editing a result path in XML updates the corresponding Show Diagram result 
node label/path after Dom refresh.
+2. Copy-paste of a result element and changing name + path yields distinct 
nodes with correct paths.
+3. No duplicate/stale identity collision between results that share a previous 
path.
+4. Soft layout preference from #122 remains: Dom refresh does not reset 
toolbar layout choice; retained nodes keep positions when identity is stable.
+5. Automated regression coverage for the copy-paste / path-edit case; short 
manual `runIde` check.
+6. Changelog documents the fix.
+
+## Non-Goals
+
+- Fixing or changing the leftover Swing Diagram tab (`diagram.fileEditor` / 
`diagram.ui`).
+- Changing LTR layouter / `StrutsDiagramExtras.getCustomLayouter` behavior.
+- Changing snapshot graph shape semantics (package → action → result, 
chain/redirect resolution).
+- Robot / UI e2e tests.
+- Full graph rebuild on every Dom event (rejected — fights soft layout 
preference).
+
+## Decisions
+
+| Question | Decision |
+|---|---|
+| Host in scope | Show Diagram (`StrutsDiagramDataModel`) only |
+| Root cause layer | Live Dom refresh + identifying-element equality, not 
snapshot build |
+| Soft layout | Required: keep smart mode + avoid layout algorithm reset; 
prefer in-place presentable updates |
+| Identity source of truth | `SmartPsiElementPointer` to the XML element 
(`navigationPointer`), not `kind@textOffset` and not path text |
+| Refresh strategy | Merge by stable identity (update presentables on retained 
API nodes), then `refreshDataModelInSmartMode` |
+| Initial open / `refreshDataModel()` | Full replace (unchanged) |
+
+### Alternatives considered
+
+| Approach | Verdict |
+|---|---|
+| Stable PSI pointer identity + presentable merge before smart refresh | 
**Chosen** — fixes stale labels while preserving soft layout / positions |
+| Content-aware identity (path/name in `equals` / id) | Rejected — correct 
labels but edited nodes (and sometimes neighbors) can jump |
+| Full rebuild instead of smart mode on Dom refresh | Rejected — simplest 
correctness but resets layout/positions; fights #122 |
+
+## Architecture
+
+**Why reopen works but live refresh does not**
+
+1. Dom refresh rebuilds a correct `StrutsConfigDiagramModel` snapshot (path 
already correct in the new `StrutsDiagramNode.name`).
+2. `applyLiveUpdate` replaces the data-model node list, then calls 
`DiagramDataModel.refreshDataModelInSmartMode(builder)`.
+3. Smart mode keeps graph nodes whose identifying elements `equals` existing 
ones.
+4. Today identity is effectively `kind@textOffset` via `StrutsDiagramNode` / 
`StrutsDiagramItem` equality. A path-only edit keeps the same id, so smart mode 
retains the **old** `StrutsDiagramApiNode` (old path baked into its identifying 
item) and drops the fresh item’s presentable data.
+5. Copy-paste of a result that initially shares a path can also collide/stale 
when identity is offset- or path-tied rather than element-tied.
+
+**Fix**
+
+```
+DomEvent → debounce → buildApiModel (fresh snapshot)
+  → mergeByStableIdentity(existing, fresh)  // update presentables in place
+  → refreshDataModelInSmartMode(builder)    // add/remove only
+```
+
+1. **Stable identity** — treat two nodes as the same when their 
`navigationPointer`s refer to the same XML element (fallback to existing string 
id when pointer is null).
+2. **Presentable merge** — on live update, match old↔new by that identity; 
update the retained `StrutsDiagramApiNode`’s identifying `StrutsDiagramItem` so 
title/tooltip/icon come from the fresh snapshot; add/remove only structural 
changes; rebuild edges from the fresh model against the post-merge node map.
+3. **Keep smart mode** — still call `refreshDataModelInSmartMode` so layout 
algorithm choice and positions stay. Do not touch `GraphSettings` / layouter on 
refresh.
+
+**Unchanged:** `StrutsDiagramExtras` LTR layouter, compact node chrome, Swing 
tab, `plugin.xml`, chain/redirect snapshot semantics.
+
+## Components
+
+| Unit | Role |
+|---|---|
+| `StrutsDiagramNode` | Equality/hash by `navigationPointer` when present; 
fallback to existing `id`. Keep `id` for debug/`toString`; stop treating 
`kind@textOffset` as the identity source of truth for smart mode. |
+| `StrutsConfigDiagramModel.buildNodeId` | May keep a debug id or non-path 
key; must not be what smart mode relies on for sameness after this change. |
+| `StrutsDiagramItem` | Equality follows snapshot node identity 
(pointer-based). Still wraps file + snapshot node. |
+| `StrutsDiagramApiNode` | Allow swapping/updating the identifying 
`StrutsDiagramItem` when merge finds the same element with new presentable 
data. Same `DiagramNode` instance stays in the graph. |
+| `StrutsDiagramDataModel.applyLiveUpdate` | Merge by stable identity → update 
presentables → then smart mode. Initial `refreshDataModel()` stays full 
replace. |
+| Merge helper | Prefer a small package-private helper on/near 
`StrutsDiagramDataModel` if merge logic needs unit tests without a full 
`DiagramBuilder`. |
+| `StrutsDiagramExtras` / layouter | Unchanged. |
+
+No new top-level packages.
+
+## Data flow
+
+### Live Dom refresh
+
+1. Debounced `scheduleRefresh` builds a fresh `ApiModel` under a read action 
(unchanged).
+2. On EDT, `applyLiveUpdate`:
+   - Index existing API nodes by stable identity.
+   - For each fresh node: if match → update retained API node’s item to the 
fresh presentable snapshot; else → add.
+   - Drop nodes whose identity disappeared; rebuild edges from the fresh model 
(endpoints via post-merge node map).
+   - Call `refreshDataModelInSmartMode(builder)` when a builder exists.
+3. Never call `GraphSettings.setCurrentLayouter` or replace the custom 
layouter on refresh.
+
+### Initial open / platform `refreshDataModel()`
+
+Full replace of nodes/edges (today’s behavior). No merge required.
+
+## Error handling
+
+| Scenario | Behavior |
+|---|---|
+| Path/name edit, same XML element | Identity match → presentable update; node 
stays; label/path refresh |
+| Copy-paste new `<result>` | New pointer → new node; no collision with source 
result |
+| Insert/delete XML above a result (offsets shift) | Smart pointer tracks 
element → identity stable; no false remove/add |
+| Pointer unresolved / null during build | Fallback identity (existing string 
id); may lose soft-position for that node only |
+| Builder null (tests / no UI) | Merge into lists only; skip smart-mode call |
+| Invalid / empty snapshot | Clear to empty model (unchanged) |
+
+## Testing
+
+### Automated
+
+1. **Identity** — Two results with the same path get distinct identities; 
after a simulated path edit on one DOM element, identity stays the same while 
`name` updates (`StrutsConfigDiagramModel` / node equality tests).
+2. **Live merge regression (#126)** — Open data model on a fixture → 
`refreshDataModel` → edit a copied result’s name+path via document write (same 
pattern as `testSameFileDomEventRefreshesLiveDataModel`) → wait for Dom refresh 
→ assert edge label `delete` targets a result node whose title/path is 
`delete.jsp` (not the old shared path), and the original `success` node still 
shows the old path.
+3. **Mapping suite** — Existing `StrutsDiagramDataModelMappingTest` / diagram 
suite still pass.
+4. **No layouter regression** — Existing LTR extras tests unchanged.
+
+### Manual (`runIde`)
+
+- Copy-paste `success` → rename to `delete` + path `delete.jsp` with Show 
Diagram open → after debounce, both nodes correct; layout algorithm choice 
unchanged if user picked a non-custom layout earlier.
+
+### Changelog
+
+One line under Unreleased: fix Show Diagram stale result path after Dom edit / 
copy-paste.
+
+## Success criteria
+
+Matches [#126](https://github.com/apache/struts-intellij-plugin/issues/126) 
acceptance criteria:
+
+- [ ] Editing a result path in XML updates the corresponding Show Diagram 
result node label/path after Dom refresh
+- [ ] Copy-paste of a result element and changing name + path yields distinct 
nodes with correct paths
+- [ ] No duplicate/stale identity collision between results that share the 
previous path
+- Soft preference / LTR layout from #122 remains unchanged
diff --git 
a/src/main/java/com/intellij/struts2/diagram/model/StrutsDiagramNode.java 
b/src/main/java/com/intellij/struts2/diagram/model/StrutsDiagramNode.java
index c88e54f..39d0412 100644
--- a/src/main/java/com/intellij/struts2/diagram/model/StrutsDiagramNode.java
+++ b/src/main/java/com/intellij/struts2/diagram/model/StrutsDiagramNode.java
@@ -22,16 +22,19 @@ import org.jetbrains.annotations.NotNull;
 import org.jetbrains.annotations.Nullable;
 
 import javax.swing.*;
-import java.util.Objects;
 
 /**
  * Toolkit-neutral node representing a Struts config element (package, action, 
or result).
  * <p>
- * Each node carries a stable {@link #id} captured during model build that 
uniquely
- * identifies it even when two elements share the same display {@link #name} 
(e.g.
- * duplicate action names across packages, or identical result paths).  The UI
- * renderer uses node identity for layout maps and edge lookup, so uniqueness 
here
- * is critical.
+ * Each node carries a debug {@link #id} captured during model build 
(kind@textOffset).
+ * When a {@link #navigationPointer} exists, pointer-based {@link #equals} / 
{@link #hashCode}
+ * are the identity source of truth so the same DOM element stays the same 
node even when
+ * text offsets change after edits. The {@link #id} remains for debug/{@link 
#toString}
+ * and as a fallback when no navigation pointer is available.
+ * <p>
+ * Display {@link #name} may be shared across nodes (e.g. duplicate action 
names across
+ * packages, or identical result paths). The UI renderer uses node identity 
for layout maps
+ * and edge lookup, so correct equality here is critical.
  * <p>
  * UI-safe fields ({@link #getTooltipHtml()}, {@link #getNavigationPointer()}, 
{@link #getIcon()})
  * are precomputed during snapshot creation under a read action so that Swing 
event handlers
@@ -77,12 +80,21 @@ public final class StrutsDiagramNode {
     public boolean equals(Object o) {
         if (this == o) return true;
         if (!(o instanceof StrutsDiagramNode that)) return false;
+        if (navigationPointer != null && that.navigationPointer != null) {
+            return navigationPointer.equals(that.navigationPointer);
+        }
+        if (navigationPointer != null || that.navigationPointer != null) {
+            return false;
+        }
         return id.equals(that.id);
     }
 
     @Override
     public int hashCode() {
-        return Objects.hash(id);
+        if (navigationPointer != null) {
+            return navigationPointer.hashCode();
+        }
+        return id.hashCode();
     }
 
     @Override
diff --git 
a/src/main/java/com/intellij/struts2/diagram/provider/StrutsDiagramDataModel.java
 
b/src/main/java/com/intellij/struts2/diagram/provider/StrutsDiagramDataModel.java
index ea10c83..96798ea 100644
--- 
a/src/main/java/com/intellij/struts2/diagram/provider/StrutsDiagramDataModel.java
+++ 
b/src/main/java/com/intellij/struts2/diagram/provider/StrutsDiagramDataModel.java
@@ -16,24 +16,16 @@
  */
 package com.intellij.struts2.diagram.provider;
 
-import com.intellij.diagram.DiagramBuilder;
-import com.intellij.diagram.DiagramDataKeys;
 import com.intellij.diagram.DiagramDataModel;
 import com.intellij.diagram.DiagramEdge;
 import com.intellij.diagram.DiagramNode;
-import com.intellij.openapi.application.ModalityState;
-import com.intellij.openapi.application.ReadAction;
 import com.intellij.openapi.project.Project;
 import com.intellij.openapi.util.ModificationTracker;
-import com.intellij.openapi.vfs.VirtualFile;
 import com.intellij.psi.PsiManager;
 import com.intellij.psi.xml.XmlFile;
 import com.intellij.struts2.diagram.model.StrutsConfigDiagramModel;
 import com.intellij.struts2.diagram.model.StrutsDiagramEdge;
 import com.intellij.struts2.diagram.model.StrutsDiagramNode;
-import com.intellij.util.Alarm;
-import com.intellij.util.concurrency.AppExecutorUtil;
-import com.intellij.util.xml.DomManager;
 import org.jetbrains.annotations.NotNull;
 import org.jetbrains.annotations.Nullable;
 
@@ -44,32 +36,28 @@ import java.util.IdentityHashMap;
 import java.util.List;
 import java.util.Map;
 
+/**
+ * Show Diagram data model for a Struts config file.
+ * <p>
+ * The graph is rebuilt when the platform invokes {@link #refreshDataModel()}
+ * (e.g. the Refresh Data Model action). There is no DomEvent auto-refresh —
+ * editing {@code struts.xml} while the diagram is open does not rebuild until
+ * the user refreshes explicitly, avoiding edit-time lag and stale smart-mode 
labels.
+ */
 public final class StrutsDiagramDataModel extends 
DiagramDataModel<StrutsDiagramItem> {
 
-    private static final int DOM_UPDATE_DELAY_MS = 300;
-
     private final List<DiagramNode<StrutsDiagramItem>> nodes = new 
ArrayList<>();
     private final List<DiagramEdge<StrutsDiagramItem>> edges = new 
ArrayList<>();
-    private final Alarm updateAlarm = new 
Alarm(Alarm.ThreadToUse.SWING_THREAD, this);
     private final @Nullable XmlFile xmlFile;
-    private final @Nullable VirtualFile virtualFile;
 
     public StrutsDiagramDataModel(@NotNull Project project,
                                   @NotNull StrutsDiagramProvider provider,
                                   @Nullable StrutsDiagramItem seed) {
         super(project, provider);
         xmlFile = seed != null ? seed.getXmlFile() : null;
-        virtualFile = xmlFile != null ? xmlFile.getVirtualFile() : null;
         if (seed != null) {
             setOriginalElement(seed);
         }
-        if (virtualFile != null) {
-            DomManager.getDomManager(project).addDomEventListener(event -> {
-                if (StrutsDiagramDomRefresh.isEventForMyFile(event, 
virtualFile)) {
-                    queueDebouncedRefresh();
-                }
-            }, this);
-        }
     }
 
     @Override
@@ -140,27 +128,6 @@ public final class StrutsDiagramDataModel extends 
DiagramDataModel<StrutsDiagram
         edges.addAll(model.edges());
     }
 
-    private void applyLiveUpdate(@NotNull ApiModel model) {
-        applyApiModel(model);
-        DiagramBuilder builder = getUserData(DiagramDataKeys.GRAPH_BUILDER);
-        if (builder != null) {
-            DiagramDataModel.refreshDataModelInSmartMode(builder);
-        }
-    }
-
-    private void queueDebouncedRefresh() {
-        updateAlarm.cancelAllRequests();
-        updateAlarm.addRequest(this::scheduleRefresh, DOM_UPDATE_DELAY_MS);
-    }
-
-    private void scheduleRefresh() {
-        ReadAction.nonBlocking(this::buildApiModel)
-                .expireWith(this)
-                .coalesceBy(this)
-                .finishOnUiThread(ModalityState.defaultModalityState(), 
this::applyLiveUpdate)
-                .submit(AppExecutorUtil.getAppExecutorService());
-    }
-
     private record ApiModel(@NotNull List<DiagramNode<StrutsDiagramItem>> 
nodes,
                             @NotNull List<DiagramEdge<StrutsDiagramItem>> 
edges) {
 
diff --git 
a/src/main/java/com/intellij/struts2/diagram/provider/StrutsDiagramDomRefresh.java
 
b/src/main/java/com/intellij/struts2/diagram/provider/StrutsDiagramDomRefresh.java
index 3c7dbd0..85efa01 100644
--- 
a/src/main/java/com/intellij/struts2/diagram/provider/StrutsDiagramDomRefresh.java
+++ 
b/src/main/java/com/intellij/struts2/diagram/provider/StrutsDiagramDomRefresh.java
@@ -24,7 +24,9 @@ import org.jetbrains.annotations.NotNull;
 import org.jetbrains.annotations.Nullable;
 
 /**
- * Pure helpers for same-file DomEvent filtering used by {@link 
StrutsDiagramDataModel}.
+ * Pure helpers for same-file DomEvent filtering.
+ * Kept for shared/test use; Show Diagram no longer auto-refreshes on DomEvents
+ * (users invoke Refresh Data Model instead).
  */
 public final class StrutsDiagramDomRefresh {
 
diff --git 
a/src/main/java/com/intellij/struts2/diagram/provider/StrutsDiagramItem.java 
b/src/main/java/com/intellij/struts2/diagram/provider/StrutsDiagramItem.java
index 0c50767..2398504 100644
--- a/src/main/java/com/intellij/struts2/diagram/provider/StrutsDiagramItem.java
+++ b/src/main/java/com/intellij/struts2/diagram/provider/StrutsDiagramItem.java
@@ -29,6 +29,10 @@ import java.util.Objects;
 /**
  * Identifying element for Struts Diagrams API nodes.
  * Root items ({@code snapshotNode == null}) seed Show Diagram from an XML 
file.
+ * <p>
+ * Equality includes the presentable snapshot name so Refresh Data Model 
treats path edits
+ * as a new identifying element. Pointer-only equality would retain previous 
node chrome
+ * ({@code createLabelNode} bakes the title once).
  */
 public final class StrutsDiagramItem {
 
@@ -73,12 +77,16 @@ public final class StrutsDiagramItem {
             return snapshotNode == that.snapshotNode
                     && Objects.equals(fileUrl(), that.fileUrl());
         }
-        return snapshotNode.equals(that.snapshotNode);
+        return snapshotNode.equals(that.snapshotNode)
+                && snapshotNode.getName().equals(that.snapshotNode.getName());
     }
 
     @Override
     public int hashCode() {
-        return snapshotNode != null ? snapshotNode.hashCode() : 
Objects.hash(fileUrl());
+        if (snapshotNode == null) {
+            return Objects.hash(fileUrl());
+        }
+        return Objects.hash(snapshotNode, snapshotNode.getName());
     }
 
     private @Nullable String fileUrl() {
diff --git 
a/src/test/java/com/intellij/struts2/diagram/StrutsConfigDiagramModelTest.java 
b/src/test/java/com/intellij/struts2/diagram/StrutsConfigDiagramModelTest.java
index 083ea41..4066bf6 100644
--- 
a/src/test/java/com/intellij/struts2/diagram/StrutsConfigDiagramModelTest.java
+++ 
b/src/test/java/com/intellij/struts2/diagram/StrutsConfigDiagramModelTest.java
@@ -18,8 +18,11 @@ package com.intellij.struts2.diagram;
 
 import com.intellij.openapi.application.ApplicationManager;
 import com.intellij.openapi.application.ReadAction;
+import com.intellij.openapi.command.WriteCommandAction;
+import com.intellij.openapi.editor.Document;
 import com.intellij.openapi.vfs.VirtualFile;
 import com.intellij.pom.Navigatable;
+import com.intellij.psi.PsiDocumentManager;
 import com.intellij.psi.PsiFile;
 import com.intellij.psi.PsiManager;
 import com.intellij.psi.SmartPsiElementPointer;
@@ -30,6 +33,7 @@ import 
com.intellij.struts2.diagram.model.StrutsConfigDiagramModel;
 import com.intellij.struts2.diagram.model.StrutsDiagramEdge;
 import com.intellij.struts2.diagram.model.StrutsDiagramNode;
 import com.intellij.struts2.diagram.ui.Struts2DiagramComponent;
+import com.intellij.testFramework.LightProjectDescriptor;
 import org.jetbrains.annotations.NotNull;
 
 import java.util.List;
@@ -42,6 +46,20 @@ import java.util.stream.Collectors;
  */
 public class StrutsConfigDiagramModelTest extends 
BasicLightHighlightingTestCase {
 
+    @NotNull
+    @Override
+    protected LightProjectDescriptor getProjectDescriptor() {
+        return WEB;
+    }
+
+    @Override
+    protected void performSetUp() {
+        myFixture.addFileToProject("shared/index.jsp", "<html></html>");
+        myFixture.addFileToProject("admin/index.jsp", "<html></html>");
+        myFixture.addFileToProject("public/index.jsp", "<html></html>");
+        myFixture.addFileToProject("public/form.jsp", "<html></html>");
+    }
+
     @Override
     @NotNull
     protected String getTestDataLocation() {
@@ -345,6 +363,79 @@ public class StrutsConfigDiagramModelTest extends 
BasicLightHighlightingTestCase
         assertEquals("All result nodes must have distinct IDs", 3, 
distinctIds);
     }
 
+    public void testResultNodeIdentitySurvivesOffsetShift() {
+        createStrutsFileSet("struts-diagram.xml");
+        VirtualFile vf = myFixture.findFileInTempDir("struts-diagram.xml");
+        assertNotNull(vf);
+        XmlFile xml = (XmlFile) 
PsiManager.getInstance(getProject()).findFile(vf);
+        assertNotNull(xml);
+
+        StrutsConfigDiagramModel before = ReadAction.nonBlocking(
+                () -> 
StrutsConfigDiagramModel.build(xml)).executeSynchronously();
+        assertNotNull(before);
+        StrutsDiagramNode resultBefore = before.getNodes().stream()
+                .filter(n -> n.getKind() == StrutsDiagramNode.Kind.RESULT)
+                .findFirst()
+                .orElseThrow();
+        assertNotNull(resultBefore.getNavigationPointer());
+
+        Document document = 
PsiDocumentManager.getInstance(getProject()).getDocument(xml);
+        assertNotNull(document);
+        WriteCommandAction.runWriteCommandAction(getProject(), () -> {
+            // Insert ahead of the only result so its textOffset changes.
+            String updated = document.getText().replace(
+                    "<action name=\"testAction\"",
+                    "<!-- pad -->\n    <action name=\"testAction\"");
+            document.setText(updated);
+            
PsiDocumentManager.getInstance(getProject()).commitDocument(document);
+        });
+
+        StrutsConfigDiagramModel after = ReadAction.nonBlocking(
+                () -> 
StrutsConfigDiagramModel.build(xml)).executeSynchronously();
+        assertNotNull(after);
+        StrutsDiagramNode resultAfter = after.getNodes().stream()
+                .filter(n -> n.getKind() == StrutsDiagramNode.Kind.RESULT)
+                .findFirst()
+                .orElseThrow();
+
+        assertFalse("Debug ids may still differ after offset shift",
+                resultBefore.getId().equals(resultAfter.getId()));
+        assertTrue("Pointer-based identity must survive offset shift",
+                resultBefore.equals(resultAfter));
+        assertEquals(resultBefore.hashCode(), resultAfter.hashCode());
+        assertEquals(resultBefore.getName(), resultAfter.getName());
+    }
+
+    public void testSamePathResultsRemainUnequalAcrossActions() {
+        createStrutsFileSet("struts-duplicate-names.xml");
+        VirtualFile vf = 
myFixture.findFileInTempDir("struts-duplicate-names.xml");
+        assertNotNull(vf);
+        XmlFile xml = (XmlFile) 
PsiManager.getInstance(getProject()).findFile(vf);
+        assertNotNull(xml);
+
+        // Two default results with different paths already exist; force a 
shared path on both.
+        Document document = 
PsiDocumentManager.getInstance(getProject()).getDocument(xml);
+        assertNotNull(document);
+        WriteCommandAction.runWriteCommandAction(getProject(), () -> {
+            String text = document.getText()
+                    .replace("/admin/index.jsp", "/shared/index.jsp")
+                    .replace("/public/index.jsp", "/shared/index.jsp");
+            document.setText(text);
+            
PsiDocumentManager.getInstance(getProject()).commitDocument(document);
+        });
+
+        StrutsConfigDiagramModel model = ReadAction.nonBlocking(
+                () -> 
StrutsConfigDiagramModel.build(xml)).executeSynchronously();
+        assertNotNull(model);
+        List<StrutsDiagramNode> shared = model.getNodes().stream()
+                .filter(n -> n.getKind() == StrutsDiagramNode.Kind.RESULT)
+                .filter(n -> n.getName().contains("/shared/index.jsp"))
+                .collect(Collectors.toList());
+        assertEquals(2, shared.size());
+        assertFalse(shared.get(0).equals(shared.get(1)));
+        assertFalse(shared.get(0).getId().equals(shared.get(1).getId()));
+    }
+
     // --- Edge structure tests ---
 
     public void testEdgesConnectPackagesToActionsAndActionsToResults() {
diff --git 
a/src/test/java/com/intellij/struts2/diagram/StrutsDiagramDataModelMappingTest.java
 
b/src/test/java/com/intellij/struts2/diagram/StrutsDiagramDataModelMappingTest.java
index b04056f..80cb871 100644
--- 
a/src/test/java/com/intellij/struts2/diagram/StrutsDiagramDataModelMappingTest.java
+++ 
b/src/test/java/com/intellij/struts2/diagram/StrutsDiagramDataModelMappingTest.java
@@ -37,15 +37,27 @@ import com.intellij.struts2.diagram.model.StrutsDiagramNode;
 import com.intellij.struts2.diagram.provider.StrutsDiagramDataModel;
 import com.intellij.struts2.diagram.provider.StrutsDiagramItem;
 import com.intellij.struts2.diagram.provider.StrutsDiagramProvider;
-import com.intellij.util.ui.UIUtil;
+import com.intellij.testFramework.LightProjectDescriptor;
 import org.jetbrains.annotations.NotNull;
 import org.jetbrains.annotations.Nullable;
 
+import java.util.Objects;
 import java.util.Set;
 import java.util.stream.Collectors;
 
 public class StrutsDiagramDataModelMappingTest extends 
BasicLightHighlightingTestCase {
 
+    @Override
+    protected @NotNull LightProjectDescriptor getProjectDescriptor() {
+        return WEB;
+    }
+
+    @Override
+    protected void performSetUp() {
+        myFixture.addFileToProject("pages/test.jsp", "<html></html>");
+        myFixture.addFileToProject("pages/delete.jsp", "<html></html>");
+    }
+
     @Override
     @NotNull
     protected String getTestDataLocation() {
@@ -124,7 +136,7 @@ public class StrutsDiagramDataModelMappingTest extends 
BasicLightHighlightingTes
         }
     }
 
-    public void testSameFileDomEventRefreshesLiveDataModel() throws 
InterruptedException {
+    public void testXmlEditDoesNotAutoRefreshUntilRefreshDataModel() {
         createStrutsFileSet("struts-diagram.xml");
         VirtualFile vf = myFixture.findFileInTempDir("struts-diagram.xml");
         assertNotNull(vf);
@@ -149,20 +161,143 @@ public class StrutsDiagramDataModelMappingTest extends 
BasicLightHighlightingTes
                 
PsiDocumentManager.getInstance(getProject()).commitDocument(document);
             });
 
-            long deadline = System.currentTimeMillis() + 10_000;
-            while (System.currentTimeMillis() < deadline
-                    && dataModel.getNodes().size() == initialNodeCount) {
-                UIUtil.dispatchAllInvocationEvents();
-                Thread.sleep(50);
-            }
+            assertEquals("Show Diagram must not auto-refresh on Dom edits",
+                    initialNodeCount, dataModel.getNodes().size());
 
-            assertTrue("Same-file DomEvent must refresh the live diagram data 
model",
+            ReadAction.run(dataModel::refreshDataModel);
+            assertTrue("Refresh Data Model must pick up XML edits",
                     dataModel.getNodes().size() > initialNodeCount);
         } finally {
             Disposer.dispose(dataModel);
         }
     }
 
+    public void testRefreshDataModelUpdatesResultPathAfterEdit() {
+        createStrutsFileSet("struts-diagram.xml");
+        VirtualFile vf = myFixture.findFileInTempDir("struts-diagram.xml");
+        assertNotNull(vf);
+        XmlFile xml = (XmlFile) 
PsiManager.getInstance(getProject()).findFile(vf);
+        assertNotNull(xml);
+
+        DiagramProvider<?> diagramProvider = 
DiagramProvider.findByID(StrutsDiagramProvider.ID);
+        assertInstanceOf(diagramProvider, StrutsDiagramProvider.class);
+        StrutsDiagramDataModel dataModel = new StrutsDiagramDataModel(
+                getProject(), (StrutsDiagramProvider) diagramProvider, 
StrutsDiagramItem.forFile(xml));
+        try {
+            ReadAction.run(dataModel::refreshDataModel);
+            StrutsDiagramItem beforeItem = resultItem(dataModel);
+            
assertTrue(beforeItem.getSnapshotNode().getName().contains("test.jsp"));
+
+            Document document = 
PsiDocumentManager.getInstance(getProject()).getDocument(xml);
+            assertNotNull(document);
+            WriteCommandAction.runWriteCommandAction(getProject(), () -> {
+                document.setText(document.getText().replace("/pages/test.jsp", 
"/pages/delete.jsp"));
+                
PsiDocumentManager.getInstance(getProject()).commitDocument(document);
+            });
+
+            assertTrue("Path edit must not auto-update the diagram model",
+                    
resultItem(dataModel).getSnapshotNode().getName().contains("test.jsp"));
+
+            ReadAction.run(dataModel::refreshDataModel);
+            StrutsDiagramItem afterItem = resultItem(dataModel);
+            
assertTrue(afterItem.getSnapshotNode().getName().contains("delete.jsp"));
+            
assertTrue(beforeItem.getSnapshotNode().equals(afterItem.getSnapshotNode()));
+            assertFalse("Presentable name is part of identifying equality for 
Refresh Data Model",
+                    beforeItem.equals(afterItem));
+            assertFalse(dataModel.getNodes().stream()
+                    .map(DiagramNode::getIdentifyingElement)
+                    .map(StrutsDiagramItem::getSnapshotNode)
+                    .filter(Objects::nonNull)
+                    .filter(n -> n.getKind() == StrutsDiagramNode.Kind.RESULT)
+                    .anyMatch(n -> n.getName().contains("test.jsp")));
+        } finally {
+            Disposer.dispose(dataModel);
+        }
+    }
+
+    public void testRefreshDataModelMapsCopyPastedResultsWithDistinctPaths() {
+        createStrutsFileSet("struts-diagram.xml");
+        VirtualFile vf = myFixture.findFileInTempDir("struts-diagram.xml");
+        assertNotNull(vf);
+        XmlFile xml = (XmlFile) 
PsiManager.getInstance(getProject()).findFile(vf);
+        assertNotNull(xml);
+
+        DiagramProvider<?> diagramProvider = 
DiagramProvider.findByID(StrutsDiagramProvider.ID);
+        assertInstanceOf(diagramProvider, StrutsDiagramProvider.class);
+        StrutsDiagramDataModel dataModel = new StrutsDiagramDataModel(
+                getProject(), (StrutsDiagramProvider) diagramProvider, 
StrutsDiagramItem.forFile(xml));
+        try {
+            ReadAction.run(dataModel::refreshDataModel);
+            assertEquals(1, resultNames(dataModel).size());
+
+            Document document = 
PsiDocumentManager.getInstance(getProject()).getDocument(xml);
+            assertNotNull(document);
+            WriteCommandAction.runWriteCommandAction(getProject(), () -> {
+                String updated = document.getText().replace(
+                        "<result>/pages/test.jsp</result>",
+                        "<result name=\"success\">/pages/test.jsp</result>\n" +
+                                "      <result 
name=\"delete\">/pages/delete.jsp</result>");
+                document.setText(updated);
+                
PsiDocumentManager.getInstance(getProject()).commitDocument(document);
+            });
+
+            assertEquals("Copy-paste must not auto-refresh the diagram model",
+                    1, resultNames(dataModel).size());
+
+            ReadAction.run(dataModel::refreshDataModel);
+            Set<String> resultNames = resultNames(dataModel);
+            assertTrue(resultNames.stream().anyMatch(n -> 
n.contains("test.jsp")));
+            assertTrue(resultNames.stream().anyMatch(n -> 
n.contains("delete.jsp")));
+
+            Set<String> edgeLabels = dataModel.getEdges().stream()
+                    .map(StrutsDiagramDataModelMappingTest::apiEdgeLabel)
+                    .collect(Collectors.toSet());
+            assertTrue(edgeLabels.contains("success"));
+            assertTrue(edgeLabels.contains("delete"));
+            assertApiEdgeTargetsResultPath(dataModel, "success", "test.jsp");
+            assertApiEdgeTargetsResultPath(dataModel, "delete", "delete.jsp");
+        } finally {
+            Disposer.dispose(dataModel);
+        }
+    }
+
+    private static @NotNull StrutsDiagramItem resultItem(@NotNull 
StrutsDiagramDataModel dataModel) {
+        return dataModel.getNodes().stream()
+                .map(DiagramNode::getIdentifyingElement)
+                .filter(item -> {
+                    StrutsDiagramNode snap = item.getSnapshotNode();
+                    return snap != null && snap.getKind() == 
StrutsDiagramNode.Kind.RESULT;
+                })
+                .findFirst()
+                .orElseThrow();
+    }
+
+    private static @NotNull Set<String> resultNames(@NotNull 
StrutsDiagramDataModel dataModel) {
+        return dataModel.getNodes().stream()
+                .map(DiagramNode::getIdentifyingElement)
+                .map(StrutsDiagramItem::getSnapshotNode)
+                .filter(Objects::nonNull)
+                .filter(n -> n.getKind() == StrutsDiagramNode.Kind.RESULT)
+                .map(StrutsDiagramNode::getName)
+                .collect(Collectors.toSet());
+    }
+
+    private static void assertApiEdgeTargetsResultPath(@NotNull 
StrutsDiagramDataModel dataModel,
+                                                       @NotNull String 
edgeLabel,
+                                                       @NotNull String 
expectedPath) {
+        StrutsDiagramNode target = dataModel.getEdges().stream()
+                .filter(edge -> edgeLabel.equals(apiEdgeLabel(edge)))
+                .map(DiagramEdge::getTarget)
+                .map(DiagramNode::getIdentifyingElement)
+                .map(StrutsDiagramItem::getSnapshotNode)
+                .filter(Objects::nonNull)
+                .findFirst()
+                .orElseThrow(() -> new AssertionError("Missing target for edge 
labeled " + edgeLabel));
+        assertEquals(StrutsDiagramNode.Kind.RESULT, target.getKind());
+        assertTrue("Edge labeled " + edgeLabel + " must target result path 
containing " + expectedPath
+                + ", got: " + target.getName(), 
target.getName().contains(expectedPath));
+    }
+
     private record EdgeTriple(@NotNull String sourceId, @NotNull String 
targetId, @NotNull String label) {
     }
 

Reply via email to