You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@struts.apache.org by lu...@apache.org on 2026/04/05 08:42:41 UTC

(struts-intellij-plugin) branch feat/struts-diagram-tab created (now 003ded1)

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

lukaszlenart pushed a change to branch feat/struts-diagram-tab
in repository https://gitbox.apache.org/repos/asf/struts-intellij-plugin.git


      at 003ded1  feat: add read-only Diagram tab for struts.xml config visualization

This branch includes the following new commits:

     new 003ded1  feat: add read-only Diagram tab for struts.xml config visualization

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



(struts-intellij-plugin) 01/01: feat: add read-only Diagram tab for struts.xml config visualization

Posted by lu...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

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

commit 003ded1d683f2083120f5eaabb0201355a59c794
Author: Lukasz Lenart <lu...@apache.org>
AuthorDate: Sun Apr 5 10:42:31 2026 +0200

    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
---
 CHANGELOG.md                                       |   4 +
 .../fileEditor/Struts2DiagramFileEditor.java       |  91 ++++++++
 .../Struts2DiagramFileEditorProvider.java          |  92 ++++++++
 .../diagram/model/StrutsConfigDiagramModel.java    |  98 ++++++++
 .../struts2/diagram/model/StrutsDiagramEdge.java   |  55 +++++
 .../struts2/diagram/model/StrutsDiagramNode.java   |  72 ++++++
 .../presentation/StrutsDiagramPresentation.java    | 102 +++++++++
 .../diagram/ui/Struts2DiagramComponent.java        | 246 +++++++++++++++++++++
 src/main/resources/META-INF/plugin.xml             |   1 +
 9 files changed, 761 insertions(+)

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..d04fd0a
--- /dev/null
+++ b/src/main/java/com/intellij/struts2/diagram/fileEditor/Struts2DiagramFileEditor.java
@@ -0,0 +1,91 @@
+/*
+ * 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.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.
+ */
+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(StrutsConfigDiagramModel.build(myXmlFile));
+    }
+
+    @Override
+    @NotNull
+    public String getName() {
+        return "Diagram";
+    }
+
+    private Struts2DiagramComponent getDiagramComponent() {
+        if (myComponent == null) {
+            myComponent = new Struts2DiagramComponent(StrutsConfigDiagramModel.build(myXmlFile));
+        }
+        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..a814327
--- /dev/null
+++ b/src/main/java/com/intellij/struts2/diagram/model/StrutsConfigDiagramModel.java
@@ -0,0 +1,98 @@
+/*
+ * 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.paths.PathReference;
+import com.intellij.psi.xml.XmlFile;
+import com.intellij.struts2.Struts2Icons;
+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 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 {@link StrutsModel} to produce package, action, and result nodes with directed
+ * edges (package->action, action->result).
+ */
+public final class StrutsConfigDiagramModel {
+
+    private static final String UNKNOWN = "???";
+
+    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 Struts model associated with the given XML file.
+     *
+     * @return populated model, or {@code null} if no Struts model is available.
+     */
+    public static @Nullable StrutsConfigDiagramModel build(@NotNull XmlFile xmlFile) {
+        StrutsModel strutsModel = StrutsManager.getInstance(xmlFile.getProject()).getModelByFile(xmlFile);
+        if (strutsModel == null) return null;
+
+        StrutsConfigDiagramModel model = new StrutsConfigDiagramModel();
+        for (StrutsPackage strutsPackage : strutsModel.getStrutsPackages()) {
+            String pkgName = Objects.toString(strutsPackage.getName().getStringValue(), UNKNOWN);
+            StrutsDiagramNode pkgNode = new StrutsDiagramNode(
+                    StrutsDiagramNode.Kind.PACKAGE, pkgName, strutsPackage, AllIcons.Nodes.Package);
+            model.nodes.add(pkgNode);
+
+            for (Action action : strutsPackage.getActions()) {
+                String actionName = Objects.toString(action.getName().getStringValue(), UNKNOWN);
+                StrutsDiagramNode actionNode = new StrutsDiagramNode(
+                        StrutsDiagramNode.Kind.ACTION, actionName, action, Struts2Icons.Action);
+                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() : UNKNOWN;
+                    Icon resultIcon = resolveResultIcon(result);
+                    StrutsDiagramNode resultNode = new StrutsDiagramNode(
+                            StrutsDiagramNode.Kind.RESULT, path, result, resultIcon);
+                    model.nodes.add(resultNode);
+
+                    String resultName = result.getName().getStringValue();
+                    model.edges.add(new StrutsDiagramEdge(actionNode, resultNode,
+                            resultName != null ? resultName : Result.DEFAULT_NAME));
+                }
+            }
+        }
+        return model;
+    }
+
+    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..709e861
--- /dev/null
+++ b/src/main/java/com/intellij/struts2/diagram/model/StrutsDiagramNode.java
@@ -0,0 +1,72 @@
+/*
+ * 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.util.xml.DomElement;
+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).
+ * 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 Kind kind;
+    private final @NotNull String name;
+    private final @NotNull DomElement domElement;
+    private final @Nullable Icon icon;
+
+    public StrutsDiagramNode(@NotNull Kind kind,
+                             @NotNull String name,
+                             @NotNull DomElement domElement,
+                             @Nullable Icon icon) {
+        this.kind = kind;
+        this.name = name;
+        this.domElement = domElement;
+        this.icon = icon;
+    }
+
+    public @NotNull Kind getKind() { return kind; }
+    public @NotNull String getName() { return name; }
+    public @NotNull DomElement getDomElement() { return domElement; }
+    public @Nullable Icon getIcon() { return icon; }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (!(o instanceof StrutsDiagramNode that)) return false;
+        return kind == that.kind && name.equals(that.name) && domElement.equals(that.domElement);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(kind, name, domElement);
+    }
+
+    @Override
+    public String toString() {
+        return kind + ":" + name;
+    }
+}
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..ff13618
--- /dev/null
+++ b/src/main/java/com/intellij/struts2/diagram/presentation/StrutsDiagramPresentation.java
@@ -0,0 +1,102 @@
+/*
+ * 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.paths.PathReference;
+import com.intellij.openapi.util.text.StringUtil;
+import com.intellij.pom.Navigatable;
+import com.intellij.psi.PsiClass;
+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;
+
+/**
+ * Toolkit-neutral presentation helpers for Struts diagram nodes: tooltips, navigation,
+ * and labels. Intentionally free of any {@code com.intellij.openapi.graph} dependencies
+ * so it can serve both the current lightweight Swing renderer and a future
+ * {@code com.intellij.diagram.Provider} migration.
+ */
+public final class StrutsDiagramPresentation {
+
+    private StrutsDiagramPresentation() {}
+
+    public static @Nullable String getTooltipHtml(@NotNull StrutsDiagramNode node) {
+        DomElement element = node.getDomElement();
+
+        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() : "???";
+            ResultType resultType = result.getEffectiveResultType();
+            String resultTypeValue = resultType != null ? resultType.getName().getStringValue() : "???";
+            return new HtmlTableBuilder()
+                    .addLine("Path", displayPath)
+                    .addLine("Type", resultTypeValue)
+                    .build();
+        }
+
+        return null;
+    }
+
+    public static void navigateToElement(@NotNull StrutsDiagramNode node) {
+        XmlElement xmlElement = node.getDomElement().getXmlElement();
+        if (xmlElement instanceof Navigatable) {
+            OpenSourceUtil.navigate((Navigatable) xmlElement);
+        }
+    }
+
+    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..3df4eab
--- /dev/null
+++ b/src/main/java/com/intellij/struts2/diagram/ui/Struts2DiagramComponent.java
@@ -0,0 +1,246 @@
+/*
+ * 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.
+ */
+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 final Map<StrutsDiagramNode, Rectangle> nodeBounds = new LinkedHashMap<>();
+    private final List<StrutsDiagramEdge> edges = new ArrayList<>();
+    private @Nullable StrutsDiagramNode hoveredNode;
+
+    public Struts2DiagramComponent(@Nullable StrutsConfigDiagramModel model) {
+        setBackground(JBColor.background());
+        if (model != null) {
+            layoutModel(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 ? StrutsDiagramPresentation.getTooltipHtml(hit) : 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 void rebuild(@Nullable StrutsConfigDiagramModel model) {
+        nodeBounds.clear();
+        edges.clear();
+        if (model != null) {
+            layoutModel(model);
+        }
+        revalidate();
+        repaint();
+    }
+
+    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);
+
+            paintEdges(g2);
+            paintNodes(g2);
+        } finally {
+            g2.dispose();
+        }
+    }
+
+    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"/>