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) {
}