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

davsclaus pushed a commit to branch feature/CAMEL-23672-tui-diagram
in repository https://gitbox.apache.org/repos/asf/camel.git

commit 64bb0c2bb86105e4688ea335a781e6b9e7dfcc71
Author: Claus Ibsen <[email protected]>
AuthorDate: Wed Jun 3 21:57:46 2026 +0200

    CAMEL-23672: camel-tui - Route-to-route navigation in diagram
    
    Enter on a linkable EIP node jumps to the connected route's diagram.
    Navigation history is stacked so Escape goes back through visited
    routes before returning to topology. Linkable nodes show a yellow
    arrow indicator and the info panel displays the target route name.
    Also fixes scroll position not resetting when switching views.
    
    Co-Authored-By: Claude Opus 4.6 <[email protected]>
    Signed-off-by: Claus Ibsen <[email protected]>
---
 .../jbang/core/commands/tui/DiagramSupport.java    | 134 ++++++++++++++++++++-
 .../dsl/jbang/core/commands/tui/DiagramTab.java    |  70 ++++++++++-
 .../commands/tui/diagram/RouteDiagramWidget.java   |  39 ++++++
 3 files changed, 234 insertions(+), 9 deletions(-)

diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramSupport.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramSupport.java
index 0353cceb6705..0b5842383c53 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramSupport.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramSupport.java
@@ -234,6 +234,11 @@ class DiagramSupport {
         return false;
     }
 
