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 956eb45 feat: add read-only Diagram tab for struts.xml config
visualization (#65)
956eb45 is described below
commit 956eb454f271814a12f42dd7c0ad941d6e1e6b19
Author: Lukasz Lenart <[email protected]>
AuthorDate: Wed Apr 8 06:48:01 2026 +0200
feat: add read-only Diagram tab for struts.xml config visualization (#65)
* feat: add read-only Diagram tab for struts.xml config visualization
Introduce a lightweight Swing-based Diagram tab alongside the existing
(disabled-by-default) Graph tab. The new tab renders packages, actions,
and results in a hierarchical layout with tooltips and click-to-navigate,
without depending on the deprecated GraphBuilder APIs.
New components:
- diagram/model: toolkit-neutral DTOs (node, edge, snapshot builder)
- diagram/presentation: reusable tooltip/navigation helpers
- diagram/ui: custom Swing renderer with hover and double-click support
- diagram/fileEditor: PerspectiveFileEditorProvider + editor registration
Made-with: Cursor
* test: add Diagram file editor provider tests and migration boundary docs
Add focused tests for Struts2DiagramFileEditorProvider verifying it
accepts struts.xml in file sets and rejects plain XML, JSP, and Java
files. Document the toolkit-neutral model layer as the migration
boundary between the current Swing renderer and a future Diagrams API.
Made-with: Cursor
* fix: resolve threading violations in Diagram tab
Precompute tooltip HTML and navigation pointers during model snapshot
creation (under read action) so Swing event handlers on the EDT never
access DOM/PSI directly.
Changes:
- StrutsDiagramNode: replace DomElement with precomputed tooltipHtml
and SmartPsiElementPointer for navigation
- StrutsConfigDiagramModel.build(): compute tooltips and create smart
pointers during snapshot (must be called under read action)
- StrutsDiagramPresentation: split into computeTooltipHtml (build-time)
and navigateToElement (EDT-safe via ReadAction.nonBlocking)
- Struts2DiagramFileEditor: wrap build() in ReadAction.nonBlocking
with progress dialog for initial load
- Struts2DiagramComponent: use precomputed node.getTooltipHtml()
instead of calling into DOM on hover
Made-with: Cursor
* feat(diagram): show local packages only and add early DTD validation
Diagram model now resolves packages from the file-local StrutsRoot DOM
instead of the merged StrutsModel, so only the current struts.xml's
packages/actions/results are displayed.
DTD validation is extracted into a shared StrutsDtdValidator helper and
wired into Struts2ModelInspection.checkFileElement() as a file-level
warning, so users see http:// vs https:// issues while editing rather
than only when opening the Diagram tab. The Diagram-specific
notification is removed since the inspection now covers it earlier.
- Add StrutsDtdValidator shared helper for DTD URI checks
- Add DTD check to Struts2ModelInspection with WARNING severity
- Remove Diagram-side DTD notification (replaced by inspection)
- Handle null model in Struts2DiagramFileEditor.reset()
- Add StrutsConfigDiagramModelTest for local-file filtering
- Add StrutsDtdValidatorTest for DTD validation logic
- Add highlighting test for valid https:// DTD
Made-with: Cursor
* fix(diagram): use EDT-safe read action for node navigation
Replace ReadAction.nonBlocking().executeSynchronously() with
Application.runReadAction(Computable) in navigateToElement() so
double-clicking a Diagram node no longer throws an EDT assertion.
The NBRA path asserts background-thread usage, but the mouse handler
always runs on the EDT. The regular runReadAction is appropriate here
since the work is just a SmartPsiElementPointer dereference.
Adds a regression test verifying pointer resolution through the
same Application.runReadAction path.
Made-with: Cursor
* feat(diagram): harden MVP with explicit states and clearer fallbacks
Unify the editor rebuild path so initial load and reset() always
reflect the current model state instead of retaining stale content.
Add LOADED/EMPTY/UNAVAILABLE states to the diagram component with
centered placeholder messages for non-loaded states. Replace raw
??? placeholders with descriptive labels ((unresolved path),
(unnamed), (unknown type)). Extend tests to cover empty files,
null models, rebuild transitions, and label clarity.
Made-with: Cursor
* fix(diagram): use stable element identity for diagram nodes
Replace display-based equals/hashCode (kind+name) in StrutsDiagramNode
with a stable ID derived from the backing XML element's text offset.
This prevents node collisions when duplicate action names or result
paths exist across packages.
Add focused tests for duplicate-name regression, edge topology and
labels, editor lifecycle (creation and reset), and DTD validator
classification outcomes.
Made-with: Cursor
---
CHANGELOG.md | 4 +
.../fileEditor/Struts2DiagramFileEditor.java | 108 ++++++
.../Struts2DiagramFileEditorProvider.java | 92 +++++
.../diagram/model/StrutsConfigDiagramModel.java | 192 ++++++++++
.../struts2/diagram/model/StrutsDiagramEdge.java | 55 +++
.../struts2/diagram/model/StrutsDiagramNode.java | 92 +++++
.../struts2/diagram/model/package-info.java | 41 +++
.../presentation/StrutsDiagramPresentation.java | 127 +++++++
.../diagram/ui/Struts2DiagramComponent.java | 282 +++++++++++++++
src/main/resources/META-INF/plugin.xml | 1 +
.../Struts2DiagramFileEditorProviderTest.java | 95 +++++
.../diagram/StrutsConfigDiagramModelTest.java | 402 +++++++++++++++++++++
.../dom/inspection/StrutsDtdValidatorTest.java | 108 ++++--
.../struts2/dom/struts/StrutsHighlightingTest.java | 1 +
src/test/testData/diagram/struts-diagram.xml | 36 ++
.../testData/diagram/struts-duplicate-names.xml | 41 +++
src/test/testData/diagram/struts-empty.xml | 27 ++
src/test/testData/diagram/struts-local-a.xml | 40 ++
src/test/testData/diagram/struts-local-b.xml | 36 ++
src/test/testData/diagram/struts-unresolved.xml | 37 ++
20 files changed, 1786 insertions(+), 31 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 73c9120..24e2f36 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,10 @@
## [Unreleased]
+### Added
+
+- Add read-only Diagram tab for struts.xml files with lightweight config
visualization (packages, actions, results)
+
### Changed
- Disable the deprecated Graph editor tab by default; opt in with JVM property
`-Dcom.intellij.struts2.enableGraphEditor=true`
diff --git
a/src/main/java/com/intellij/struts2/diagram/fileEditor/Struts2DiagramFileEditor.java
b/src/main/java/com/intellij/struts2/diagram/fileEditor/Struts2DiagramFileEditor.java
new file mode 100644
index 0000000..6836e58
--- /dev/null
+++
b/src/main/java/com/intellij/struts2/diagram/fileEditor/Struts2DiagramFileEditor.java
@@ -0,0 +1,108 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package 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;
+import com.intellij.psi.xml.XmlFile;
+import com.intellij.struts2.diagram.model.StrutsConfigDiagramModel;
+import com.intellij.struts2.diagram.ui.Struts2DiagramComponent;
+import com.intellij.util.xml.DomElement;
+import com.intellij.util.xml.ui.PerspectiveFileEditor;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+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.
+ */
+public class Struts2DiagramFileEditor extends PerspectiveFileEditor {
+
+ private final XmlFile myXmlFile;
+ private Struts2DiagramComponent myComponent;
+
+ public Struts2DiagramFileEditor(final Project project, final VirtualFile
file) {
+ super(project, file);
+
+ final PsiFile psiFile = getPsiFile();
+ assert psiFile instanceof XmlFile;
+ myXmlFile = (XmlFile) psiFile;
+ }
+
+ @Override
+ @Nullable
+ protected DomElement getSelectedDomElement() {
+ return null;
+ }
+
+ @Override
+ protected void setSelectedDomElement(final DomElement domElement) {
+ }
+
+ @Override
+ @NotNull
+ protected JComponent createCustomComponent() {
+ return getDiagramComponent();
+ }
+
+ @Override
+ @Nullable
+ public JComponent getPreferredFocusedComponent() {
+ return getDiagramComponent();
+ }
+
+ @Override
+ public void commit() {
+ }
+
+ @Override
+ public void reset() {
+ getDiagramComponent().rebuild(buildModel());
+ }
+
+ @Override
+ @NotNull
+ public String getName() {
+ 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;
+ }
+}
diff --git
a/src/main/java/com/intellij/struts2/diagram/fileEditor/Struts2DiagramFileEditorProvider.java
b/src/main/java/com/intellij/struts2/diagram/fileEditor/Struts2DiagramFileEditorProvider.java
new file mode 100644
index 0000000..44bd789
--- /dev/null
+++
b/src/main/java/com/intellij/struts2/diagram/fileEditor/Struts2DiagramFileEditorProvider.java
@@ -0,0 +1,92 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.intellij.struts2.diagram.fileEditor;
+
+import com.intellij.openapi.module.Module;
+import com.intellij.openapi.module.ModuleUtilCore;
+import com.intellij.openapi.project.Project;
+import com.intellij.openapi.vfs.VirtualFile;
+import com.intellij.psi.PsiFile;
+import com.intellij.psi.PsiManager;
+import com.intellij.psi.jsp.JspFile;
+import com.intellij.psi.xml.XmlFile;
+import com.intellij.struts2.dom.struts.model.StrutsManager;
+import com.intellij.struts2.facet.ui.StrutsFileSet;
+import com.intellij.util.xml.ui.PerspectiveFileEditor;
+import com.intellij.util.xml.ui.PerspectiveFileEditorProvider;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.Set;
+
+/**
+ * Provides the read-only "Diagram" tab for struts.xml files registered in a
Struts file set.
+ * Uses the same eligibility rules as the legacy Graph tab but does not depend
on
+ * deprecated {@code GraphBuilder} APIs.
+ */
+public class Struts2DiagramFileEditorProvider extends
PerspectiveFileEditorProvider {
+
+ @Override
+ public boolean accept(@NotNull final Project project, @NotNull final
VirtualFile file) {
+ if (!file.isValid()) {
+ return false;
+ }
+
+ final PsiFile psiFile = PsiManager.getInstance(project).findFile(file);
+
+ if (!(psiFile instanceof XmlFile)) {
+ return false;
+ }
+
+ if (psiFile instanceof JspFile) {
+ return false;
+ }
+
+ if (!StrutsManager.getInstance(project).isStruts2ConfigFile((XmlFile)
psiFile)) {
+ return false;
+ }
+
+ final Module module = ModuleUtilCore.findModuleForFile(file, project);
+ if (module == null) {
+ return false;
+ }
+
+ final Set<StrutsFileSet> fileSets =
StrutsManager.getInstance(project).getAllConfigFileSets(module);
+ for (final StrutsFileSet fileSet : fileSets) {
+ if (fileSet.hasFile(file)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ @Override
+ @NotNull
+ public PerspectiveFileEditor createEditor(@NotNull final Project project,
@NotNull final VirtualFile file) {
+ return new Struts2DiagramFileEditor(project, file);
+ }
+
+ @Override
+ public boolean isDumbAware() {
+ return false;
+ }
+
+ @Override
+ public double getWeight() {
+ return 0;
+ }
+}
diff --git
a/src/main/java/com/intellij/struts2/diagram/model/StrutsConfigDiagramModel.java
b/src/main/java/com/intellij/struts2/diagram/model/StrutsConfigDiagramModel.java
new file mode 100644
index 0000000..8cca983
--- /dev/null
+++
b/src/main/java/com/intellij/struts2/diagram/model/StrutsConfigDiagramModel.java
@@ -0,0 +1,192 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.intellij.struts2.diagram.model;
+
+import com.intellij.icons.AllIcons;
+import com.intellij.openapi.diagnostic.Logger;
+import com.intellij.openapi.paths.PathReference;
+import com.intellij.openapi.vfs.VirtualFile;
+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.struts2.Struts2Icons;
+import com.intellij.struts2.diagram.presentation.StrutsDiagramPresentation;
+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.model.StrutsManager;
+import com.intellij.struts2.dom.struts.model.StrutsModel;
+import com.intellij.struts2.dom.struts.strutspackage.StrutsPackage;
+import com.intellij.util.xml.DomElement;
+import com.intellij.util.xml.DomFileElement;
+import com.intellij.util.xml.DomManager;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import javax.swing.*;
+import java.util.*;
+
+/**
+ * Builds a toolkit-neutral snapshot of the Struts configuration for diagram
rendering.
+ * Walks the <b>file-local</b> {@link StrutsRoot} DOM to produce package,
action, and
+ * result nodes with directed edges (package->action, action->result).
+ * Only elements declared in the currently opened {@code struts.xml} are
included;
+ * 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>
+ * <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.
+ */
+public final class StrutsConfigDiagramModel {
+
+ private static final Logger LOG =
Logger.getInstance(StrutsConfigDiagramModel.class);
+ static final String UNRESOLVED_RESULT = "(unresolved path)";
+ static final String UNNAMED = "(unnamed)";
+
+ private final List<StrutsDiagramNode> nodes = new ArrayList<>();
+ private final List<StrutsDiagramEdge> edges = new ArrayList<>();
+
+ private StrutsConfigDiagramModel() {}
+
+ public @NotNull List<StrutsDiagramNode> getNodes() { return
Collections.unmodifiableList(nodes); }
+ public @NotNull List<StrutsDiagramEdge> getEdges() { return
Collections.unmodifiableList(edges); }
+
+ /**
+ * Build a snapshot from the packages declared in the given XML file only.
+ * Uses the file-local {@link StrutsRoot} DOM when available, falling back
+ * to the merged {@link StrutsModel} filtered to packages whose XML tag
+ * belongs to the current file.
+ * <p>
+ * <b>Must be called under a read action</b> — all DOM/PSI access happens
here.
+ *
+ * @return populated model, or {@code null} if the file is not a Struts
config.
+ */
+ public static @Nullable StrutsConfigDiagramModel build(@NotNull XmlFile
xmlFile) {
+ List<StrutsPackage> packages = getLocalPackages(xmlFile);
+ if (packages == null) return null;
+
+ SmartPointerManager pointerManager =
SmartPointerManager.getInstance(xmlFile.getProject());
+ StrutsConfigDiagramModel model = new StrutsConfigDiagramModel();
+
+ for (StrutsPackage strutsPackage : packages) {
+ String pkgName =
Objects.toString(strutsPackage.getName().getStringValue(), UNNAMED);
+ StrutsDiagramNode pkgNode = createNode(
+ StrutsDiagramNode.Kind.PACKAGE, pkgName,
AllIcons.Nodes.Package,
+ strutsPackage, pointerManager);
+ model.nodes.add(pkgNode);
+
+ for (Action action : strutsPackage.getActions()) {
+ String actionName =
Objects.toString(action.getName().getStringValue(), UNNAMED);
+ StrutsDiagramNode actionNode = createNode(
+ StrutsDiagramNode.Kind.ACTION, actionName,
Struts2Icons.Action,
+ action, pointerManager);
+ model.nodes.add(actionNode);
+ model.edges.add(new StrutsDiagramEdge(pkgNode, 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));
+ }
+ }
+ }
+ return model;
+ }
+
+ /**
+ * Resolves the list of packages local to the given file.
+ * Finds the {@link StrutsRoot} for the current file from the model's
individual
+ * roots (not the concatenated merged packages) so we get only this file's
packages.
+ */
+ private static @Nullable List<StrutsPackage> getLocalPackages(@NotNull
XmlFile xmlFile) {
+ VirtualFile targetVFile = xmlFile.getOriginalFile().getVirtualFile();
+
+ StrutsModel strutsModel =
StrutsManager.getInstance(xmlFile.getProject()).getModelByFile(xmlFile);
+ if (strutsModel != null) {
+ for (DomFileElement<StrutsRoot> root : strutsModel.getRoots()) {
+ VirtualFile rootVFile =
root.getOriginalFile().getVirtualFile();
+ if (Objects.equals(targetVFile, rootVFile)) {
+ List<StrutsPackage> packages =
root.getRootElement().getPackages();
+ LOG.debug("Found matching root for " + xmlFile.getName() +
+ ", " + packages.size() + " packages");
+ return packages;
+ }
+ }
+ LOG.debug("Merged model has " + strutsModel.getRoots().size() +
+ " roots but none matched " + targetVFile);
+ }
+
+ DomFileElement<StrutsRoot> fileElement =
+
DomManager.getDomManager(xmlFile.getProject()).getFileElement(xmlFile,
StrutsRoot.class);
+ if (fileElement != null) {
+ List<StrutsPackage> packages =
fileElement.getRootElement().getPackages();
+ LOG.debug("File-local DOM returned " + packages.size() + "
packages for " + xmlFile.getName());
+ return packages;
+ }
+
+ LOG.debug("No model available for " + xmlFile.getName());
+ return null;
+ }
+
+ private static @NotNull StrutsDiagramNode createNode(
+ @NotNull StrutsDiagramNode.Kind kind,
+ @NotNull String name,
+ @Nullable Icon icon,
+ @NotNull DomElement domElement,
+ @NotNull SmartPointerManager pointerManager) {
+
+ String tooltipHtml =
StrutsDiagramPresentation.computeTooltipHtml(domElement);
+ SmartPsiElementPointer<XmlElement> navPointer =
createNavigationPointer(domElement, pointerManager);
+ String id = buildNodeId(kind, domElement);
+ return new StrutsDiagramNode(id, kind, name, icon, tooltipHtml,
navPointer);
+ }
+
+ private static @NotNull String buildNodeId(@NotNull StrutsDiagramNode.Kind
kind,
+ @NotNull DomElement domElement)
{
+ XmlElement xml = domElement.getXmlElement();
+ if (xml != null) {
+ return kind.name() + "@" + xml.getTextOffset();
+ }
+ return kind.name() + "@" + System.identityHashCode(domElement);
+ }
+
+ private static @Nullable SmartPsiElementPointer<XmlElement>
createNavigationPointer(
+ @NotNull DomElement domElement,
+ @NotNull SmartPointerManager pointerManager) {
+ XmlElement xmlElement = domElement.getXmlElement();
+ if (xmlElement == null) return null;
+ return pointerManager.createSmartPsiElementPointer(xmlElement);
+ }
+
+ private static @NotNull Icon resolveResultIcon(@NotNull Result result) {
+ if (!result.isValid()) return AllIcons.FileTypes.Unknown;
+ PathReference ref = result.getValue();
+ if (ref == null || ref.resolve() == null) return
AllIcons.FileTypes.Unknown;
+ Icon icon = ref.getIcon();
+ return icon != null ? icon : AllIcons.FileTypes.Unknown;
+ }
+}
diff --git
a/src/main/java/com/intellij/struts2/diagram/model/StrutsDiagramEdge.java
b/src/main/java/com/intellij/struts2/diagram/model/StrutsDiagramEdge.java
new file mode 100644
index 0000000..6460c1b
--- /dev/null
+++ b/src/main/java/com/intellij/struts2/diagram/model/StrutsDiagramEdge.java
@@ -0,0 +1,55 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.intellij.struts2.diagram.model;
+
+import org.jetbrains.annotations.NotNull;
+
+import java.util.Objects;
+
+/**
+ * Toolkit-neutral directed edge between two {@link StrutsDiagramNode}s.
+ */
+public final class StrutsDiagramEdge {
+
+ private final @NotNull StrutsDiagramNode source;
+ private final @NotNull StrutsDiagramNode target;
+ private final @NotNull String label;
+
+ public StrutsDiagramEdge(@NotNull StrutsDiagramNode source,
+ @NotNull StrutsDiagramNode target,
+ @NotNull String label) {
+ this.source = source;
+ this.target = target;
+ this.label = label;
+ }
+
+ public @NotNull StrutsDiagramNode getSource() { return source; }
+ public @NotNull StrutsDiagramNode getTarget() { return target; }
+ public @NotNull String getLabel() { return label; }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof StrutsDiagramEdge that)) return false;
+ return source.equals(that.source) && target.equals(that.target);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(source, target);
+ }
+}
diff --git
a/src/main/java/com/intellij/struts2/diagram/model/StrutsDiagramNode.java
b/src/main/java/com/intellij/struts2/diagram/model/StrutsDiagramNode.java
new file mode 100644
index 0000000..c88e54f
--- /dev/null
+++ b/src/main/java/com/intellij/struts2/diagram/model/StrutsDiagramNode.java
@@ -0,0 +1,92 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.intellij.struts2.diagram.model;
+
+import com.intellij.psi.SmartPsiElementPointer;
+import com.intellij.psi.xml.XmlElement;
+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.
+ * <p>
+ * UI-safe fields ({@link #getTooltipHtml()}, {@link #getNavigationPointer()},
{@link #getIcon()})
+ * are precomputed during snapshot creation under a read action so that Swing
event handlers
+ * on the EDT never need to touch PSI/DOM directly.
+ * <p>
+ * Intentionally free of any {@code com.intellij.openapi.graph} dependencies
so it can
+ * serve as the data layer for both the current lightweight Swing renderer and
a future
+ * {@code com.intellij.diagram.Provider} migration.
+ */
+public final class StrutsDiagramNode {
+
+ public enum Kind { PACKAGE, ACTION, RESULT }
+
+ private final @NotNull String id;
+ private final @NotNull Kind kind;
+ private final @NotNull String name;
+ private final @Nullable Icon icon;
+ private final @Nullable String tooltipHtml;
+ private final @Nullable SmartPsiElementPointer<XmlElement>
navigationPointer;
+
+ public StrutsDiagramNode(@NotNull String id,
+ @NotNull Kind kind,
+ @NotNull String name,
+ @Nullable Icon icon,
+ @Nullable String tooltipHtml,
+ @Nullable SmartPsiElementPointer<XmlElement>
navigationPointer) {
+ this.id = id;
+ this.kind = kind;
+ this.name = name;
+ this.icon = icon;
+ this.tooltipHtml = tooltipHtml;
+ this.navigationPointer = navigationPointer;
+ }
+
+ public @NotNull String getId() { return id; }
+ public @NotNull Kind getKind() { return kind; }
+ public @NotNull String getName() { return name; }
+ public @Nullable Icon getIcon() { return icon; }
+ public @Nullable String getTooltipHtml() { return tooltipHtml; }
+ public @Nullable SmartPsiElementPointer<XmlElement> getNavigationPointer()
{ return navigationPointer; }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof StrutsDiagramNode that)) return false;
+ return id.equals(that.id);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(id);
+ }
+
+ @Override
+ public String toString() {
+ return kind + ":" + name + "(" + id + ")";
+ }
+}
diff --git a/src/main/java/com/intellij/struts2/diagram/model/package-info.java
b/src/main/java/com/intellij/struts2/diagram/model/package-info.java
new file mode 100644
index 0000000..e3d3839
--- /dev/null
+++ b/src/main/java/com/intellij/struts2/diagram/model/package-info.java
@@ -0,0 +1,41 @@
+/*
+ * 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.
+ */
+
+/**
+ * Toolkit-neutral Struts configuration diagram model.
+ *
+ * <h2>Migration boundary</h2>
+ * <p>The types in this package ({@link
com.intellij.struts2.diagram.model.StrutsConfigDiagramModel},
+ * {@link com.intellij.struts2.diagram.model.StrutsDiagramNode},
+ * {@link com.intellij.struts2.diagram.model.StrutsDiagramEdge}) are
intentionally independent of both:</p>
+ * <ul>
+ * <li>The deprecated {@code com.intellij.openapi.graph.builder}
(GraphBuilder) APIs used by the legacy
+ * {@code com.intellij.struts2.graph} package, and</li>
+ * <li>The newer {@code com.intellij.diagram.Provider} (Diagrams API) that
may be adopted in the future.</li>
+ * </ul>
+ * <p>This isolation means that the rendering/editor layer (currently a
lightweight Swing panel in
+ * {@code com.intellij.struts2.diagram.ui}) can be replaced without touching
the DOM traversal or
+ * presentation logic. A future migration to {@code
com.intellij.diagram.Provider} should:</p>
+ * <ol>
+ * <li>Implement {@code DiagramProvider} / {@code DiagramDataModel}
consuming the snapshot produced
+ * by {@link
com.intellij.struts2.diagram.model.StrutsConfigDiagramModel#build}.</li>
+ * <li>Reuse {@link
com.intellij.struts2.diagram.presentation.StrutsDiagramPresentation} for
+ * tooltips and navigation.</li>
+ * <li>Replace only the {@code diagram.ui} and {@code diagram.fileEditor}
packages.</li>
+ * </ol>
+ */
+package com.intellij.struts2.diagram.model;
diff --git
a/src/main/java/com/intellij/struts2/diagram/presentation/StrutsDiagramPresentation.java
b/src/main/java/com/intellij/struts2/diagram/presentation/StrutsDiagramPresentation.java
new file mode 100644
index 0000000..3992633
--- /dev/null
+++
b/src/main/java/com/intellij/struts2/diagram/presentation/StrutsDiagramPresentation.java
@@ -0,0 +1,127 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.intellij.struts2.diagram.presentation;
+
+import com.intellij.openapi.application.ApplicationManager;
+import com.intellij.openapi.paths.PathReference;
+import com.intellij.openapi.util.text.StringUtil;
+import com.intellij.pom.Navigatable;
+import com.intellij.psi.PsiClass;
+import com.intellij.psi.SmartPsiElementPointer;
+import com.intellij.psi.xml.XmlElement;
+import com.intellij.struts2.diagram.model.StrutsDiagramNode;
+import com.intellij.struts2.dom.struts.action.Action;
+import com.intellij.struts2.dom.struts.action.Result;
+import com.intellij.struts2.dom.struts.strutspackage.ResultType;
+import com.intellij.struts2.dom.struts.strutspackage.StrutsPackage;
+import com.intellij.util.OpenSourceUtil;
+import com.intellij.util.xml.DomElement;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * Presentation helpers for Struts diagram nodes.
+ * <p>
+ * <b>Threading contract:</b>
+ * <ul>
+ * <li>{@link #computeTooltipHtml(DomElement)} accesses DOM/PSI and <b>must
be called
+ * under a read action</b> (typically during snapshot creation).</li>
+ * <li>{@link #navigateToElement(StrutsDiagramNode)} is safe to call from
the EDT —
+ * it resolves the precomputed {@link SmartPsiElementPointer} inside a
+ * short synchronous read action via {@code
Application.runReadAction}.</li>
+ * </ul>
+ * Intentionally free of any {@code com.intellij.openapi.graph} dependencies.
+ */
+public final class StrutsDiagramPresentation {
+
+ private StrutsDiagramPresentation() {}
+
+ /**
+ * Compute tooltip HTML for a DOM element. <b>Must be called under a read
action.</b>
+ */
+ public static @Nullable String computeTooltipHtml(@NotNull DomElement
element) {
+ if (element instanceof StrutsPackage pkg) {
+ return new HtmlTableBuilder()
+ .addLine("Package", pkg.getName().getStringValue())
+ .addLine("Namespace", pkg.searchNamespace())
+ .addLine("Extends", pkg.getExtends().getStringValue())
+ .build();
+ }
+
+ if (element instanceof Action action) {
+ StrutsPackage strutsPackage = action.getStrutsPackage();
+ PsiClass actionClass = action.searchActionClass();
+ return new HtmlTableBuilder()
+ .addLine("Action", action.getName().getStringValue())
+ .addLine("Class", actionClass != null ?
actionClass.getQualifiedName() : null)
+ .addLine("Method", action.getMethod().getStringValue())
+ .addLine("Package",
strutsPackage.getName().getStringValue())
+ .addLine("Namespace", strutsPackage.searchNamespace())
+ .build();
+ }
+
+ if (element instanceof Result result) {
+ PathReference ref = result.getValue();
+ String displayPath = ref != null ? ref.getPath() : "(unresolved)";
+ ResultType resultType = result.getEffectiveResultType();
+ String resultTypeValue = resultType != null ?
resultType.getName().getStringValue() : "(unknown type)";
+ return new HtmlTableBuilder()
+ .addLine("Path", displayPath)
+ .addLine("Type", resultTypeValue)
+ .build();
+ }
+
+ return null;
+ }
+
+ /**
+ * Navigate to the XML element backing a diagram node.
+ * Safe to call from the EDT — resolves the smart pointer via
+ * {@code Application.runReadAction(Computable)} which, unlike
+ * {@code ReadAction.nonBlocking().executeSynchronously()}, does not
+ * assert a background thread.
+ */
+ public static void navigateToElement(@NotNull StrutsDiagramNode node) {
+ SmartPsiElementPointer<XmlElement> pointer =
node.getNavigationPointer();
+ if (pointer == null) return;
+
+ Navigatable navigatable =
ApplicationManager.getApplication().runReadAction(
+ (com.intellij.openapi.util.Computable<Navigatable>) () -> {
+ XmlElement element = pointer.getElement();
+ return element instanceof Navigatable ? (Navigatable)
element : null;
+ });
+
+ if (navigatable != null) {
+ OpenSourceUtil.navigate(navigatable);
+ }
+ }
+
+ private static final class HtmlTableBuilder {
+ private final StringBuilder sb = new StringBuilder("<html><table>");
+
+ HtmlTableBuilder addLine(@NotNull String label, @Nullable String
content) {
+
sb.append("<tr><td><strong>").append(label).append(":</strong></td>")
+ .append("<td>").append(StringUtil.isNotEmpty(content) ?
content : "-").append("</td></tr>");
+ return this;
+ }
+
+ String build() {
+ sb.append("</table></html>");
+ return sb.toString();
+ }
+ }
+}
diff --git
a/src/main/java/com/intellij/struts2/diagram/ui/Struts2DiagramComponent.java
b/src/main/java/com/intellij/struts2/diagram/ui/Struts2DiagramComponent.java
new file mode 100644
index 0000000..a0342cb
--- /dev/null
+++ b/src/main/java/com/intellij/struts2/diagram/ui/Struts2DiagramComponent.java
@@ -0,0 +1,282 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.intellij.struts2.diagram.ui;
+
+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.presentation.StrutsDiagramPresentation;
+import com.intellij.ui.JBColor;
+import com.intellij.util.ui.JBUI;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import javax.swing.*;
+import java.awt.*;
+import java.awt.event.MouseAdapter;
+import java.awt.event.MouseEvent;
+import java.awt.geom.Path2D;
+import java.util.List;
+import java.util.*;
+
+/**
+ * Lightweight read-only Swing panel that renders a Struts config diagram with
a
+ * deterministic hierarchical layout: packages at left, actions in center,
results at right.
+ * Supports hover tooltips and click-to-navigate.
+ * <p>
+ * When the model is {@code null} or empty, a centered placeholder message is
rendered
+ * instead of a blank canvas.
+ */
+public final class Struts2DiagramComponent extends JPanel {
+
+ private static final int NODE_WIDTH = 160;
+ private static final int NODE_HEIGHT = 30;
+ private static final int H_GAP = 80;
+ private static final int V_GAP = 16;
+ private static final int PADDING = 24;
+ private static final int ICON_TEXT_GAP = 4;
+ private static final int ARC = 8;
+
+ private static final String MSG_UNAVAILABLE = "Diagram is not available
for this file";
+ private static final String MSG_EMPTY = "No packages or actions found in
this file";
+
+ public enum State { LOADED, EMPTY, UNAVAILABLE }
+
+ private final Map<StrutsDiagramNode, Rectangle> nodeBounds = new
LinkedHashMap<>();
+ private final List<StrutsDiagramEdge> edges = new ArrayList<>();
+ private @Nullable StrutsDiagramNode hoveredNode;
+ private @NotNull State state = State.UNAVAILABLE;
+
+ public Struts2DiagramComponent(@Nullable StrutsConfigDiagramModel model) {
+ setBackground(JBColor.background());
+ applyModel(model);
+
+ MouseAdapter mouseHandler = new MouseAdapter() {
+ @Override
+ public void mouseMoved(MouseEvent e) {
+ StrutsDiagramNode hit = hitTest(e.getPoint());
+ if (!Objects.equals(hit, hoveredNode)) {
+ hoveredNode = hit;
+ setToolTipText(hit != null ? hit.getTooltipHtml() : null);
+ repaint();
+ }
+ }
+
+ @Override
+ public void mouseClicked(MouseEvent e) {
+ StrutsDiagramNode hit = hitTest(e.getPoint());
+ if (hit != null && e.getClickCount() == 2) {
+ StrutsDiagramPresentation.navigateToElement(hit);
+ }
+ }
+ };
+ addMouseListener(mouseHandler);
+ addMouseMotionListener(mouseHandler);
+ }
+
+ public @NotNull State getState() { return state; }
+
+ public void rebuild(@Nullable StrutsConfigDiagramModel model) {
+ nodeBounds.clear();
+ edges.clear();
+ hoveredNode = null;
+ applyModel(model);
+ revalidate();
+ repaint();
+ }
+
+ private void applyModel(@Nullable StrutsConfigDiagramModel model) {
+ if (model == null) {
+ state = State.UNAVAILABLE;
+ return;
+ }
+ if (model.getNodes().isEmpty()) {
+ state = State.EMPTY;
+ return;
+ }
+ state = State.LOADED;
+ layoutModel(model);
+ }
+
+ private void layoutModel(@NotNull StrutsConfigDiagramModel model) {
+ edges.addAll(model.getEdges());
+
+ List<StrutsDiagramNode> packages = new ArrayList<>();
+ List<StrutsDiagramNode> actions = new ArrayList<>();
+ List<StrutsDiagramNode> results = new ArrayList<>();
+
+ for (StrutsDiagramNode node : model.getNodes()) {
+ switch (node.getKind()) {
+ case PACKAGE -> packages.add(node);
+ case ACTION -> actions.add(node);
+ case RESULT -> results.add(node);
+ }
+ }
+
+ int colX = PADDING;
+ int maxY = placeColumn(packages, colX, PADDING);
+ colX += NODE_WIDTH + H_GAP;
+ maxY = Math.max(maxY, placeColumn(actions, colX, PADDING));
+ colX += NODE_WIDTH + H_GAP;
+ maxY = Math.max(maxY, placeColumn(results, colX, PADDING));
+
+ int totalWidth = colX + NODE_WIDTH + PADDING;
+ int totalHeight = maxY + PADDING;
+ setPreferredSize(new Dimension(totalWidth, totalHeight));
+ }
+
+ private int placeColumn(List<StrutsDiagramNode> nodes, int x, int startY) {
+ int y = startY;
+ for (StrutsDiagramNode node : nodes) {
+ nodeBounds.put(node, new Rectangle(x, y, NODE_WIDTH, NODE_HEIGHT));
+ y += NODE_HEIGHT + V_GAP;
+ }
+ return y;
+ }
+
+ private @Nullable StrutsDiagramNode hitTest(Point p) {
+ for (Map.Entry<StrutsDiagramNode, Rectangle> entry :
nodeBounds.entrySet()) {
+ if (entry.getValue().contains(p)) {
+ return entry.getKey();
+ }
+ }
+ return null;
+ }
+
+ @Override
+ protected void paintComponent(Graphics g) {
+ super.paintComponent(g);
+ Graphics2D g2 = (Graphics2D) g.create();
+ try {
+ g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING,
RenderingHints.VALUE_ANTIALIAS_ON);
+ g2.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING,
RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
+
+ if (state != State.LOADED) {
+ paintPlaceholder(g2);
+ } else {
+ paintEdges(g2);
+ paintNodes(g2);
+ }
+ } finally {
+ g2.dispose();
+ }
+ }
+
+ private void paintPlaceholder(Graphics2D g2) {
+ String message = state == State.EMPTY ? MSG_EMPTY : MSG_UNAVAILABLE;
+ g2.setFont(JBUI.Fonts.label(14));
+ g2.setColor(JBColor.namedColor("Editor.foreground",
+ new JBColor(new Color(0x888888), new Color(0x999999))));
+ FontMetrics fm = g2.getFontMetrics();
+ int x = (getWidth() - fm.stringWidth(message)) / 2;
+ int y = getHeight() / 2;
+ g2.drawString(message, Math.max(x, PADDING), Math.max(y, PADDING));
+ }
+
+ private void paintEdges(Graphics2D g2) {
+ g2.setStroke(new BasicStroke(1.2f));
+ for (StrutsDiagramEdge edge : edges) {
+ Rectangle srcRect = nodeBounds.get(edge.getSource());
+ 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);
+ }
+ }
+ }
+
+ 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;
+ int x1 = (int) (toX - arrowLen * Math.cos(angle - Math.PI / 6));
+ int y1 = (int) (toY - arrowLen * Math.sin(angle - Math.PI / 6));
+ int x2 = (int) (toX - arrowLen * Math.cos(angle + Math.PI / 6));
+ int y2 = (int) (toY - arrowLen * Math.sin(angle + Math.PI / 6));
+ g2.fillPolygon(new int[]{toX, x1, x2}, new int[]{toY, y1, y2}, 3);
+ }
+
+ private void paintNodes(Graphics2D g2) {
+ Font nodeFont = JBUI.Fonts.label();
+ g2.setFont(nodeFont);
+ FontMetrics fm = g2.getFontMetrics();
+
+ for (Map.Entry<StrutsDiagramNode, Rectangle> entry :
nodeBounds.entrySet()) {
+ StrutsDiagramNode node = entry.getKey();
+ Rectangle r = entry.getValue();
+ boolean hovered = Objects.equals(node, hoveredNode);
+
+ Color fill = switch (node.getKind()) {
+ case PACKAGE -> JBColor.namedColor("Diagram.packageNodeFill",
+ new JBColor(new Color(0xE8F0FE), new Color(0x2B3A4C)));
+ case ACTION -> JBColor.namedColor("Diagram.actionNodeFill",
+ new JBColor(new Color(0xE6F4EA), new Color(0x1E3A2C)));
+ case RESULT -> JBColor.namedColor("Diagram.resultNodeFill",
+ new JBColor(new Color(0xFFF3E0), new Color(0x3A2E1E)));
+ };
+ Color border = hovered
+ ? JBColor.namedColor("Diagram.nodeHoverBorder",
JBColor.BLUE)
+ : JBColor.namedColor("Diagram.nodeBorder", JBColor.GRAY);
+
+ g2.setColor(fill);
+ g2.fillRoundRect(r.x, r.y, r.width, r.height, ARC, ARC);
+ g2.setColor(border);
+ g2.setStroke(new BasicStroke(hovered ? 2f : 1f));
+ g2.drawRoundRect(r.x, r.y, r.width, r.height, ARC, ARC);
+
+ Icon icon = node.getIcon();
+ int textX = r.x + 6;
+ if (icon != null) {
+ int iconY = r.y + (r.height - icon.getIconHeight()) / 2;
+ icon.paintIcon(this, g2, textX, iconY);
+ textX += icon.getIconWidth() + ICON_TEXT_GAP;
+ }
+
+ g2.setColor(JBColor.foreground());
+ String label = node.getName();
+ int availableWidth = r.x + r.width - textX - 4;
+ if (fm.stringWidth(label) > availableWidth) {
+ while (label.length() > 1 && fm.stringWidth(label + "...") >
availableWidth) {
+ label = label.substring(0, label.length() - 1);
+ }
+ label += "...";
+ }
+ int textY = r.y + (r.height + fm.getAscent() - fm.getDescent()) /
2;
+ g2.drawString(label, textX, textY);
+ }
+ }
+}
diff --git a/src/main/resources/META-INF/plugin.xml
b/src/main/resources/META-INF/plugin.xml
index 0c55621..2b4fcb9 100644
--- a/src/main/resources/META-INF/plugin.xml
+++ b/src/main/resources/META-INF/plugin.xml
@@ -168,6 +168,7 @@
implementation="com.intellij.struts2.structure.StrutsStructureViewBuilderProvider"/>
<fileEditorProvider
implementation="com.intellij.struts2.graph.fileEditor.Struts2GraphFileEditorProvider"/>
+ <fileEditorProvider
implementation="com.intellij.struts2.diagram.fileEditor.Struts2DiagramFileEditorProvider"/>
<struts2.resultContributor
implementation="com.intellij.struts2.dom.struts.impl.path.DispatchPathResultContributor"/>
diff --git
a/src/test/java/com/intellij/struts2/diagram/Struts2DiagramFileEditorProviderTest.java
b/src/test/java/com/intellij/struts2/diagram/Struts2DiagramFileEditorProviderTest.java
new file mode 100644
index 0000000..664f590
--- /dev/null
+++
b/src/test/java/com/intellij/struts2/diagram/Struts2DiagramFileEditorProviderTest.java
@@ -0,0 +1,95 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.intellij.struts2.diagram;
+
+import com.intellij.openapi.fileEditor.FileEditor;
+import com.intellij.openapi.util.Disposer;
+import com.intellij.openapi.vfs.VirtualFile;
+import com.intellij.struts2.BasicLightHighlightingTestCase;
+import com.intellij.struts2.diagram.fileEditor.Struts2DiagramFileEditor;
+import
com.intellij.struts2.diagram.fileEditor.Struts2DiagramFileEditorProvider;
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * Tests for {@link Struts2DiagramFileEditorProvider} covering both acceptance
+ * gating and basic editor lifecycle (creation, name, reset).
+ */
+public class Struts2DiagramFileEditorProviderTest extends
BasicLightHighlightingTestCase {
+
+ private final Struts2DiagramFileEditorProvider myProvider = new
Struts2DiagramFileEditorProvider();
+
+ @Override
+ @NotNull
+ protected String getTestDataLocation() {
+ return "diagram";
+ }
+
+ public void testAcceptsStrutsConfigInFileSet() {
+ createStrutsFileSet("struts-diagram.xml");
+ VirtualFile file = myFixture.copyFileToProject("struts-diagram.xml");
+ assertTrue("Diagram provider should accept struts.xml registered in
file set",
+ myProvider.accept(getProject(), file));
+ }
+
+ public void testRejectsStrutsConfigNotInFileSet() {
+ VirtualFile file = myFixture.copyFileToProject("struts-diagram.xml");
+ assertFalse("Diagram provider should reject struts.xml not in any file
set",
+ myProvider.accept(getProject(), file));
+ }
+
+ public void testRejectsPlainXml() {
+ VirtualFile file = myFixture.configureByText("plain.xml",
"<root/>").getVirtualFile();
+ assertFalse("Diagram provider should reject non-Struts XML",
+ myProvider.accept(getProject(), file));
+ }
+
+ public void testRejectsJavaFile() {
+ VirtualFile file = myFixture.configureByText("Foo.java", "class Foo
{}").getVirtualFile();
+ assertFalse("Diagram provider should reject non-XML files",
+ myProvider.accept(getProject(), file));
+ }
+
+ // --- Editor lifecycle tests ---
+
+ public void testEditorCreationProducesValidDiagramEditor() {
+ createStrutsFileSet("struts-diagram.xml");
+ VirtualFile file = myFixture.findFileInTempDir("struts-diagram.xml");
+ assertNotNull(file);
+
+ FileEditor editor = myProvider.createEditor(getProject(), file);
+ try {
+ assertInstanceOf(editor, Struts2DiagramFileEditor.class);
+ assertEquals("Diagram", editor.getName());
+ } finally {
+ Disposer.dispose(editor);
+ }
+ }
+
+ public void testResetDoesNotThrow() {
+ createStrutsFileSet("struts-diagram.xml");
+ VirtualFile file = myFixture.findFileInTempDir("struts-diagram.xml");
+ assertNotNull(file);
+
+ Struts2DiagramFileEditor editor =
+ (Struts2DiagramFileEditor)
myProvider.createEditor(getProject(), file);
+ try {
+ editor.reset();
+ } finally {
+ Disposer.dispose(editor);
+ }
+ }
+}
diff --git
a/src/test/java/com/intellij/struts2/diagram/StrutsConfigDiagramModelTest.java
b/src/test/java/com/intellij/struts2/diagram/StrutsConfigDiagramModelTest.java
new file mode 100644
index 0000000..da43a4d
--- /dev/null
+++
b/src/test/java/com/intellij/struts2/diagram/StrutsConfigDiagramModelTest.java
@@ -0,0 +1,402 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.intellij.struts2.diagram;
+
+import com.intellij.openapi.application.ApplicationManager;
+import com.intellij.openapi.application.ReadAction;
+import com.intellij.openapi.vfs.VirtualFile;
+import com.intellij.pom.Navigatable;
+import com.intellij.psi.PsiFile;
+import com.intellij.psi.PsiManager;
+import com.intellij.psi.SmartPsiElementPointer;
+import com.intellij.psi.xml.XmlElement;
+import com.intellij.psi.xml.XmlFile;
+import com.intellij.struts2.BasicLightHighlightingTestCase;
+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 org.jetbrains.annotations.NotNull;
+
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * Tests for {@link StrutsConfigDiagramModel} covering file-local snapshot,
+ * fallback states, unresolved result labeling, and component state mapping.
+ */
+public class StrutsConfigDiagramModelTest extends
BasicLightHighlightingTestCase {
+
+ @Override
+ @NotNull
+ protected String getTestDataLocation() {
+ return "diagram";
+ }
+
+ public void testBuildReturnsOnlyLocalPackagesAndActions() {
+ createStrutsFileSet("struts-local-a.xml", "struts-local-b.xml");
+
+ VirtualFile vfA = myFixture.findFileInTempDir("struts-local-a.xml");
+ assertNotNull("struts-local-a.xml should exist in temp dir", vfA);
+
+ PsiFile psiA = PsiManager.getInstance(getProject()).findFile(vfA);
+ assertInstanceOf(psiA, XmlFile.class);
+
+ StrutsConfigDiagramModel modelA = ReadAction.nonBlocking(
+ () -> StrutsConfigDiagramModel.build((XmlFile)
psiA)).executeSynchronously();
+ assertNotNull("Model should be built for struts-local-a.xml", modelA);
+
+ Set<String> nodeNames = modelA.getNodes().stream()
+ .map(StrutsDiagramNode::getName)
+ .collect(Collectors.toSet());
+
+ assertTrue("Should contain local package, got: " + nodeNames,
nodeNames.contains("packageA"));
+ assertTrue("Should contain local action actionA1, got: " + nodeNames,
nodeNames.contains("actionA1"));
+ assertTrue("Should contain local action actionA2, got: " + nodeNames,
nodeNames.contains("actionA2"));
+
+ long resultCount = modelA.getNodes().stream()
+ .filter(n -> n.getKind() == StrutsDiagramNode.Kind.RESULT)
+ .count();
+ assertEquals("Should have 2 local results, got names: " + nodeNames,
2, resultCount);
+
+ assertFalse("Should NOT contain package from other file",
nodeNames.contains("packageB"));
+ assertFalse("Should NOT contain action from other file",
nodeNames.contains("actionB1"));
+ assertFalse("Should NOT contain result from other file",
nodeNames.contains("/b/page1.jsp"));
+ }
+
+ public void testBuildReturnsCorrectNodeKinds() {
+ createStrutsFileSet("struts-local-a.xml");
+
+ VirtualFile vfA = myFixture.findFileInTempDir("struts-local-a.xml");
+ assertNotNull("struts-local-a.xml should exist in temp dir", vfA);
+
+ PsiFile psiA = PsiManager.getInstance(getProject()).findFile(vfA);
+ assertInstanceOf(psiA, XmlFile.class);
+
+ StrutsConfigDiagramModel model = ReadAction.nonBlocking(
+ () -> StrutsConfigDiagramModel.build((XmlFile)
psiA)).executeSynchronously();
+ assertNotNull(model);
+
+ List<StrutsDiagramNode> nodes = model.getNodes();
+ long packages = nodes.stream().filter(n -> n.getKind() ==
StrutsDiagramNode.Kind.PACKAGE).count();
+ long actions = nodes.stream().filter(n -> n.getKind() ==
StrutsDiagramNode.Kind.ACTION).count();
+ long results = nodes.stream().filter(n -> n.getKind() ==
StrutsDiagramNode.Kind.RESULT).count();
+
+ assertEquals("One local package", 1, packages);
+ assertEquals("Two local actions", 2, actions);
+ assertEquals("Two local results", 2, results);
+ }
+
+ public void testBuildReturnsNullForNonStrutsXml() {
+ XmlFile plainXml = (XmlFile) myFixture.configureByText("plain.xml",
"<root/>");
+ StrutsConfigDiagramModel model = ReadAction.nonBlocking(
+ () ->
StrutsConfigDiagramModel.build(plainXml)).executeSynchronously();
+ assertNull("Should return null for non-Struts XML", model);
+ }
+
+ public void testNavigationPointerResolvesUnderSynchronousReadAction() {
+ createStrutsFileSet("struts-local-a.xml");
+
+ VirtualFile vfA = myFixture.findFileInTempDir("struts-local-a.xml");
+ assertNotNull(vfA);
+ PsiFile psiA = PsiManager.getInstance(getProject()).findFile(vfA);
+ assertInstanceOf(psiA, XmlFile.class);
+
+ StrutsConfigDiagramModel model = ReadAction.nonBlocking(
+ () -> StrutsConfigDiagramModel.build((XmlFile)
psiA)).executeSynchronously();
+ assertNotNull(model);
+
+ StrutsDiagramNode actionNode = model.getNodes().stream()
+ .filter(n -> n.getKind() == StrutsDiagramNode.Kind.ACTION)
+ .findFirst().orElse(null);
+ assertNotNull("Model should contain at least one ACTION node",
actionNode);
+
+ SmartPsiElementPointer<XmlElement> pointer =
actionNode.getNavigationPointer();
+ assertNotNull("ACTION node should have a navigation pointer", pointer);
+
+ Navigatable navigatable =
ApplicationManager.getApplication().runReadAction(
+ (com.intellij.openapi.util.Computable<Navigatable>) () -> {
+ XmlElement element = pointer.getElement();
+ return element instanceof Navigatable ? (Navigatable)
element : null;
+ });
+ assertNotNull("Smart pointer should resolve to a Navigatable element",
navigatable);
+ }
+
+ // --- Fallback state tests ---
+
+ public void testBuildReturnsEmptyModelForStrutsFileWithNoPackages() {
+ createStrutsFileSet("struts-empty.xml");
+
+ VirtualFile vf = myFixture.findFileInTempDir("struts-empty.xml");
+ assertNotNull(vf);
+
+ PsiFile psi = PsiManager.getInstance(getProject()).findFile(vf);
+ assertInstanceOf(psi, XmlFile.class);
+
+ StrutsConfigDiagramModel model = ReadAction.nonBlocking(
+ () -> StrutsConfigDiagramModel.build((XmlFile)
psi)).executeSynchronously();
+ assertNotNull("Should return a model (not null) for a valid but empty
Struts file", model);
+ assertTrue("Model should have no nodes", model.getNodes().isEmpty());
+ assertTrue("Model should have no edges", model.getEdges().isEmpty());
+ }
+
+ public void testComponentStateUnavailableForNullModel() {
+ Struts2DiagramComponent component = new Struts2DiagramComponent(null);
+ assertEquals(Struts2DiagramComponent.State.UNAVAILABLE,
component.getState());
+ }
+
+ public void testComponentStateEmptyForEmptyModel() {
+ createStrutsFileSet("struts-empty.xml");
+
+ VirtualFile vf = myFixture.findFileInTempDir("struts-empty.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);
+
+ Struts2DiagramComponent component = new Struts2DiagramComponent(model);
+ assertEquals(Struts2DiagramComponent.State.EMPTY,
component.getState());
+ }
+
+ public void testComponentStateLoadedForNormalModel() {
+ createStrutsFileSet("struts-local-a.xml");
+
+ VirtualFile vf = myFixture.findFileInTempDir("struts-local-a.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);
+
+ Struts2DiagramComponent component = new Struts2DiagramComponent(model);
+ assertEquals(Struts2DiagramComponent.State.LOADED,
component.getState());
+ }
+
+ public void testRebuildClearsStaleThenShowsFallback() {
+ createStrutsFileSet("struts-local-a.xml");
+
+ VirtualFile vf = myFixture.findFileInTempDir("struts-local-a.xml");
+ PsiFile psi = PsiManager.getInstance(getProject()).findFile(vf);
+ StrutsConfigDiagramModel loaded = ReadAction.nonBlocking(
+ () -> StrutsConfigDiagramModel.build((XmlFile)
psi)).executeSynchronously();
+
+ Struts2DiagramComponent component = new
Struts2DiagramComponent(loaded);
+ assertEquals(Struts2DiagramComponent.State.LOADED,
component.getState());
+
+ component.rebuild(null);
+ assertEquals("rebuild(null) should switch to UNAVAILABLE, not keep
stale content",
+ Struts2DiagramComponent.State.UNAVAILABLE,
component.getState());
+ }
+
+ // --- Unresolved result label tests ---
+
+ public void testUnresolvedResultUsesDescriptiveLabel() {
+ createStrutsFileSet("struts-unresolved.xml");
+
+ VirtualFile vf = myFixture.findFileInTempDir("struts-unresolved.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);
+
+ List<StrutsDiagramNode> results = model.getNodes().stream()
+ .filter(n -> n.getKind() == StrutsDiagramNode.Kind.RESULT)
+ .collect(Collectors.toList());
+ assertFalse("Should have result nodes", results.isEmpty());
+
+ for (StrutsDiagramNode result : results) {
+ assertFalse("Result node label should not be raw '???': " +
result.getName(),
+ "???".equals(result.getName()));
+ }
+ }
+
+ public void testUnresolvedResultTooltipDoesNotContainRawPlaceholders() {
+ createStrutsFileSet("struts-unresolved.xml");
+
+ VirtualFile vf = myFixture.findFileInTempDir("struts-unresolved.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);
+
+ List<StrutsDiagramNode> results = model.getNodes().stream()
+ .filter(n -> n.getKind() == StrutsDiagramNode.Kind.RESULT)
+ .collect(Collectors.toList());
+
+ for (StrutsDiagramNode result : results) {
+ String tooltip = result.getTooltipHtml();
+ if (tooltip != null) {
+ assertFalse("Tooltip should not contain raw '???' for path: "
+ tooltip,
+ tooltip.contains(">???<"));
+ }
+ }
+ }
+
+ // --- Duplicate name and identity tests ---
+
+ public void testDuplicateActionNamesAcrossPackagesProduceDistinctNodes() {
+ createStrutsFileSet("struts-duplicate-names.xml");
+
+ VirtualFile vf =
myFixture.findFileInTempDir("struts-duplicate-names.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);
+
+ List<StrutsDiagramNode> actions = model.getNodes().stream()
+ .filter(n -> n.getKind() == StrutsDiagramNode.Kind.ACTION)
+ .collect(Collectors.toList());
+ assertEquals("Both 'index' actions should be present as separate
nodes", 2, actions.size());
+
+ assertEquals("Both share the same display name",
actions.get(0).getName(), actions.get(1).getName());
+ assertFalse("Must have different IDs",
actions.get(0).getId().equals(actions.get(1).getId()));
+ assertFalse("Must not be equal() to each other",
actions.get(0).equals(actions.get(1)));
+ }
+
+ public void testDuplicateResultPathsProduceDistinctNodes() {
+ createStrutsFileSet("struts-duplicate-names.xml");
+
+ VirtualFile vf =
myFixture.findFileInTempDir("struts-duplicate-names.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);
+
+ List<StrutsDiagramNode> results = model.getNodes().stream()
+ .filter(n -> n.getKind() == StrutsDiagramNode.Kind.RESULT)
+ .collect(Collectors.toList());
+ assertEquals("Should have 3 result nodes total", 3, results.size());
+
+ long distinctIds =
results.stream().map(StrutsDiagramNode::getId).distinct().count();
+ assertEquals("All result nodes must have distinct IDs", 3,
distinctIds);
+ }
+
+ // --- Edge structure tests ---
+
+ public void testEdgesConnectPackagesToActionsAndActionsToResults() {
+ createStrutsFileSet("struts-local-a.xml");
+
+ VirtualFile vf = myFixture.findFileInTempDir("struts-local-a.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);
+
+ List<StrutsDiagramEdge> edges = model.getEdges();
+ assertEquals("struts-local-a.xml has 2 package->action + 2
action->result edges", 4, edges.size());
+
+ long pkgToAction = edges.stream()
+ .filter(e -> e.getSource().getKind() ==
StrutsDiagramNode.Kind.PACKAGE
+ && e.getTarget().getKind() ==
StrutsDiagramNode.Kind.ACTION)
+ .count();
+ assertEquals("Two package->action edges", 2, pkgToAction);
+
+ long actionToResult = edges.stream()
+ .filter(e -> e.getSource().getKind() ==
StrutsDiagramNode.Kind.ACTION
+ && e.getTarget().getKind() ==
StrutsDiagramNode.Kind.RESULT)
+ .count();
+ assertEquals("Two action->result edges", 2, actionToResult);
+ }
+
+ public void testResultEdgeLabelsReflectResultNames() {
+ createStrutsFileSet("struts-local-a.xml");
+
+ VirtualFile vf = myFixture.findFileInTempDir("struts-local-a.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);
+
+ List<StrutsDiagramEdge> resultEdges = model.getEdges().stream()
+ .filter(e -> e.getTarget().getKind() ==
StrutsDiagramNode.Kind.RESULT)
+ .collect(Collectors.toList());
+ assertEquals(2, resultEdges.size());
+
+ Set<String> labels = resultEdges.stream()
+ .map(StrutsDiagramEdge::getLabel)
+ .collect(Collectors.toSet());
+ assertTrue("Should have default 'success' label, got: " + labels,
labels.contains("success"));
+ assertTrue("Should have explicit 'input' label, got: " + labels,
labels.contains("input"));
+ }
+
+ public void testEdgesInDuplicateNameFileAreCorrectlyWired() {
+ createStrutsFileSet("struts-duplicate-names.xml");
+
+ VirtualFile vf =
myFixture.findFileInTempDir("struts-duplicate-names.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);
+
+ List<StrutsDiagramNode> packages = model.getNodes().stream()
+ .filter(n -> n.getKind() == StrutsDiagramNode.Kind.PACKAGE)
+ .collect(Collectors.toList());
+ assertEquals("Two packages", 2, packages.size());
+
+ for (StrutsDiagramNode pkg : packages) {
+ long outgoing = model.getEdges().stream()
+ .filter(e -> e.getSource().equals(pkg))
+ .count();
+ assertEquals("Each package should have exactly one outgoing edge
to its 'index' action", 1, outgoing);
+ }
+
+ List<StrutsDiagramNode> actions = model.getNodes().stream()
+ .filter(n -> n.getKind() == StrutsDiagramNode.Kind.ACTION)
+ .collect(Collectors.toList());
+ StrutsDiagramNode adminAction = actions.get(0);
+ StrutsDiagramNode publicAction = actions.get(1);
+
+ long adminResults = model.getEdges().stream()
+ .filter(e -> e.getSource().equals(adminAction)
+ && e.getTarget().getKind() ==
StrutsDiagramNode.Kind.RESULT)
+ .count();
+ assertEquals("admin/index has 1 result", 1, adminResults);
+
+ long publicResults = model.getEdges().stream()
+ .filter(e -> e.getSource().equals(publicAction)
+ && e.getTarget().getKind() ==
StrutsDiagramNode.Kind.RESULT)
+ .count();
+ assertEquals("public/index has 2 results", 2, publicResults);
+ }
+}
diff --git
a/src/test/java/com/intellij/struts2/dom/inspection/StrutsDtdValidatorTest.java
b/src/test/java/com/intellij/struts2/dom/inspection/StrutsDtdValidatorTest.java
index 6b9921d..8b542f8 100644
---
a/src/test/java/com/intellij/struts2/dom/inspection/StrutsDtdValidatorTest.java
+++
b/src/test/java/com/intellij/struts2/dom/inspection/StrutsDtdValidatorTest.java
@@ -16,59 +16,105 @@
*/
package com.intellij.struts2.dom.inspection;
-import com.intellij.psi.PsiFile;
import com.intellij.psi.xml.XmlFile;
import com.intellij.testFramework.fixtures.BasePlatformTestCase;
+/**
+ * Unit tests for {@link StrutsDtdValidator} proving that the three DTD
+ * validation outcomes (OK, HTTP_INSTEAD_OF_HTTPS, UNRECOGNIZED) are
+ * correctly classified.
+ */
public class StrutsDtdValidatorTest extends BasePlatformTestCase {
- public void testHttpUriDetected() {
- XmlFile file =
createStrutsXmlWithDoctype("http://struts.apache.org/dtds/struts-6.0.dtd");
- assertEquals(StrutsDtdValidator.Result.HTTP_INSTEAD_OF_HTTPS,
StrutsDtdValidator.validate(file));
+ public void testValidHttpsDtdReturnsOk() {
+ XmlFile file = (XmlFile) myFixture.configureByText("struts.xml",
+ """
+ <?xml version="1.0" encoding="UTF-8" ?>
+ <!DOCTYPE struts PUBLIC
+ "-//Apache Software Foundation//DTD Struts Configuration
6.0//EN"
+ "https://struts.apache.org/dtds/struts-6.0.dtd">
+ <struts/>
+ """);
+ assertEquals(StrutsDtdValidator.Result.OK,
StrutsDtdValidator.validate(file));
}
- public void testHttpsUriOk() {
- XmlFile file =
createStrutsXmlWithDoctype("https://struts.apache.org/dtds/struts-6.0.dtd");
+ public void testValidOldHttpDtdReturnsOk() {
+ XmlFile file = (XmlFile) myFixture.configureByText("struts.xml",
+ """
+ <?xml version="1.0" encoding="UTF-8" ?>
+ <!DOCTYPE struts PUBLIC
+ "-//Apache Software Foundation//DTD Struts Configuration
2.0//EN"
+ "http://struts.apache.org/dtds/struts-2.0.dtd">
+ <struts/>
+ """);
assertEquals(StrutsDtdValidator.Result.OK,
StrutsDtdValidator.validate(file));
}
- public void testOldHttpUriOk() {
- XmlFile file =
createStrutsXmlWithDoctype("http://struts.apache.org/dtds/struts-2.0.dtd");
- assertEquals(StrutsDtdValidator.Result.OK,
StrutsDtdValidator.validate(file));
+ public void testHttpInsteadOfHttpsForNewDtdReturnsWarning() {
+ XmlFile file = (XmlFile) myFixture.configureByText("struts.xml",
+ """
+ <?xml version="1.0" encoding="UTF-8" ?>
+ <!DOCTYPE struts PUBLIC
+ "-//Apache Software Foundation//DTD Struts Configuration
6.0//EN"
+ "http://struts.apache.org/dtds/struts-6.0.dtd">
+ <struts/>
+ """);
+ assertEquals(StrutsDtdValidator.Result.HTTP_INSTEAD_OF_HTTPS,
StrutsDtdValidator.validate(file));
+ }
+
+ public void testHttpInsteadOfHttpsForStrutsLikeDtdReturnsWarning() {
+ XmlFile file = (XmlFile) myFixture.configureByText("struts.xml",
+ """
+ <?xml version="1.0" encoding="UTF-8" ?>
+ <!DOCTYPE struts PUBLIC
+ "-//Apache Software Foundation//DTD Struts Configuration
2.5//EN"
+ "http://struts.apache.org/dtds/struts-2.5.dtd">
+ <struts/>
+ """);
+ assertEquals(StrutsDtdValidator.Result.HTTP_INSTEAD_OF_HTTPS,
StrutsDtdValidator.validate(file));
}
- public void testUnrecognizedUri() {
- XmlFile file =
createStrutsXmlWithDoctype("http://example.com/bogus.dtd");
+ public void testUnrecognizedDtdReturnsUnrecognized() {
+ XmlFile file = (XmlFile) myFixture.configureByText("struts.xml",
+ """
+ <?xml version="1.0" encoding="UTF-8" ?>
+ <!DOCTYPE struts PUBLIC
+ "-//Unknown//DTD Something//EN"
+ "https://example.com/bogus-struts.dtd">
+ <struts/>
+ """);
assertEquals(StrutsDtdValidator.Result.UNRECOGNIZED,
StrutsDtdValidator.validate(file));
}
- public void testNoDoctype() {
- PsiFile psiFile = myFixture.configureByText("struts.xml",
"<struts></struts>");
- assertEquals(StrutsDtdValidator.Result.OK,
StrutsDtdValidator.validate((XmlFile) psiFile));
+ public void testNoDoctypeReturnsOk() {
+ XmlFile file = (XmlFile) myFixture.configureByText("struts.xml",
+ """
+ <?xml version="1.0" encoding="UTF-8" ?>
+ <struts/>
+ """);
+ assertEquals(StrutsDtdValidator.Result.OK,
StrutsDtdValidator.validate(file));
}
- public void testSuggestedUri() {
+ public void testSuggestedUriReplacesHttpWithHttps() {
assertEquals("https://struts.apache.org/dtds/struts-6.0.dtd",
StrutsDtdValidator.suggestedUri("http://struts.apache.org/dtds/struts-6.0.dtd"));
}
- public void testHttp25UriDetected() {
- XmlFile file =
createStrutsXmlWithDoctype("http://struts.apache.org/dtds/struts-2.5.dtd");
- assertEquals(StrutsDtdValidator.Result.HTTP_INSTEAD_OF_HTTPS,
StrutsDtdValidator.validate(file));
+ public void testExtractSystemIdReturnsNullForNoDoctype() {
+ XmlFile file = (XmlFile) myFixture.configureByText("struts.xml",
"<struts/>");
+ assertNull(StrutsDtdValidator.extractSystemId(file));
}
- public void testHttps25UriOk() {
- XmlFile file =
createStrutsXmlWithDoctype("https://struts.apache.org/dtds/struts-2.5.dtd");
- assertEquals(StrutsDtdValidator.Result.OK,
StrutsDtdValidator.validate(file));
- }
-
- private XmlFile createStrutsXmlWithDoctype(String systemUri) {
- String content = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
- "<!DOCTYPE struts PUBLIC\n" +
- " \"-//Apache Software Foundation//DTD Struts Configuration
6.0//EN\"\n" +
- " \"" + systemUri + "\">\n" +
- "<struts></struts>";
- PsiFile psiFile = myFixture.configureByText("struts.xml", content);
- return (XmlFile) psiFile;
+ public void testExtractSystemIdReturnsDtdUri() {
+ XmlFile file = (XmlFile) myFixture.configureByText("struts.xml",
+ """
+ <?xml version="1.0" encoding="UTF-8" ?>
+ <!DOCTYPE struts PUBLIC
+ "-//Apache Software Foundation//DTD Struts Configuration
6.0//EN"
+ "https://struts.apache.org/dtds/struts-6.0.dtd">
+ <struts/>
+ """);
+ assertEquals("https://struts.apache.org/dtds/struts-6.0.dtd",
+ StrutsDtdValidator.extractSystemId(file));
}
}
diff --git
a/src/test/java/com/intellij/struts2/dom/struts/StrutsHighlightingTest.java
b/src/test/java/com/intellij/struts2/dom/struts/StrutsHighlightingTest.java
index 1c15bbd..b9ea5b9 100644
--- a/src/test/java/com/intellij/struts2/dom/struts/StrutsHighlightingTest.java
+++ b/src/test/java/com/intellij/struts2/dom/struts/StrutsHighlightingTest.java
@@ -85,4 +85,5 @@ public class StrutsHighlightingTest extends
StrutsLightHighlightingTestCase {
public void testDtdHttpsNoWarning() {
performHighlightingTest("struts-dtd-https.xml");
}
+
}
\ No newline at end of file
diff --git a/src/test/testData/diagram/struts-diagram.xml
b/src/test/testData/diagram/struts-diagram.xml
new file mode 100644
index 0000000..dc25fe2
--- /dev/null
+++ b/src/test/testData/diagram/struts-diagram.xml
@@ -0,0 +1,36 @@
+<?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="testPackage" namespace="/test">
+
+ <action name="testAction" class="MyClass">
+ <result>/pages/test.jsp</result>
+ </action>
+
+ </package>
+
+</struts>
diff --git a/src/test/testData/diagram/struts-duplicate-names.xml
b/src/test/testData/diagram/struts-duplicate-names.xml
new file mode 100644
index 0000000..9e169fa
--- /dev/null
+++ b/src/test/testData/diagram/struts-duplicate-names.xml
@@ -0,0 +1,41 @@
+<?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="admin" namespace="/admin">
+ <action name="index">
+ <result>/admin/index.jsp</result>
+ </action>
+ </package>
+
+ <package name="public" namespace="/public">
+ <action name="index">
+ <result>/public/index.jsp</result>
+ <result name="input">/public/form.jsp</result>
+ </action>
+ </package>
+
+</struts>
diff --git a/src/test/testData/diagram/struts-empty.xml
b/src/test/testData/diagram/struts-empty.xml
new file mode 100644
index 0000000..9ac70bf
--- /dev/null
+++ b/src/test/testData/diagram/struts-empty.xml
@@ -0,0 +1,27 @@
+<?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>
+</struts>
diff --git a/src/test/testData/diagram/struts-local-a.xml
b/src/test/testData/diagram/struts-local-a.xml
new file mode 100644
index 0000000..50dd8c9
--- /dev/null
+++ b/src/test/testData/diagram/struts-local-a.xml
@@ -0,0 +1,40 @@
+<?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="packageA" namespace="/a">
+
+ <action name="actionA1" class="MyClass">
+ <result>/a/page1.jsp</result>
+ </action>
+
+ <action name="actionA2">
+ <result name="input">/a/page2.jsp</result>
+ </action>
+
+ </package>
+
+</struts>
diff --git a/src/test/testData/diagram/struts-local-b.xml
b/src/test/testData/diagram/struts-local-b.xml
new file mode 100644
index 0000000..e412e32
--- /dev/null
+++ b/src/test/testData/diagram/struts-local-b.xml
@@ -0,0 +1,36 @@
+<?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="packageB" namespace="/b">
+
+ <action name="actionB1">
+ <result>/b/page1.jsp</result>
+ </action>
+
+ </package>
+
+</struts>
diff --git a/src/test/testData/diagram/struts-unresolved.xml
b/src/test/testData/diagram/struts-unresolved.xml
new file mode 100644
index 0000000..dcfaeff
--- /dev/null
+++ b/src/test/testData/diagram/struts-unresolved.xml
@@ -0,0 +1,37 @@
+<?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="unresolvedPkg" namespace="/unresolved">
+
+ <action name="unresolvedAction">
+ <result>/nonexistent/missing.jsp</result>
+ <result name="error">/also/missing.jsp</result>
+ </action>
+
+ </package>
+
+</struts>