This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch fix/CAMEL-23935 in repository https://gitbox.apache.org/repos/asf/camel.git
commit 52f0e3e23c66f21dc25e96a4b3becbe3c1414fb1 Author: Claus Ibsen <[email protected]> AuthorDate: Tue Jul 7 22:58:28 2026 +0200 CAMEL-23935: Route diagram mini tree should scroll to keep selected node visible Co-Authored-By: Claude Opus 4.6 <[email protected]> Signed-off-by: Claus Ibsen <[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(); + } +}
