This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new f3ad4fab862c CAMEL-23935: Route diagram mini tree should scroll to
keep selected node visible (#24505)
f3ad4fab862c is described below
commit f3ad4fab862ce07ca3e5df8e2904b03b8f646fe3
Author: Claus Ibsen <[email protected]>
AuthorDate: Wed Jul 8 07:39:51 2026 +0200
CAMEL-23935: Route diagram mini tree should scroll to keep selected node
visible (#24505)
Signed-off-by: Claus Ibsen <[email protected]>
Co-authored-by: Claude Opus 4.6 <[email protected]>
---
.../jbang/core/commands/tui/RouteTreePreview.java | 52 +++---
.../core/commands/tui/RouteTreePreviewTest.java | 184 +++++++++++++++++++++
2 files changed, 215 insertions(+), 21 deletions(-)
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RouteTreePreview.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RouteTreePreview.java
index f2f950f52c99..83a43a19b2a0 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RouteTreePreview.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RouteTreePreview.java
@@ -54,41 +54,50 @@ class RouteTreePreview {
return List.of(Line.from(Span.styled("(no structure)",
Style.EMPTY.dim())));
}
- List<Line> result = new ArrayList<>();
- addLine(result, " ", root, maxLines, maxWidth, selectedNode);
- addChildren(root.children, " ", result, maxLines, maxWidth,
selectedNode);
- return result;
+ List<Line> allLines = new ArrayList<>();
+ List<TreeNode> lineNodes = new ArrayList<>();
+ addLine(allLines, lineNodes, " ", root, maxWidth, selectedNode);
+ addChildren(root.children, " ", allLines, lineNodes, maxWidth,
selectedNode);
+
+ if (allLines.size() <= maxLines) {
+ return allLines;
+ }
+
+ // find the selected node's line and scroll to keep it visible
+ int selectedIdx = -1;
+ if (selectedNode != null) {
+ selectedIdx = lineNodes.indexOf(selectedNode);
+ }
+
+ int start;
+ if (selectedIdx < 0) {
+ start = 0;
+ } else {
+ // center the selected node in the window, but keep root visible
when near the top
+ start = selectedIdx - maxLines / 2;
+ start = Math.max(0, Math.min(start, allLines.size() - maxLines));
+ }
+
+ return allLines.subList(start, start + maxLines);
}
private static void addChildren(
List<TreeNode> children, String parentIndent,
- List<Line> result, int maxLines, int maxWidth, TreeNode
selectedNode) {
+ List<Line> result, List<TreeNode> lineNodes, int maxWidth,
TreeNode selectedNode) {
for (int i = 0; i < children.size(); i++) {
- if (result.size() >= maxLines) {
- return;
- }
boolean last = (i == children.size() - 1);
-
- if (result.size() >= maxLines - 1 && !last) {
- result.add(Line.from(Span.styled(parentIndent + "...",
Style.EMPTY.dim())));
- return;
- }
-
String connector = last ? "└─" : "├─";
String childCont = last ? " " : "│ ";
TreeNode child = children.get(i);
- addLine(result, parentIndent + connector, child, maxLines,
maxWidth, selectedNode);
- addChildren(child.children, parentIndent + childCont, result,
maxLines, maxWidth, selectedNode);
+ addLine(result, lineNodes, parentIndent + connector, child,
maxWidth, selectedNode);
+ addChildren(child.children, parentIndent + childCont, result,
lineNodes, maxWidth, selectedNode);
}
}
private static void addLine(
- List<Line> result, String prefix, TreeNode node,
- int maxLines, int maxWidth, TreeNode selectedNode) {
- if (result.size() >= maxLines) {
- return;
- }
+ List<Line> result, List<TreeNode> lineNodes, String prefix,
TreeNode node,
+ int maxWidth, TreeNode selectedNode) {
String label = buildLabel(node);
boolean selected = (node == selectedNode);
@@ -96,6 +105,7 @@ class RouteTreePreview {
spans.add(Span.styled(prefix, Style.EMPTY.fg(Color.DARK_GRAY)));
spans.add(styledLabel(node.info.type, truncate(label, maxWidth -
prefix.length()), selected));
result.add(Line.from(spans));
+ lineNodes.add(node);
}
private static Span styledLabel(String type, String text, boolean
selected) {
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/RouteTreePreviewTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/RouteTreePreviewTest.java
new file mode 100644
index 000000000000..56021200fce7
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/RouteTreePreviewTest.java
@@ -0,0 +1,184 @@
+/*
+ * 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 org.apache.camel.dsl.jbang.core.commands.tui;
+
+import java.util.List;
+
+import dev.tamboui.text.Line;
+import org.apache.camel.diagram.RouteDiagramLayoutEngine.LayoutNode;
+import org.apache.camel.diagram.RouteDiagramLayoutEngine.LayoutRoute;
+import org.apache.camel.diagram.RouteDiagramLayoutEngine.NodeInfo;
+import org.apache.camel.diagram.RouteDiagramLayoutEngine.TreeNode;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class RouteTreePreviewTest {
+
+ @Test
+ void testSmallTreeFitsWithoutScrolling() {
+ LayoutRoute layout = buildContentBasedRouterLayout();
+ TreeNode root = layout.nodes.get(0).treeNode;
+
+ // with enough lines, all nodes are shown
+ List<Line> lines = RouteTreePreview.buildTree(layout, 20, 40, null);
+ assertTrue(lines.size() > 5, "Tree should have multiple lines");
+ }
+
+ @Test
+ void testTreeScrollsToSelectedNode() {
+ LayoutRoute layout = buildContentBasedRouterLayout();
+ TreeNode root = layout.nodes.get(0).treeNode;
+
+ // find the last node deep in the tree (last child of "otherwise")
+ TreeNode choice = root.children.get(2); // choice
+ TreeNode otherwise = choice.children.get(2); // otherwise
+ TreeNode lastLog = otherwise.children.get(1); // log (last node)
+
+ // build full tree to know total size
+ List<Line> fullTree = RouteTreePreview.buildTree(layout, 100, 40,
null);
+ int totalLines = fullTree.size();
+
+ // with a small window, the selected node at the bottom must be visible
+ int maxLines = 6;
+ assertTrue(totalLines > maxLines, "Tree must be taller than window for
this test");
+
+ List<Line> windowedTree = RouteTreePreview.buildTree(layout, maxLines,
40, lastLog);
+ assertEquals(maxLines, windowedTree.size());
+
+ // the selected node's line should be present in the windowed output
+ String lastLineText = lineToPlainText(windowedTree);
+ assertTrue(lastLineText.contains("log"), "Selected 'log' node should
be visible in the scrolled tree");
+ }
+
+ @Test
+ void testTreeScrollsToSelectedNodeInMiddle() {
+ LayoutRoute layout = buildContentBasedRouterLayout();
+ TreeNode root = layout.nodes.get(0).treeNode;
+
+ // select a node in the middle of the tree
+ TreeNode choice = root.children.get(2); // choice
+ TreeNode when2 = choice.children.get(1); // second when
+
+ int maxLines = 6;
+ List<Line> windowedTree = RouteTreePreview.buildTree(layout, maxLines,
40, when2);
+ assertEquals(maxLines, windowedTree.size());
+
+ String allText = lineToPlainText(windowedTree);
+ assertTrue(allText.contains("when"), "Selected 'when' node should be
visible");
+ }
+
+ @Test
+ void testNoSelectedNodeShowsFromTop() {
+ LayoutRoute layout = buildContentBasedRouterLayout();
+
+ int maxLines = 6;
+ List<Line> lines = RouteTreePreview.buildTree(layout, maxLines, 40,
null);
+ assertEquals(maxLines, lines.size());
+
+ // first line should be the root (from)
+ String firstLine = lines.get(0).rawContent();
+ assertTrue(firstLine.contains("from"), "Without selection, tree should
start from the root");
+ }
+
+ /**
+ * Builds a layout matching the content-based-router example: from(timer)
-> setBody -> convertBodyTo -> choice when
+ * -> setHeader -> log when -> setHeader -> log otherwise -> setHeader ->
log
+ */
+ private LayoutRoute buildContentBasedRouterLayout() {
+ LayoutRoute layout = new LayoutRoute();
+ layout.routeId = "sensor";
+
+ // build tree structure
+ TreeNode root = node("from", "timer:sensor");
+ TreeNode setBody = node("setBody", "${random(0,40)}");
+ TreeNode convertBody = node("convertBodyTo", "int");
+ TreeNode choice = node("choice", null);
+
+ TreeNode when1 = node("when", "${body} >= 30");
+ TreeNode setHeader1 = node("setHeader", "level=hot");
+ TreeNode log1 = node("log", "Hot alert");
+
+ TreeNode when2 = node("when", "${body} >= 15");
+ TreeNode setHeader2 = node("setHeader", "level=normal");
+ TreeNode log2 = node("log", "Normal");
+
+ TreeNode otherwise = node("otherwise", null);
+ TreeNode setHeader3 = node("setHeader", "level=cold");
+ TreeNode log3 = node("log", "Cold alert");
+
+ // wire parent-child relationships
+ addChild(root, setBody);
+ addChild(root, convertBody);
+ addChild(root, choice);
+ addChild(choice, when1);
+ addChild(when1, setHeader1);
+ addChild(when1, log1);
+ addChild(choice, when2);
+ addChild(when2, setHeader2);
+ addChild(when2, log2);
+ addChild(choice, otherwise);
+ addChild(otherwise, setHeader3);
+ addChild(otherwise, log3);
+
+ // create layout nodes and link to tree nodes
+ addLayoutNode(layout, root);
+ addLayoutNode(layout, setBody);
+ addLayoutNode(layout, convertBody);
+ addLayoutNode(layout, choice);
+ addLayoutNode(layout, when1);
+ addLayoutNode(layout, setHeader1);
+ addLayoutNode(layout, log1);
+ addLayoutNode(layout, when2);
+ addLayoutNode(layout, setHeader2);
+ addLayoutNode(layout, log2);
+ addLayoutNode(layout, otherwise);
+ addLayoutNode(layout, setHeader3);
+ addLayoutNode(layout, log3);
+
+ return layout;
+ }
+
+ private TreeNode node(String type, String code) {
+ NodeInfo info = new NodeInfo();
+ info.type = type;
+ info.code = code;
+ return new TreeNode(info);
+ }
+
+ private void addChild(TreeNode parent, TreeNode child) {
+ child.parent = parent;
+ parent.children.add(child);
+ }
+
+ private void addLayoutNode(LayoutRoute layout, TreeNode treeNode) {
+ LayoutNode ln = new LayoutNode();
+ ln.type = treeNode.info.type;
+ ln.treeNode = treeNode;
+ treeNode.layoutNode = ln;
+ layout.nodes.add(ln);
+ }
+
+ private String lineToPlainText(List<Line> lines) {
+ StringBuilder sb = new StringBuilder();
+ for (Line line : lines) {
+ sb.append(line.rawContent()).append("\n");
+ }
+ return sb.toString();
+ }
+}