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 93e1501 feat(diagram): resolve chain/redirect results as
action-to-action edges (#76)
93e1501 is described below
commit 93e15012cdb6805f7742bf6ec15ed72b1dae8e3b
Author: Lukasz Lenart <[email protected]>
AuthorDate: Mon Apr 13 07:52:17 2026 +0200
feat(diagram): resolve chain/redirect results as action-to-action edges
(#76)
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 — 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>