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

lukaszlenart pushed a commit to branch feat/diagram-chain-redirect-edges
in repository https://gitbox.apache.org/repos/asf/struts-intellij-plugin.git

commit c5e25fe466452555231322f80ff5cab6d803eec5
Author: Lukasz Lenart <[email protected]>
AuthorDate: Thu Apr 9 07:42:08 2026 +0200

    feat(diagram): resolve chain/redirect results as action-to-action edges
    
    Extend the Struts config diagram to handle redirectAction, chain, and
    redirect-action result types. When a result target resolves to an action
    in the same file, the model emits an action→action edge instead of an
    unresolvable RESULT node. External/unresolvable targets fall back to a
    labeled RESULT node with a descriptive arrow label.
    
    - Add resolveChainOrRedirectTarget() helper that extracts target from
      tag body text or <param name="actionName">/<param name="namespace">
    - Refactor build() into a two-pass approach: first create nodes and map
      XmlTag→node for stable identity, then process results
    - Draw same-column (action→action) edges as dashed curves looping from
      the source's right edge below both nodes to the target's left edge
    - Build model asynchronously via ReadAction.nonBlocking to avoid
      write-intent lock violations on project open
    - Add test fixtures and tests for body-based and param-based redirect
    
    Made-with: Cursor
---
 .../fileEditor/Struts2DiagramFileEditor.java       |  42 +++---
 .../diagram/model/StrutsConfigDiagramModel.java    | 154 +++++++++++++++++++--
 .../diagram/ui/Struts2DiagramComponent.java        | 103 +++++++++++---
 .../diagram/StrutsConfigDiagramModelTest.java      |  75 ++++++++++
 src/test/testData/diagram/struts-redirect-body.xml |  45 ++++++
 .../testData/diagram/struts-redirect-param.xml     |  44 ++++++
 6 files changed, 405 insertions(+), 58 deletions(-)

diff --git 
a/src/main/java/com/intellij/struts2/diagram/fileEditor/Struts2DiagramFileEditor.java
 
b/src/main/java/com/intellij/struts2/diagram/fileEditor/Struts2DiagramFileEditor.java
index 6836e58..36449e1 100644
--- 
a/src/main/java/com/intellij/struts2/diagram/fileEditor/Struts2DiagramFileEditor.java
+++ 
b/src/main/java/com/intellij/struts2/diagram/fileEditor/Struts2DiagramFileEditor.java
@@ -17,7 +17,6 @@
 package com.intellij.struts2.diagram.fileEditor;
 
 import com.intellij.openapi.application.ReadAction;
-import com.intellij.openapi.progress.ProgressManager;
 import com.intellij.openapi.project.Project;
 import com.intellij.openapi.vfs.VirtualFile;
 import com.intellij.psi.PsiFile;
