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; }