+    void resetScroll() {
+        scrollY = 0;
+        scrollX = 0;
+    }
+
     void toggleDiagram(Runnable loadTrigger) {
         if (showDiagram) {
             close();
@@ -570,6 +575,77 @@ class DiagramSupport {
         return null;
     }
 
+    /**
+     * Finds the route ID that the selected EIP node links to, by matching the 
node's endpoint URI against topology
+     * edges and route "from" endpoints.
+     */
+    String findLinkedRouteId(String currentRouteId) {
+        var box = getSelectedEipNodeBox();
+        if (box == null || box.layoutNode() == null || 
box.layoutNode().treeNode == null) {
+            return null;
+        }
+        String type = box.type();
+        if (type == null) {
+            return null;
+        }
+        // Only "to"-style nodes can link to other routes
+        if (!"to".equals(type) && !"toD".equals(type) && 
!"wireTap".equals(type)
+                && !"enrich".equals(type) && !"pollEnrich".equals(type)
+                && !"from".equals(type)) {
+            return null;
+        }
+        String code = box.layoutNode().treeNode.info.code;
+        if (code == null || code.isBlank()) {
+            return null;
+        }
+
+        // First try topology edges for direct matching
+        for (TopologyLayoutEdge edge : topologyEdges) {
+            if ("from".equals(type)) {
+                // "from" node: find route that sends TO this endpoint
+                if (currentRouteId.equals(edge.to.routeId) && 
!currentRouteId.equals(edge.from.routeId)) {
+                    return edge.from.routeId;
+                }
+            } else {
+                // "to" node: find route that consumes FROM this endpoint
+                if (currentRouteId.equals(edge.from.routeId) && 
!currentRouteId.equals(edge.to.routeId)) {
+                    // Match endpoint URI against the target route's from
+                    String targetFrom = edge.to.from;
+                    if (targetFrom != null && uriMatches(code, targetFrom)) {
+                        return edge.to.routeId;
+                    }
+                }
+            }
+        }
+
+        // Fallback: match code against route "from" endpoints in routeLayouts
+        if (!"from".equals(type)) {
+            for (var entry : routeLayouts.entrySet()) {
+                if (currentRouteId.equals(entry.getKey())) {
+                    continue;
+                }
+                var lr = entry.getValue();
+                if (!lr.nodes.isEmpty()) {
+                    var firstNode = lr.nodes.get(0);
+                    if ("from".equals(firstNode.type) && firstNode.treeNode != 
null) {
+                        String fromCode = firstNode.treeNode.info.code;
+                        if (fromCode != null && uriMatches(code, fromCode)) {
+                            return entry.getKey();
+                        }
+                    }
+                }
+            }
+        }
+        return null;
+    }
+
+    private static boolean uriMatches(String toUri, String fromUri) {
+        // Normalize: strip query parameters for matching
+        String toBase = toUri.contains("?") ? toUri.substring(0, 
toUri.indexOf('?')) : toUri;
+        String fromBase = fromUri.contains("?") ? fromUri.substring(0, 
fromUri.indexOf('?')) : fromUri;
+        return toBase.equals(fromBase);
+    }
+
     void selectEipNodeUp() {
         if (eipNodeBoxes.isEmpty()) {
             return;
@@ -664,9 +740,60 @@ class DiagramSupport {
         }
     }
 
+    /**
+     * Computes the set of base endpoint URIs that can be navigated to from 
the current route. Includes both "from" URIs
+     * of other routes (for "to" nodes) and "to" URIs that target this route 
(for "from" nodes).
+     */
+    private Set<String> computeLinkableEndpoints(String currentRouteId) {
+        Set<String> endpoints = new HashSet<>();
+        for (var entry : routeLayouts.entrySet()) {
+            if (currentRouteId.equals(entry.getKey())) {
+                continue;
+            }
+            var lr = entry.getValue();
+            if (!lr.nodes.isEmpty()) {
+                // Add "from" URIs of other routes (linkable from "to" nodes)
+                var firstNode = lr.nodes.get(0);
+                if ("from".equals(firstNode.type) && firstNode.treeNode != 
null) {
+                    String code = firstNode.treeNode.info.code;
+                    if (code != null) {
+                        endpoints.add(code.contains("?") ? code.substring(0, 
code.indexOf('?')) : code);
+                    }
+                }
+                // Add "to" URIs of other routes (linkable from "from" node)
+                for (var node : lr.nodes) {
+                    String type = node.type;
+                    if (("to".equals(type) || "toD".equals(type) || 
"wireTap".equals(type))
+                            && node.treeNode != null) {
+                        String code = node.treeNode.info.code;
+                        if (code != null) {
+                            String baseUri = code.contains("?") ? 
code.substring(0, code.indexOf('?')) : code;
+                            // Check if this targets our route's "from" 
endpoint
+                            var currentLayout = 
routeLayouts.get(currentRouteId);
+                            if (currentLayout != null && 
!currentLayout.nodes.isEmpty()) {
+                                var fromNode = currentLayout.nodes.get(0);
+                                if ("from".equals(fromNode.type) && 
fromNode.treeNode != null) {
+                                    String fromCode = 
fromNode.treeNode.info.code;
+                                    if (fromCode != null) {
+                                        String fromBase = 
fromCode.contains("?")
+                                                ? fromCode.substring(0, 
fromCode.indexOf('?')) : fromCode;
+                                        if (baseUri.equals(fromBase)) {
+                                            endpoints.add(fromBase);
+                                        }
+                                    }
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+        }
+        return endpoints;
+    }
+
     void renderNativeRouteDiagram(
             Frame frame, Rect area, String title, boolean metrics,
-            RouteDiagramLayoutEngine.LayoutRoute routeLayout) {
+            String currentRouteId, RouteDiagramLayoutEngine.LayoutRoute 
routeLayout) {
         Block block = Block.builder()
                 .borderType(BorderType.ROUNDED)
                 .title(title)
@@ -675,9 +802,10 @@ class DiagramSupport {
 
         Rect inner = block.inner(area);
         int nw = RouteDiagramLayoutEngine.DEFAULT_BOX_WIDTH * 
RouteDiagramLayoutEngine.SCALE;
+        Set<String> linkable = computeLinkableEndpoints(currentRouteId);
 
         var widget = new 
org.apache.camel.dsl.jbang.core.commands.tui.diagram.RouteDiagramWidget(
-                routeLayout, nw, selectedEipNodeIndex, scrollX, scrollY, 
metrics);
+                routeLayout, nw, selectedEipNodeIndex, scrollX, scrollY, 
metrics, linkable);
 
         int totalRows = widget.getTotalRows();
         int totalCols = widget.getTotalCols();
@@ -692,7 +820,7 @@ class DiagramSupport {
         scrollX = Math.min(scrollX, maxHScroll);
 
         var finalWidget = new 
org.apache.camel.dsl.jbang.core.commands.tui.diagram.RouteDiagramWidget(
-                routeLayout, nw, selectedEipNodeIndex, scrollX, scrollY, 
metrics);
+                routeLayout, nw, selectedEipNodeIndex, scrollX, scrollY, 
metrics, linkable);
 
         List<Rect> vChunks = Layout.vertical()
                 .constraints(Constraint.fill(), Constraint.length(1))
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramTab.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramTab.java
index c9b7f1a15211..ecdc67961f43 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramTab.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramTab.java
@@ -16,7 +16,9 @@
  */
 package org.apache.camel.dsl.jbang.core.commands.tui;
 
+import java.util.ArrayDeque;
 import java.util.ArrayList;
+import java.util.Deque;
 import java.util.List;
 
 import dev.tamboui.layout.Constraint;
@@ -44,6 +46,7 @@ class DiagramTab implements MonitorTab {
     private boolean showExternal;
     private boolean topologyMode = true;
     private String drillDownRouteId;
+    private final Deque<String> routeNavigationStack = new ArrayDeque<>();
 
     DiagramTab(MonitorContext ctx) {
         this.ctx = ctx;
@@ -132,16 +135,31 @@ class DiagramTab implements MonitorTab {
             return true;
         }
 
-        // Drill down into route diagram (Enter)
+        // Jump to linked route from EIP node (Enter in route mode)
+        if (!topologyMode && ke.isConfirm() && 
!diagram.getEipNodeBoxes().isEmpty()) {
+            String linkedRouteId = diagram.findLinkedRouteId(drillDownRouteId);
+            if (linkedRouteId != null && diagram.getRouteLayout(linkedRouteId) 
!= null) {
+                routeNavigationStack.push(drillDownRouteId);
+                drillDownRouteId = linkedRouteId;
+                diagram.setSelectedEipNodeIndex(-1);
+                diagram.resetScroll();
+                return true;
+            }
+            return true;
+        }
+
+        // Drill down into route diagram (Enter from topology)
         if (topologyMode && ke.isConfirm()) {
             String selectedRouteId = diagram.getSelectedRouteId();
             if (selectedRouteId != null) {
                 IntegrationInfo info = ctx.findSelectedIntegration();
                 if (info != null && info.routes.stream().anyMatch(r -> 
selectedRouteId.equals(r.routeId))) {
+                    routeNavigationStack.clear();
                     drillDownRouteId = selectedRouteId;
                     topologyMode = false;
                     diagram.setTopologyMode(false);
                     diagram.setSelectedEipNodeIndex(-1);
+                    diagram.resetScroll();
                     diagram.endLoad();
                     // Use cached route layout if available (no IPC needed)
                     if (diagram.getRouteLayout(selectedRouteId) != null) {
@@ -159,10 +177,19 @@ class DiagramTab implements MonitorTab {
     @Override
     public boolean handleEscape() {
         if (!topologyMode) {
+            if (!routeNavigationStack.isEmpty()) {
+                // Go back to the previous route in the stack
+                drillDownRouteId = routeNavigationStack.pop();
+                diagram.setSelectedEipNodeIndex(-1);
+                diagram.resetScroll();
+                return true;
+            }
+            // Go back to topology
             diagram.setPendingSelectionRouteId(drillDownRouteId);
             topologyMode = true;
             diagram.setTopologyMode(true);
             diagram.setSelectedEipNodeIndex(-1);
+            diagram.resetScroll();
             // If topology layout is cached, just switch view without IPC
             if (diagram.hasNativeLayout()) {
                 return true;
@@ -195,6 +222,7 @@ class DiagramTab implements MonitorTab {
     public void onIntegrationChanged() {
         topologyMode = true;
         drillDownRouteId = null;
+        routeNavigationStack.clear();
         diagram.reset();
         diagram.setTopologyMode(true);
     }
@@ -237,9 +265,10 @@ class DiagramTab implements MonitorTab {
                             .constraints(Constraint.length(panelWidth), 
Constraint.fill())
                             .split(area);
                     renderEipInfoPanel(frame, hChunks.get(0));
-                    diagram.renderNativeRouteDiagram(frame, hChunks.get(1), 
title, diagramMetrics, routeLayout);
+                    diagram.renderNativeRouteDiagram(
+                            frame, hChunks.get(1), title, diagramMetrics, 
drillDownRouteId, routeLayout);
                 } else {
-                    diagram.renderNativeRouteDiagram(frame, area, title, 
diagramMetrics, routeLayout);
+                    diagram.renderNativeRouteDiagram(frame, area, title, 
diagramMetrics, drillDownRouteId, routeLayout);
                 }
             }
             return;
@@ -395,6 +424,14 @@ class DiagramTab implements MonitorTab {
                         Span.raw(ln.id)));
             }
 
+            String linkedRoute = diagram.findLinkedRouteId(drillDownRouteId);
+            if (linkedRoute != null) {
+                lines.add(Line.from(Span.raw("")));
+                lines.add(Line.from(
+                        Span.styled(" ↵ ", 
Style.EMPTY.fg(Color.YELLOW).bold()),
+                        Span.styled(linkedRoute, 
Style.EMPTY.fg(Color.WHITE))));
+            }
+
             if (ln.treeNode != null && ln.treeNode.info.stat != null) {
                 var stat = ln.treeNode.info.stat;
                 lines.add(Line.from(Span.raw("")));
@@ -444,6 +481,9 @@ class DiagramTab implements MonitorTab {
             if (!topologyMode && !diagram.getEipNodeBoxes().isEmpty()) {
                 hint(spans, "Esc", "back");
                 hint(spans, "↑↓←→", "navigate");
+                if (diagram.findLinkedRouteId(drillDownRouteId) != null) {
+                    hint(spans, "Enter", "jump to route");
+                }
                 hint(spans, "PgUp/PgDn", "page");
             } else if (!topologyMode) {
                 hint(spans, "Esc", "back");
@@ -574,14 +614,32 @@ class DiagramTab implements MonitorTab {
                                 internal EIP structure (the route diagram). 
Press `Esc` to
                                 return to the topology view.
 
+                                ## Route Diagram
+
+                                In the route diagram, each EIP node shows its 
type tag (colored)
+                                and endpoint URI or description. Nodes that 
connect to other routes
+                                display a `↵` indicator — press `Enter` to 
jump directly to the
+                                linked route's diagram.
+
+                                Navigation history is maintained as a stack: 
pressing `Esc` goes
+                                back to the previous route, and eventually 
back to the topology view.
+
                                 ## Keys
 
+                                **Topology view:**
                                 - `↑↓←→` — navigate between route boxes
                                 - `Enter` — drill down into selected route
-                                - `Esc` — return to topology / close diagram
+                                - `Esc` — close diagram
+
+                                **Route diagram:**
+                                - `↑↓←→` — navigate between EIP nodes
+                                - `Enter` — jump to linked route (when `↵` 
indicator shown)
+                                - `Esc` — go back (previous route or topology)
+
+                                **Common:**
                                 - `m` — toggle metrics on/off (default: on)
-                                - `e` — toggle external systems on/off 
(default: off)
-                                - `n` — toggle description labels on/off 
(default: off)
+                                - `e` — toggle external systems on/off 
(topology only)
+                                - `n` — toggle description labels on/off
                                 - `PgUp/PgDn` — page scroll
                                 - `Home/End` — top/end
                 """;
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/RouteDiagramWidget.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/RouteDiagramWidget.java
index d3fbccac2301..0d82aebc907d 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/RouteDiagramWidget.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/RouteDiagramWidget.java
@@ -17,7 +17,9 @@
 package org.apache.camel.dsl.jbang.core.commands.tui.diagram;
 
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.List;
+import java.util.Set;
 
 import dev.tamboui.buffer.Buffer;
 import dev.tamboui.layout.Rect;
@@ -47,6 +49,8 @@ public class RouteDiagramWidget implements Widget {
     private static final char SCOPE_H = '╌';
     private static final char SCOPE_V = '╎';
 
+    private static final String LINK_INDICATOR = " ↵";
+
     private final LayoutRoute layoutRoute;
     private final int nodeWidth;
     private final int boxWidth;
@@ -54,6 +58,7 @@ public class RouteDiagramWidget implements Widget {
     private final int scrollX;
     private final int scrollY;
     private final boolean showMetrics;
+    private final Set<String> linkableEndpoints;
 
     private final List<EipNodeBox> nodeBoxes = new ArrayList<>();
 
@@ -65,6 +70,13 @@ public class RouteDiagramWidget implements Widget {
                               LayoutRoute layoutRoute, int nodeWidth,
                               int selectedNodeIndex, int scrollX, int scrollY,
                               boolean showMetrics) {
+        this(layoutRoute, nodeWidth, selectedNodeIndex, scrollX, scrollY, 
showMetrics, Collections.emptySet());
+    }
+
+    public RouteDiagramWidget(
+                              LayoutRoute layoutRoute, int nodeWidth,
+                              int selectedNodeIndex, int scrollX, int scrollY,
+                              boolean showMetrics, Set<String> 
linkableEndpoints) {
         this.layoutRoute = layoutRoute;
         this.nodeWidth = nodeWidth;
         this.boxWidth = Math.max(MIN_BOX_WIDTH, nodeWidth / X_DIVISOR);
@@ -72,6 +84,7 @@ public class RouteDiagramWidget implements Widget {
         this.scrollX = scrollX;
         this.scrollY = scrollY;
         this.showMetrics = showMetrics;
+        this.linkableEndpoints = linkableEndpoints;
     }
 
     public List<EipNodeBox> getNodeBoxes() {
@@ -178,6 +191,14 @@ public class RouteDiagramWidget implements Widget {
             }
         }
 
+        // Link indicator for nodes that connect to other routes
+        if (isLinkable(node)) {
+            Style linkStyle = selected
+                    ? 
Style.EMPTY.fg(Color.YELLOW).bold().patch(SELECTION_STYLE)
+                    : Style.EMPTY.fg(Color.YELLOW).bold();
+            writeText(buffer, area, bottom, col + boxWidth, LINK_INDICATOR, 
linkStyle);
+        }
+
         nodeBoxes.add(new EipNodeBox(node.id, node.type, row, row + height - 
1, col, col + boxWidth - 1, node));
     }
 
@@ -398,6 +419,24 @@ public class RouteDiagramWidget implements Widget {
         return pixelX * boxWidth / nodeWidth;
     }
 
+    private boolean isLinkable(LayoutNode node) {
+        if (linkableEndpoints.isEmpty() || node.treeNode == null) {
+            return false;
+        }
+        String type = node.type;
+        if (!"to".equals(type) && !"toD".equals(type) && 
!"wireTap".equals(type)
+                && !"enrich".equals(type) && !"pollEnrich".equals(type)
+                && !"from".equals(type)) {
+            return false;
+        }
+        String code = node.treeNode.info.code;
+        if (code == null || code.isBlank()) {
+            return false;
+        }
+        String baseUri = code.contains("?") ? code.substring(0, 
code.indexOf('?')) : code;
+        return linkableEndpoints.contains(baseUri);
+    }
+
     private int toRow(int pixelY) {
         return pixelY / Y_SCALE;
     }

Reply via email to