@@ -34,15 +33,17 @@ import javax.swing.*;
 /**
  * Read-only file editor that hosts the lightweight Struts config diagram.
  * <p>
- * Both initial creation and {@link #reset()} go through the same
- * {@link #buildModel()} path so the component always reflects the
- * current model state &mdash; including explicit empty and unavailable
- * fallbacks instead of stale or blank content.
+ * The component is created eagerly (with a {@code null} model) so that
+ * {@link #getPreferredFocusedComponent()} never triggers PSI/DOM access
+ * on the UI thread. The model is built via {@link ReadAction#nonBlocking}
+ * and applied asynchronously; both initial creation and {@link #reset()}
+ * go through the same path so the component always reflects the current
+ * model state — including explicit empty and unavailable fallbacks.
  */
 public class Struts2DiagramFileEditor extends PerspectiveFileEditor {
 
     private final XmlFile myXmlFile;
-    private Struts2DiagramComponent myComponent;
+    private final Struts2DiagramComponent myComponent;
 
     public Struts2DiagramFileEditor(final Project project, final VirtualFile 
file) {
         super(project, file);
@@ -50,6 +51,8 @@ public class Struts2DiagramFileEditor extends 
PerspectiveFileEditor {
         final PsiFile psiFile = getPsiFile();
         assert psiFile instanceof XmlFile;
         myXmlFile = (XmlFile) psiFile;
+        myComponent = new Struts2DiagramComponent(null);
+        scheduleModelBuild();
     }
 
     @Override
@@ -65,13 +68,13 @@ public class Struts2DiagramFileEditor extends 
PerspectiveFileEditor {
     @Override
     @NotNull
     protected JComponent createCustomComponent() {
-        return getDiagramComponent();
+        return myComponent;
     }
 
     @Override
     @Nullable
     public JComponent getPreferredFocusedComponent() {
-        return getDiagramComponent();
+        return myComponent;
     }
 
     @Override
@@ -80,7 +83,7 @@ public class Struts2DiagramFileEditor extends 
PerspectiveFileEditor {
 
     @Override
     public void reset() {
-        getDiagramComponent().rebuild(buildModel());
+        scheduleModelBuild();
     }
 
     @Override
@@ -89,20 +92,11 @@ public class Struts2DiagramFileEditor extends 
PerspectiveFileEditor {
         return "Diagram";
     }
 
-    private @Nullable StrutsConfigDiagramModel buildModel() {
-        final StrutsConfigDiagramModel[] model = {null};
-        ProgressManager.getInstance().runProcessWithProgressSynchronously(
-                () -> model[0] = ReadAction.nonBlocking(
-                        () -> StrutsConfigDiagramModel.build(myXmlFile))
-                        .executeSynchronously(),
-                "Building Diagram", false, myXmlFile.getProject());
-        return model[0];
-    }
-
-    private Struts2DiagramComponent getDiagramComponent() {
-        if (myComponent == null) {
-            myComponent = new Struts2DiagramComponent(buildModel());
-        }
-        return myComponent;
+    private void scheduleModelBuild() {
+        ReadAction.nonBlocking(() -> StrutsConfigDiagramModel.build(myXmlFile))
+                .expireWith(this)
+                
.finishOnUiThread(com.intellij.openapi.application.ModalityState.defaultModalityState(),
+                        myComponent::rebuild)
+                
.submit(com.intellij.util.concurrency.AppExecutorUtil.getAppExecutorService());
     }
 }
diff --git 
a/src/main/java/com/intellij/struts2/diagram/model/StrutsConfigDiagramModel.java
 
b/src/main/java/com/intellij/struts2/diagram/model/StrutsConfigDiagramModel.java
index 8cca983..081ac4f 100644
--- 
a/src/main/java/com/intellij/struts2/diagram/model/StrutsConfigDiagramModel.java
+++ 
b/src/main/java/com/intellij/struts2/diagram/model/StrutsConfigDiagramModel.java
@@ -24,13 +24,17 @@ import com.intellij.psi.SmartPointerManager;
 import com.intellij.psi.SmartPsiElementPointer;
 import com.intellij.psi.xml.XmlElement;
 import com.intellij.psi.xml.XmlFile;
+import com.intellij.psi.xml.XmlTag;
 import com.intellij.struts2.Struts2Icons;
 import com.intellij.struts2.diagram.presentation.StrutsDiagramPresentation;
+import com.intellij.struts2.dom.params.Param;
 import com.intellij.struts2.dom.struts.StrutsRoot;
 import com.intellij.struts2.dom.struts.action.Action;
 import com.intellij.struts2.dom.struts.action.Result;
+import com.intellij.struts2.dom.struts.impl.path.ResultTypeResolver;
 import com.intellij.struts2.dom.struts.model.StrutsManager;
 import com.intellij.struts2.dom.struts.model.StrutsModel;
+import com.intellij.struts2.dom.struts.strutspackage.ResultType;
 import com.intellij.struts2.dom.struts.strutspackage.StrutsPackage;
 import com.intellij.util.xml.DomElement;
 import com.intellij.util.xml.DomFileElement;
@@ -49,6 +53,12 @@ import java.util.*;
  * inherited framework packages (e.g. {@code struts-default}) are not expanded 
into
  * full diagram nodes — their names appear only in the package tooltip's 
"Extends" line.
  * <p>
+ * For results whose effective type is {@code chain}, {@code redirectAction}, 
or
+ * {@code redirect-action}, the model resolves the target action (from tag 
body text
+ * or {@code actionName}/{@code namespace} params). When the target action is 
declared
+ * in the same file, an action→action edge is emitted instead of a separate 
result
+ * node. External or unresolvable targets fall back to a labeled {@code 
Kind.RESULT} node.
+ * <p>
  * <b>Must be called under a read action.</b> All DOM/PSI access (tooltip 
computation,
  * navigation pointer creation) happens here so that Swing event handlers on 
the EDT
  * never need to touch PSI directly.
@@ -81,9 +91,17 @@ public final class StrutsConfigDiagramModel {
         List<StrutsPackage> packages = getLocalPackages(xmlFile);
         if (packages == null) return null;
 
+        StrutsModel strutsModel = 
StrutsManager.getInstance(xmlFile.getProject()).getModelByFile(xmlFile);
         SmartPointerManager pointerManager = 
SmartPointerManager.getInstance(xmlFile.getProject());
         StrutsConfigDiagramModel model = new StrutsConfigDiagramModel();
 
+        // Pass 1: create package and action nodes; collect XmlTag→node mapping
+        // Use XmlTag keys rather than Action DOM proxies, because 
findActionsByName
+        // may return different proxy instances for the same underlying XML 
element.
+        Map<XmlTag, StrutsDiagramNode> actionNodeMap = new IdentityHashMap<>();
+        record PendingResult(StrutsDiagramNode actionNode, Result result, 
String currentNamespace) {}
+        List<PendingResult> pendingResults = new ArrayList<>();
+
         for (StrutsPackage strutsPackage : packages) {
             String pkgName = 
Objects.toString(strutsPackage.getName().getStringValue(), UNNAMED);
             StrutsDiagramNode pkgNode = createNode(
@@ -91,6 +109,7 @@ public final class StrutsConfigDiagramModel {
                     strutsPackage, pointerManager);
             model.nodes.add(pkgNode);
 
+            String namespace = strutsPackage.searchNamespace();
             for (Action action : strutsPackage.getActions()) {
                 String actionName = 
Objects.toString(action.getName().getStringValue(), UNNAMED);
                 StrutsDiagramNode actionNode = createNode(
@@ -98,25 +117,138 @@ public final class StrutsConfigDiagramModel {
                         action, pointerManager);
                 model.nodes.add(actionNode);
                 model.edges.add(new StrutsDiagramEdge(pkgNode, actionNode, 
""));
+                XmlTag actionTag = action.getXmlTag();
+                if (actionTag != null) {
+                    actionNodeMap.put(actionTag, actionNode);
+                }
 
                 for (Result result : action.getResults()) {
-                    PathReference pathRef = result.getValue();
-                    String path = pathRef != null ? pathRef.getPath() : 
UNRESOLVED_RESULT;
-                    Icon resultIcon = resolveResultIcon(result);
-                    StrutsDiagramNode resultNode = createNode(
-                            StrutsDiagramNode.Kind.RESULT, path, resultIcon,
-                            result, pointerManager);
-                    model.nodes.add(resultNode);
-
-                    String resultName = result.getName().getStringValue();
-                    model.edges.add(new StrutsDiagramEdge(actionNode, 
resultNode,
-                            resultName != null ? resultName : 
Result.DEFAULT_NAME));
+                    pendingResults.add(new PendingResult(actionNode, result, 
namespace));
+                }
+            }
+        }
+
+        // Pass 2: process results — chain/redirect targets become 
action→action edges
+        for (PendingResult pr : pendingResults) {
+            String resultName = pr.result.getName().getStringValue();
+            String edgeLabel = resultName != null ? resultName : 
Result.DEFAULT_NAME;
+
+            Action targetAction = resolveChainOrRedirectTarget(pr.result, 
strutsModel, pr.currentNamespace);
+            if (targetAction != null) {
+                XmlTag targetTag = targetAction.getXmlTag();
+                StrutsDiagramNode targetNode = targetTag != null ? 
actionNodeMap.get(targetTag) : null;
+                if (targetNode != null) {
+                    // Target is in the same file — direct action→action edge
+                    model.edges.add(new StrutsDiagramEdge(pr.actionNode, 
targetNode, edgeLabel));
+                    continue;
                 }
+                // Target is in another file — show as labeled result node
+                String targetLabel = formatExternalActionLabel(targetAction);
+                StrutsDiagramNode resultNode = createNode(
+                        StrutsDiagramNode.Kind.RESULT, targetLabel, 
Struts2Icons.Action,
+                        pr.result, pointerManager);
+                model.nodes.add(resultNode);
+                model.edges.add(new StrutsDiagramEdge(pr.actionNode, 
resultNode, edgeLabel));
+                continue;
             }
+
+            // Non-chain/redirect or unresolvable — standard result node
+            PathReference pathRef = pr.result.getValue();
+            String path = pathRef != null ? pathRef.getPath() : 
UNRESOLVED_RESULT;
+            Icon resultIcon = resolveResultIcon(pr.result);
+            StrutsDiagramNode resultNode = createNode(
+                    StrutsDiagramNode.Kind.RESULT, path, resultIcon,
+                    pr.result, pointerManager);
+            model.nodes.add(resultNode);
+            model.edges.add(new StrutsDiagramEdge(pr.actionNode, resultNode, 
edgeLabel));
         }
         return model;
     }
 
+    /**
+     * Resolves the target {@link Action} for chain/redirect result types.
+     * Mirrors the resolution logic of
+     * {@link 
com.intellij.struts2.dom.struts.impl.path.ActionChainOrRedirectResultContributor}.
+     *
+     * @return the uniquely resolved action, or {@code null} if the result is 
not a
+     *         chain/redirect type or the target cannot be resolved 
unambiguously.
+     */
+    static @Nullable Action resolveChainOrRedirectTarget(@NotNull Result 
result,
+                                                         @Nullable StrutsModel 
strutsModel,
+                                                         @NotNull String 
currentNamespace) {
+        if (!result.isValid()) return null;
+
+        String typeName = null;
+        ResultType effectiveType = result.getEffectiveResultType();
+        if (effectiveType != null) {
+            typeName = effectiveType.getName().getStringValue();
+        }
+        if (typeName == null) {
+            // Fall back to the raw XML attribute when the ResultType DOM 
can't be resolved
+            // (e.g. the result-type definition is in struts-default and not 
in the model)
+            typeName = result.getType().getStringValue();
+        }
+        if (typeName == null || 
!ResultTypeResolver.isChainOrRedirectType(typeName)) return null;
+
+        // Determine action path: prefer tag body, fall back to <param 
name="actionName">
+        String actionPath = null;
+        XmlTag xmlTag = result.getXmlTag();
+        if (xmlTag != null) {
+            String bodyText = xmlTag.getValue().getTrimmedText();
+            if (!bodyText.isEmpty()) {
+                actionPath = bodyText;
+            }
+        }
+        if (actionPath == null) {
+            actionPath = getParamValue(result, "actionName");
+        }
+        if (actionPath == null || actionPath.isEmpty()) return null;
+
+        // Strip query parameters (e.g. "actionPath2?myParam=myValue")
+        int queryIdx = actionPath.indexOf('?');
+        if (queryIdx != -1) {
+            actionPath = actionPath.substring(0, queryIdx);
+        }
+
+        // Determine namespace: from path prefix, explicit param, or current 
package
+        String namespace = currentNamespace;
+        int lastSlash = actionPath.lastIndexOf('/');
+        if (lastSlash != -1) {
+            namespace = actionPath.substring(0, lastSlash);
+            actionPath = actionPath.substring(lastSlash + 1);
+        } else {
+            String nsParam = getParamValue(result, "namespace");
+            if (nsParam != null && !nsParam.isEmpty()) {
+                namespace = nsParam;
+            }
+        }
+
+        if (strutsModel == null) return null;
+        List<Action> actions = strutsModel.findActionsByName(actionPath, 
namespace);
+        return actions.size() == 1 ? actions.get(0) : null;
+    }
+
+    private static @Nullable String getParamValue(@NotNull Result result, 
@NotNull String paramName) {
+        for (Param param : result.getParams()) {
+            XmlTag tag = param.getXmlTag();
+            if (tag != null && 
paramName.equals(tag.getAttributeValue("name"))) {
+                String value = tag.getValue().getTrimmedText();
+                if (!value.isEmpty()) return value;
+            }
+        }
+        return null;
+    }
+
+    private static @NotNull String formatExternalActionLabel(@NotNull Action 
action) {
+        String ns = action.getNamespace();
+        String name = action.getName().getStringValue();
+        if (name == null) name = UNNAMED;
+        if (ns != null && !StrutsPackage.DEFAULT_NAMESPACE.equals(ns)) {
+            return "\u2192 " + ns + "/" + name;
+        }
+        return "\u2192 " + name;
+    }
+
     /**
      * Resolves the list of packages local to the given file.
      * Finds the {@link StrutsRoot} for the current file from the model's 
individual
diff --git 
a/src/main/java/com/intellij/struts2/diagram/ui/Struts2DiagramComponent.java 
b/src/main/java/com/intellij/struts2/diagram/ui/Struts2DiagramComponent.java
index a0342cb..6650747 100644
--- a/src/main/java/com/intellij/struts2/diagram/ui/Struts2DiagramComponent.java
+++ b/src/main/java/com/intellij/struts2/diagram/ui/Struts2DiagramComponent.java
@@ -134,8 +134,14 @@ public final class Struts2DiagramComponent extends JPanel {
         colX += NODE_WIDTH + H_GAP;
         maxY = Math.max(maxY, placeColumn(results, colX, PADDING));
 
+        boolean hasSameColumnEdges = edges.stream().anyMatch(e -> {
+            Rectangle s = nodeBounds.get(e.getSource());
+            Rectangle t = nodeBounds.get(e.getTarget());
+            return s != null && t != null && s.x == t.x;
+        });
+        int extraHeight = hasSameColumnEdges ? V_GAP + NODE_HEIGHT : 0;
         int totalWidth = colX + NODE_WIDTH + PADDING;
-        int totalHeight = maxY + PADDING;
+        int totalHeight = maxY + extraHeight + PADDING;
         setPreferredSize(new Dimension(totalWidth, totalHeight));
     }
 
@@ -194,32 +200,83 @@ public final class Struts2DiagramComponent extends JPanel 
{
             Rectangle tgtRect = nodeBounds.get(edge.getTarget());
             if (srcRect == null || tgtRect == null) continue;
 
-            int x1 = srcRect.x + srcRect.width;
-            int y1 = srcRect.y + srcRect.height / 2;
-            int x2 = tgtRect.x;
-            int y2 = tgtRect.y + tgtRect.height / 2;
-
-            g2.setColor(JBColor.namedColor("Diagram.edgeColor", JBColor.GRAY));
-            int midX = (x1 + x2) / 2;
-            Path2D path = new Path2D.Float();
-            path.moveTo(x1, y1);
-            path.curveTo(midX, y1, midX, y2, x2, y2);
-            g2.draw(path);
-
-            drawArrowHead(g2, midX, y2, x2, y2);
-
-            String label = edge.getLabel();
-            if (!label.isEmpty()) {
-                g2.setFont(JBUI.Fonts.smallFont());
-                g2.setColor(JBColor.namedColor("Diagram.edgeLabelColor", 
JBColor.DARK_GRAY));
-                FontMetrics fm = g2.getFontMetrics();
-                int labelX = midX - fm.stringWidth(label) / 2;
-                int labelY = (y1 + y2) / 2 - 3;
-                g2.drawString(label, labelX, labelY);
+            boolean sameColumn = srcRect.x == tgtRect.x;
+            if (sameColumn) {
+                paintSameColumnEdge(g2, edge, srcRect, tgtRect);
+            } else {
+                paintCrossColumnEdge(g2, edge, srcRect, tgtRect);
             }
         }
     }
 
+    private void paintCrossColumnEdge(Graphics2D g2, StrutsDiagramEdge edge,
+                                       Rectangle srcRect, Rectangle tgtRect) {
+        int x1 = srcRect.x + srcRect.width;
+        int y1 = srcRect.y + srcRect.height / 2;
+        int x2 = tgtRect.x;
+        int y2 = tgtRect.y + tgtRect.height / 2;
+
+        g2.setColor(JBColor.namedColor("Diagram.edgeColor", JBColor.GRAY));
+        int midX = (x1 + x2) / 2;
+        Path2D path = new Path2D.Float();
+        path.moveTo(x1, y1);
+        path.curveTo(midX, y1, midX, y2, x2, y2);
+        g2.draw(path);
+
+        drawArrowHead(g2, midX, y2, x2, y2);
+
+        String label = edge.getLabel();
+        if (!label.isEmpty()) {
+            g2.setFont(JBUI.Fonts.smallFont());
+            g2.setColor(JBColor.namedColor("Diagram.edgeLabelColor", 
JBColor.DARK_GRAY));
+            FontMetrics fm = g2.getFontMetrics();
+            int labelX = midX - fm.stringWidth(label) / 2;
+            int labelY = (y1 + y2) / 2 - 3;
+            g2.drawString(label, labelX, labelY);
+        }
+    }
+
+    /**
+     * Draws an edge between two nodes in the same column (e.g. action→action 
for
+     * chain/redirect results). Departs from the source's <b>right</b> edge, 
loops
+     * below both nodes, and arrives at the target's <b>left</b> edge — 
visually
+     * distinguishing it from the left-to-right action→result flow. Uses a 
dashed
+     * stroke for additional contrast.
+     */
+    private void paintSameColumnEdge(Graphics2D g2, StrutsDiagramEdge edge,
+                                      Rectangle srcRect, Rectangle tgtRect) {
+        // Start at source right edge, end at target left edge
+        int x1 = srcRect.x + srcRect.width;
+        int y1 = srcRect.y + srcRect.height / 2;
+        int x2 = tgtRect.x;
+        int y2 = tgtRect.y + tgtRect.height / 2;
+
+        // Loop below both nodes
+        int bottomY = Math.max(srcRect.y + srcRect.height, tgtRect.y + 
tgtRect.height) + V_GAP + NODE_HEIGHT / 2;
+
+        g2.setColor(JBColor.namedColor("Diagram.edgeColor", JBColor.GRAY));
+        g2.setStroke(new BasicStroke(1.2f, BasicStroke.CAP_BUTT, 
BasicStroke.JOIN_MITER,
+                10.0f, new float[]{6.0f, 4.0f}, 0.0f));
+        Path2D path = new Path2D.Float();
+        path.moveTo(x1, y1);
+        path.curveTo(x1 + H_GAP / 3, y1, x1 + H_GAP / 3, bottomY, (x1 + x2) / 
2, bottomY);
+        path.curveTo(x2 - H_GAP / 3, bottomY, x2 - H_GAP / 3, y2, x2, y2);
+        g2.draw(path);
+        g2.setStroke(new BasicStroke(1.2f));
+
+        drawArrowHead(g2, x2 - H_GAP / 3, y2, x2, y2);
+
+        String label = edge.getLabel();
+        if (!label.isEmpty()) {
+            g2.setFont(JBUI.Fonts.smallFont());
+            g2.setColor(JBColor.namedColor("Diagram.edgeLabelColor", 
JBColor.DARK_GRAY));
+            FontMetrics fm = g2.getFontMetrics();
+            int labelX = (x1 + x2) / 2 - fm.stringWidth(label) / 2;
+            int labelY = bottomY + fm.getAscent() + 2;
+            g2.drawString(label, labelX, labelY);
+        }
+    }
+
     private static void drawArrowHead(Graphics2D g2, int fromX, int fromY, int 
toX, int toY) {
         double angle = Math.atan2(toY - fromY, toX - fromX);
         int arrowLen = 8;
diff --git 
a/src/test/java/com/intellij/struts2/diagram/StrutsConfigDiagramModelTest.java 
b/src/test/java/com/intellij/struts2/diagram/StrutsConfigDiagramModelTest.java
index da43a4d..22781d3 100644
--- 
a/src/test/java/com/intellij/struts2/diagram/StrutsConfigDiagramModelTest.java
+++ 
b/src/test/java/com/intellij/struts2/diagram/StrutsConfigDiagramModelTest.java
@@ -357,6 +357,81 @@ public class StrutsConfigDiagramModelTest extends 
BasicLightHighlightingTestCase
         assertTrue("Should have explicit 'input' label, got: " + labels, 
labels.contains("input"));
     }
 
+    // --- Chain/redirect result type tests ---
+
+    public void testRedirectActionBodyCreatesActionToActionEdge() {
+        createStrutsFileSet("struts-redirect-body.xml");
+
+        VirtualFile vf = 
myFixture.findFileInTempDir("struts-redirect-body.xml");
+        assertNotNull(vf);
+        PsiFile psi = PsiManager.getInstance(getProject()).findFile(vf);
+        assertInstanceOf(psi, XmlFile.class);
+
+        StrutsConfigDiagramModel model = ReadAction.nonBlocking(
+                () -> StrutsConfigDiagramModel.build((XmlFile) 
psi)).executeSynchronously();
+        assertNotNull(model);
+
+        // Two packages, two actions, one dispatcher result (dashboard's JSP)
+        List<StrutsDiagramNode> actions = model.getNodes().stream()
+                .filter(n -> n.getKind() == StrutsDiagramNode.Kind.ACTION)
+                .collect(Collectors.toList());
+        assertEquals("Should have 2 actions (index, dashboard)", 2, 
actions.size());
+
+        // The redirectAction result should NOT produce a RESULT node 
(resolved same-file)
+        List<StrutsDiagramNode> results = model.getNodes().stream()
+                .filter(n -> n.getKind() == StrutsDiagramNode.Kind.RESULT)
+                .collect(Collectors.toList());
+        assertEquals("Only dashboard's JSP result should be a RESULT node", 1, 
results.size());
+
+        List<StrutsDiagramEdge> actionToActionEdges = model.getEdges().stream()
+                .filter(e -> e.getSource().getKind() == 
StrutsDiagramNode.Kind.ACTION
+                        && e.getTarget().getKind() == 
StrutsDiagramNode.Kind.ACTION)
+                .collect(Collectors.toList());
+        assertEquals("Should have one action→action edge", 1, 
actionToActionEdges.size());
+
+        StrutsDiagramEdge redirectEdge = actionToActionEdges.get(0);
+        assertEquals("index", redirectEdge.getSource().getName());
+        assertEquals("dashboard", redirectEdge.getTarget().getName());
+        assertEquals("success", redirectEdge.getLabel());
+    }
+
+    public void testRedirectActionParamCreatesActionToActionEdge() {
+        createStrutsFileSet("struts-redirect-param.xml");
+
+        VirtualFile vf = 
myFixture.findFileInTempDir("struts-redirect-param.xml");
+        assertNotNull(vf);
+        PsiFile psi = PsiManager.getInstance(getProject()).findFile(vf);
+        assertInstanceOf(psi, XmlFile.class);
+
+        StrutsConfigDiagramModel model = ReadAction.nonBlocking(
+                () -> StrutsConfigDiagramModel.build((XmlFile) 
psi)).executeSynchronously();
+        assertNotNull(model);
+
+        // One package, two actions (index, upload)
+        List<StrutsDiagramNode> actions = model.getNodes().stream()
+                .filter(n -> n.getKind() == StrutsDiagramNode.Kind.ACTION)
+                .collect(Collectors.toList());
+        assertEquals("Should have 2 actions (index, upload)", 2, 
actions.size());
+
+        // The param-only redirectAction should NOT produce a RESULT node
+        List<StrutsDiagramNode> results = model.getNodes().stream()
+                .filter(n -> n.getKind() == StrutsDiagramNode.Kind.RESULT)
+                .collect(Collectors.toList());
+        assertEquals("Only upload's JSP result should be a RESULT node", 1, 
results.size());
+
+        // action→action edge from index to upload
+        List<StrutsDiagramEdge> actionToActionEdges = model.getEdges().stream()
+                .filter(e -> e.getSource().getKind() == 
StrutsDiagramNode.Kind.ACTION
+                        && e.getTarget().getKind() == 
StrutsDiagramNode.Kind.ACTION)
+                .collect(Collectors.toList());
+        assertEquals("Should have one action→action edge", 1, 
actionToActionEdges.size());
+
+        StrutsDiagramEdge redirectEdge = actionToActionEdges.get(0);
+        assertEquals("index", redirectEdge.getSource().getName());
+        assertEquals("upload", redirectEdge.getTarget().getName());
+        assertEquals("success", redirectEdge.getLabel());
+    }
+
     public void testEdgesInDuplicateNameFileAreCorrectlyWired() {
         createStrutsFileSet("struts-duplicate-names.xml");
 
diff --git a/src/test/testData/diagram/struts-redirect-body.xml 
b/src/test/testData/diagram/struts-redirect-body.xml
new file mode 100644
index 0000000..54798dd
--- /dev/null
+++ b/src/test/testData/diagram/struts-redirect-body.xml
@@ -0,0 +1,45 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+
+<!--
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements.  See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership.  The ASF licenses this file
+to you under the Apache License, Version 2.0 (the
+"License"); you may not use this file except in compliance
+with the License.  You may obtain a copy of the License at
+
+  http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an
+"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+KIND, either express or implied.  See the License for the
+specific language governing permissions and limitations
+under the License.
+-->
+
+<!DOCTYPE struts PUBLIC
+    "-//Apache Software Foundation//DTD Struts Configuration 2.0//EN"
+    "http://struts.apache.org/dtds/struts-2.0.dtd";>
+
+<struts>
+
+  <package name="pkgA" namespace="/a">
+    <result-types>
+      <result-type name="redirectAction" 
class="org.apache.struts2.result.ServletActionRedirectResult"/>
+      <result-type name="chain" class="org.apache.struts2.result.ChainResult"/>
+    </result-types>
+
+    <action name="index">
+      <result type="redirectAction">/b/dashboard</result>
+    </action>
+  </package>
+
+  <package name="pkgB" namespace="/b">
+    <action name="dashboard" class="com.example.DashboardAction">
+      <result>/pages/dashboard.jsp</result>
+    </action>
+  </package>
+
+</struts>
diff --git a/src/test/testData/diagram/struts-redirect-param.xml 
b/src/test/testData/diagram/struts-redirect-param.xml
new file mode 100644
index 0000000..a4ddf08
--- /dev/null
+++ b/src/test/testData/diagram/struts-redirect-param.xml
@@ -0,0 +1,44 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+
+<!--
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements.  See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership.  The ASF licenses this file
+to you under the Apache License, Version 2.0 (the
+"License"); you may not use this file except in compliance
+with the License.  You may obtain a copy of the License at
+
+  http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an
+"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+KIND, either express or implied.  See the License for the
+specific language governing permissions and limitations
+under the License.
+-->
+
+<!DOCTYPE struts PUBLIC
+    "-//Apache Software Foundation//DTD Struts Configuration 2.0//EN"
+    "http://struts.apache.org/dtds/struts-2.0.dtd";>
+
+<struts>
+
+  <package name="default" namespace="/" extends="struts-default">
+
+    <default-action-ref name="index"/>
+
+    <action name="index">
+      <result type="redirectAction">
+        <param name="actionName">upload</param>
+      </result>
+    </action>
+
+    <action name="upload" class="org.apache.struts.example.UploadAction">
+      <result name="input">/WEB-INF/upload.jsp</result>
+    </action>
+
+  </package>
+
+</struts>

Reply via email to