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 3cdd66ca166e3c664bbff111ec4627d85d31762a
Author: Claus Ibsen <[email protected]>
AuthorDate: Wed Jun 3 22:50:42 2026 +0200

    CAMEL-23672: camel-tui - Extract reusable SourceViewer, add source view to 
diagram, add breadcrumb navigation
    
    - Extract SourceViewer into a reusable class from RoutesTab for use across 
tabs
    - Add 'c' key in route diagram drill-down to show source code at selected 
EIP node
    - Parse source line numbers from route structure into NodeInfo.line
    - Add breadcrumb navigation trail in route diagram title (route1 → route2 → 
route3)
    
    Co-Authored-By: Claude Opus 4.6 <[email protected]>
    Signed-off-by: Claus Ibsen <[email protected]>
    Signed-off-by: Claus Ibsen <[email protected]>
---
 .../apache/camel/diagram/RouteDiagramHelper.java   |   2 +
 .../camel/diagram/RouteDiagramLayoutEngine.java    |   1 +
 .../dsl/jbang/core/commands/tui/DiagramTab.java    |  53 +++-
 .../dsl/jbang/core/commands/tui/RoutesTab.java     | 312 +------------------
 .../dsl/jbang/core/commands/tui/SourceViewer.java  | 341 +++++++++++++++++++++
 5 files changed, 413 insertions(+), 296 deletions(-)

diff --git 
a/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramHelper.java
 
b/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramHelper.java
index 6024a5d8a814..6fdd0d2028cd 100644
--- 
a/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramHelper.java
+++ 
b/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramHelper.java
@@ -77,6 +77,8 @@ public final class RouteDiagramHelper {
                     node.description = line.getString("description");
                     Integer level = line.getInteger("level");
                     node.level = level != null ? level : 0;
+                    Integer lineNum = line.getInteger("line");
+                    node.line = lineNum != null ? lineNum : 0;
 
                     if (line.containsKey("statistics")) {
                         JsonObject ls = line.getJsonObject("statistics");
diff --git 
a/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramLayoutEngine.java
 
b/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramLayoutEngine.java
index 2c7cb558a427..b234db8f1552 100644
--- 
a/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramLayoutEngine.java
+++ 
b/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramLayoutEngine.java
@@ -157,6 +157,7 @@ public class RouteDiagramLayoutEngine {
         public String uri;
         public String description;
         public int level;
+        public int line;
         public StatInfo stat;
     }
 
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 1ff1073cb3f1..1560e690b30b 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
@@ -42,6 +42,7 @@ class DiagramTab implements MonitorTab {
 
     private final MonitorContext ctx;
     private final DiagramSupport diagram = new DiagramSupport();
+    private final SourceViewer sourceViewer = new SourceViewer();
     private boolean diagramMetrics = true;
     private boolean showExternal;
     private boolean topologyMode = true;
@@ -58,6 +59,17 @@ class DiagramTab implements MonitorTab {
 
     @Override
     public boolean handleKeyEvent(KeyEvent ke) {
+        // Source view scrolling (takes priority when active)
+        if (sourceViewer.handleKeyEvent(ke)) {
+            return true;
+        }
+
+        // Source viewer toggle
+        if (!topologyMode && diagram.isShowDiagram() && ke.isChar('c')) {
+            loadSourceForSelectedNode();
+            return true;
+        }
+
         // Node selection navigation in topology mode
         if (topologyMode && diagram.isShowDiagram() && diagram.hasDiagramData()
                 && !diagram.getNodeBoxes().isEmpty()) {
@@ -176,6 +188,10 @@ class DiagramTab implements MonitorTab {
 
     @Override
     public boolean handleEscape() {
+        if (sourceViewer.isVisible()) {
+            sourceViewer.hide();
+            return true;
+        }
         if (!topologyMode) {
             if (!routeNavigationStack.isEmpty()) {
                 // Go back to the previous route in the stack
@@ -235,12 +251,17 @@ class DiagramTab implements MonitorTab {
             return;
         }
 
+        if (sourceViewer.isVisible()) {
+            sourceViewer.render(frame, area);
+            return;
+        }
+
         if (diagram.isShowDiagram() && diagram.hasDiagramData()) {
             String title;
             if (topologyMode) {
                 title = " Topology ";
             } else {
-                title = " Route [" + drillDownRouteId + "] ";
+                title = " Route [" + buildBreadcrumb() + "] ";
             }
 
             String selectedRouteId = topologyMode ? 
diagram.getSelectedRouteId() : drillDownRouteId;
@@ -482,6 +503,10 @@ class DiagramTab implements MonitorTab {
 
     @Override
     public void renderFooter(List<Span> spans) {
+        if (sourceViewer.isVisible()) {
+            sourceViewer.renderFooter(spans);
+            return;
+        }
         if (diagram.isShowDiagram()) {
             if (!topologyMode && !diagram.getEipNodeBoxes().isEmpty()) {
                 hint(spans, "Esc", "back");
@@ -490,6 +515,7 @@ class DiagramTab implements MonitorTab {
                     hint(spans, "Enter", "jump to route");
                 }
                 hint(spans, "PgUp/PgDn", "page");
+                hint(spans, "c", "source");
             } else if (!topologyMode) {
                 hint(spans, "Esc", "back");
                 hint(spans, "↑↓←→", "scroll");
@@ -663,6 +689,31 @@ class DiagramTab implements MonitorTab {
         return result;
     }
 
+    private String buildBreadcrumb() {
+        if (routeNavigationStack.isEmpty()) {
+            return drillDownRouteId;
+        }
+        StringBuilder sb = new StringBuilder();
+        for (var it = routeNavigationStack.descendingIterator(); 
it.hasNext();) {
+            sb.append(it.next()).append(" → ");
+        }
+        sb.append(drillDownRouteId);
+        return sb.toString();
+    }
+
+    private void loadSourceForSelectedNode() {
+        if (drillDownRouteId == null) {
+            return;
+        }
+        int targetLine = 0;
+        var selected = diagram.getSelectedEipNodeBox();
+        if (selected != null && selected.layoutNode() != null
+                && selected.layoutNode().treeNode != null) {
+            targetLine = selected.layoutNode().treeNode.info.line;
+        }
+        sourceViewer.loadSource(ctx, drillDownRouteId, targetLine);
+    }
+
     private static int numWidth(long... values) {
         long max = 0;
         for (long v : values) {
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RoutesTab.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RoutesTab.java
index 2fa9976b70ba..3b457829afa4 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RoutesTab.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RoutesTab.java
@@ -18,9 +18,7 @@ package org.apache.camel.dsl.jbang.core.commands.tui;
 
 import java.nio.file.Path;
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.List;
-import java.util.concurrent.atomic.AtomicBoolean;
 
 import dev.tamboui.layout.Constraint;
 import dev.tamboui.layout.Layout;
@@ -31,23 +29,17 @@ import dev.tamboui.terminal.Frame;
 import dev.tamboui.text.Line;
 import dev.tamboui.text.Span;
 import dev.tamboui.text.Text;
-import dev.tamboui.tui.event.KeyCode;
 import dev.tamboui.tui.event.KeyEvent;
 import dev.tamboui.widgets.block.Block;
 import dev.tamboui.widgets.block.BorderType;
 import dev.tamboui.widgets.paragraph.Paragraph;
-import dev.tamboui.widgets.scrollbar.Scrollbar;
-import dev.tamboui.widgets.scrollbar.ScrollbarState;
 import dev.tamboui.widgets.table.Cell;
 import dev.tamboui.widgets.table.Row;
 import dev.tamboui.widgets.table.Table;
 import dev.tamboui.widgets.table.TableState;
 import org.apache.camel.dsl.jbang.core.common.PathUtils;
-import org.apache.camel.support.LoggerHelper;
-import org.apache.camel.util.FileUtil;
 import org.apache.camel.util.json.JsonArray;
 import org.apache.camel.util.json.JsonObject;
-import org.apache.camel.util.json.Jsoner;
 
 import static org.apache.camel.dsl.jbang.core.commands.tui.MonitorContext.*;
 
@@ -74,21 +66,11 @@ class RoutesTab implements MonitorTab {
 
     // Diagram support (shared rendering/loading logic)
     private final DiagramSupport diagram = new DiagramSupport();
+    private final SourceViewer sourceViewer = new SourceViewer();
     private boolean diagramAllRoutes;
     private boolean diagramMetrics = true;
     private String diagramRouteId;
 
-    // Source viewer state
-    private boolean showSource;
-    private List<String> sourceLines = Collections.emptyList();
-    private String sourceTitle;
-    private SyntaxHighlighter.Language sourceLanguage = 
SyntaxHighlighter.Language.PLAIN;
-    private int sourceScroll;
-    private int sourceScrollX;
-    private final ScrollbarState sourceVScrollState = new ScrollbarState();
-    private final ScrollbarState sourceHScrollState = new ScrollbarState();
-    private final AtomicBoolean sourceLoading = new AtomicBoolean(false);
-
     RoutesTab(MonitorContext ctx) {
         this.ctx = ctx;
     }
@@ -110,37 +92,13 @@ class RoutesTab implements MonitorTab {
     }
 
     boolean isShowSource() {
-        return showSource;
+        return sourceViewer.isVisible();
     }
 
     @Override
     public boolean handleKeyEvent(KeyEvent ke) {
         // Source view scrolling
-        if (showSource) {
-            if (ke.isChar('c') || ke.isCancel()) {
-                showSource = false;
-                return true;
-            }
-            if (ke.isUp()) {
-                sourceScroll = Math.max(0, sourceScroll - 1);
-            } else if (ke.isDown()) {
-                sourceScroll++;
-            } else if (ke.isPageUp() || ke.isKey(KeyCode.PAGE_UP)) {
-                sourceScroll = Math.max(0, sourceScroll - 20);
-            } else if (ke.isPageDown() || ke.isKey(KeyCode.PAGE_DOWN)) {
-                sourceScroll += 20;
-            } else if (ke.isLeft()) {
-                sourceScrollX = Math.max(0, sourceScrollX - 1);
-            } else if (ke.isRight()) {
-                sourceScrollX++;
-            } else if (ke.isHome()) {
-                sourceScroll = 0;
-                sourceScrollX = 0;
-            } else if (ke.isEnd()) {
-                sourceScroll = Integer.MAX_VALUE;
-            } else {
-                return false;
-            }
+        if (sourceViewer.handleKeyEvent(ke)) {
             return true;
         }
 
@@ -182,13 +140,13 @@ class RoutesTab implements MonitorTab {
         }
 
         // Toggle top mode (only when not in source or diagram view)
-        if (!showSource && !diagram.isShowDiagram() && 
ke.isCharIgnoreCase('t')) {
+        if (!sourceViewer.isVisible() && !diagram.isShowDiagram() && 
ke.isCharIgnoreCase('t')) {
             routeTopMode = !routeTopMode;
             return true;
         }
 
         // Toggle all routes diagram flag
-        if (!showSource && !diagram.isShowDiagram() && 
ke.isCharIgnoreCase('a')) {
+        if (!sourceViewer.isVisible() && !diagram.isShowDiagram() && 
ke.isCharIgnoreCase('a')) {
             diagramAllRoutes = !diagramAllRoutes;
             return true;
         }
@@ -209,8 +167,8 @@ class RoutesTab implements MonitorTab {
 
         // Source viewer toggle
         if (ke.isChar('c')) {
-            if (showSource) {
-                showSource = false;
+            if (sourceViewer.isVisible()) {
+                sourceViewer.hide();
             } else {
                 loadSourceForSelectedRoute();
             }
@@ -218,13 +176,13 @@ class RoutesTab implements MonitorTab {
         }
 
         // Route start/stop
-        if (!showSource && !diagram.isShowDiagram() && ke.isChar('p')) {
+        if (!sourceViewer.isVisible() && !diagram.isShowDiagram() && 
ke.isChar('p')) {
             toggleRouteStartStop();
             return true;
         }
 
         // Route suspend/resume
-        if (!showSource && !diagram.isShowDiagram() && ke.isChar('P') && 
selectedRouteSupportsSuspension()) {
+        if (!sourceViewer.isVisible() && !diagram.isShowDiagram() && 
ke.isChar('P') && selectedRouteSupportsSuspension()) {
             toggleRouteSuspendResume();
             return true;
         }
@@ -234,8 +192,8 @@ class RoutesTab implements MonitorTab {
 
     @Override
     public boolean handleEscape() {
-        if (showSource) {
-            showSource = false;
+        if (sourceViewer.isVisible()) {
+            sourceViewer.hide();
             return true;
         }
         return diagram.handleEscape();
@@ -254,11 +212,7 @@ class RoutesTab implements MonitorTab {
 
     @Override
     public void onIntegrationChanged() {
-        showSource = false;
-        sourceLines = Collections.emptyList();
-        sourceTitle = null;
-        sourceScroll = 0;
-        sourceScrollX = 0;
+        sourceViewer.reset();
         diagram.reset();
         routeTableState.select(0);
     }
@@ -272,12 +226,12 @@ class RoutesTab implements MonitorTab {
         }
 
         // Fullscreen source view
-        if (showSource) {
+        if (sourceViewer.isVisible()) {
             List<Rect> fullChunks = Layout.vertical()
                     .constraints(Constraint.length(4), Constraint.fill())
                     .split(area);
             renderRouteHeader(frame, fullChunks.get(0), info);
-            renderSource(frame, fullChunks.get(1));
+            sourceViewer.render(frame, fullChunks.get(1));
             return;
         }
 
@@ -456,11 +410,8 @@ class RoutesTab implements MonitorTab {
 
     @Override
     public void renderFooter(List<Span> spans) {
-        if (showSource) {
-            hint(spans, "c/Esc", "close");
-            hint(spans, "↑↓←→", "scroll");
-            hint(spans, "PgUp/PgDn", "page");
-            hintLast(spans, "Home/End", "top/end");
+        if (sourceViewer.isVisible()) {
+            sourceViewer.renderFooter(spans);
         } else if (diagram.isShowDiagram()) {
             diagram.renderFooterHints(spans);
             hint(spans, "m", "metrics" + (diagramMetrics ? " [on]" : " 
[off]"));
@@ -701,88 +652,6 @@ class RoutesTab implements MonitorTab {
         frame.renderStatefulWidget(table, area, routeHeaderTableState);
     }
 
-    private void renderSource(Frame frame, Rect area) {
-        Block block = Block.builder()
-                .borderType(BorderType.ROUNDED)
-                .title(" Source [" + (sourceTitle != null ? sourceTitle : "") 
+ "] ")
-                .build();
-        Rect inner = block.inner(area);
-        frame.renderWidget(block, area);
-
-        if (sourceLines.isEmpty()) {
-            return;
-        }
-
-        int visibleLines = inner.height();
-        int maxScroll = Math.max(0, sourceLines.size() - visibleLines);
-        sourceScroll = Math.min(sourceScroll, maxScroll);
-
-        int maxLineWidth = 
sourceLines.stream().mapToInt(String::length).max().orElse(0);
-        int maxHScroll = Math.max(0, maxLineWidth - inner.width());
-        sourceScrollX = Math.min(sourceScrollX, maxHScroll);
-
-        int end = Math.min(sourceScroll + visibleLines, sourceLines.size());
-        List<Line> visible = new ArrayList<>();
-        for (int i = sourceScroll; i < end; i++) {
-            String raw = sourceLines.get(i);
-            visible.add(highlightSourceLine(raw, sourceScrollX));
-        }
-        
frame.renderWidget(Paragraph.builder().text(Text.from(visible)).build(), inner);
-
-        // Vertical scrollbar
-        if (sourceLines.size() > visibleLines) {
-            
sourceVScrollState.contentLength(sourceLines.size()).viewportContentLength(visibleLines).position(sourceScroll);
-            frame.renderStatefulWidget(Scrollbar.builder().build(), inner, 
sourceVScrollState);
-        }
-        // Horizontal scrollbar
-        if (maxHScroll > 0) {
-            
sourceHScrollState.contentLength(maxLineWidth).viewportContentLength(inner.width()).position(sourceScrollX);
-            frame.renderStatefulWidget(Scrollbar.horizontal(), inner, 
sourceHScrollState);
-        }
-    }
-
-    private Line highlightSourceLine(String raw, int hSkip) {
-        // Split line number prefix from code content
-        int prefixEnd = 0;
-        while (prefixEnd < raw.length() && (raw.charAt(prefixEnd) == ' ' || 
Character.isDigit(raw.charAt(prefixEnd)))) {
-            prefixEnd++;
-        }
-
-        String prefix = raw.substring(0, prefixEnd);
-        String code = raw.substring(prefixEnd);
-
-        Line highlighted = SyntaxHighlighter.highlightLine(code, 
sourceLanguage);
-
-        // Prepend dim line-number prefix
-        List<Span> spans = new ArrayList<>();
-        if (!prefix.isEmpty()) {
-            spans.add(Span.styled(prefix, Style.EMPTY.dim()));
-        }
-        spans.addAll(highlighted.spans());
-
-        Line full = Line.from(spans);
-
-        // Apply horizontal scroll by skipping characters from spans
-        if (hSkip <= 0) {
-            return full;
-        }
-        List<Span> scrolled = new ArrayList<>();
-        int skipped = 0;
-        for (Span span : full.spans()) {
-            String content = span.content();
-            if (skipped >= hSkip) {
-                scrolled.add(span);
-            } else if (skipped + content.length() > hSkip) {
-                int offset = hSkip - skipped;
-                scrolled.add(Span.styled(content.substring(offset), 
span.style()));
-                skipped = hSkip;
-            } else {
-                skipped += content.length();
-            }
-        }
-        return scrolled.isEmpty() ? Line.from(List.of(Span.raw(""))) : 
Line.from(scrolled);
-    }
-
     // ---- Sorting ----
 
     private int sortRoute(RouteInfo a, RouteInfo b) {
@@ -1007,15 +876,8 @@ class RoutesTab implements MonitorTab {
     }
 
     private void loadSourceForSelectedRoute() {
-        if (ctx.selectedPid == null || ctx.runner == null) {
-            return;
-        }
-        if (!sourceLoading.compareAndSet(false, true)) {
-            return;
-        }
         IntegrationInfo info = ctx.findSelectedIntegration();
         if (info == null || info.routes.isEmpty()) {
-            sourceLoading.set(false);
             return;
         }
         List<RouteInfo> sortedRoutes = new ArrayList<>(info.routes);
@@ -1023,147 +885,7 @@ class RoutesTab implements MonitorTab {
         Integer sel = routeTableState.selected();
         RouteInfo selectedRoute = (sel != null && sel >= 0 && sel < 
sortedRoutes.size())
                 ? sortedRoutes.get(sel) : sortedRoutes.get(0);
-
-        sourceLines = List.of("(Loading source...)");
-        sourceTitle = selectedRoute.routeId;
-        sourceScroll = 0;
-        sourceScrollX = 0;
-        showSource = true;
-
-        String pid = ctx.selectedPid;
-        String routeId = selectedRoute.routeId;
-        ctx.runner.scheduler().execute(() -> {
-            try {
-                loadSourceInBackground(pid, routeId);
-            } finally {
-                sourceLoading.set(false);
-            }
-        });
-    }
-
-    private void loadSourceInBackground(String pid, String routeId) {
-        Path outputFile = ctx.getOutputFile(pid);
-        PathUtils.deleteFile(outputFile);
-
-        JsonObject root = new JsonObject();
-        root.put("action", "source");
-        root.put("filter", routeId);
-
-        Path actionFile = ctx.getActionFile(pid);
-        PathUtils.writeTextSafely(root.toJson(), actionFile);
-
-        JsonObject jo = pollJsonResponse(outputFile, 5000);
-        PathUtils.deleteFile(outputFile);
-
-        if (jo == null) {
-            applySourceResult(routeId, null, List.of("(No response from 
integration)"));
-            return;
-        }
-
-        JsonArray routes = (JsonArray) jo.get("routes");
-        if (routes == null || routes.isEmpty()) {
-            applySourceResult(routeId, null, List.of("(No source available for 
route: " + routeId + ")"));
-            return;
-        }
-
-        JsonObject routeObj = (JsonObject) routes.get(0);
-        String sourceLocation = objToString(routeObj.get("source"));
-        List<JsonObject> codeLines = routeObj.getCollection("code");
-        if (codeLines == null || codeLines.isEmpty()) {
-            applySourceResult(routeId, sourceLocation, List.of("(No source 
code available)"));
-            return;
-        }
-
-        List<String> lines = new ArrayList<>();
-        int maxLineNum = 0;
-        for (JsonObject codeLine : codeLines) {
-            Integer lineNum = codeLine.getInteger("line");
-            if (lineNum != null && lineNum > maxLineNum) {
-                maxLineNum = lineNum;
-            }
-        }
-        int lineNumWidth = String.valueOf(maxLineNum).length();
-        int matchLine = -1;
-        int idx = 0;
-        for (JsonObject codeLine : codeLines) {
-            Integer lineNum = codeLine.getInteger("line");
-            String code = Jsoner.unescape(objToString(codeLine.get("code")));
-            Boolean match = codeLine.getBoolean("match");
-            String prefix = lineNum != null
-                    ? String.format("%" + lineNumWidth + "d  ", lineNum)
-                    : String.format("%" + lineNumWidth + "s  ", "");
-            lines.add(prefix + code);
-            if (Boolean.TRUE.equals(match) && matchLine < 0) {
-                matchLine = idx;
-            }
-            idx++;
-        }
-
-        int scrollTo;
-        if (matchLine > 0) {
-            scrollTo = Math.max(0, matchLine - 2);
-        } else {
-            scrollTo = findLicenseHeaderEnd(codeLines);
-        }
-        applySourceResult(routeId, sourceLocation, lines, scrollTo);
-    }
-
-    private void applySourceResult(String routeId, String location, 
List<String> lines) {
-        applySourceResult(routeId, location, lines, 0);
-    }
-
-    private void applySourceResult(String routeId, String location, 
List<String> lines, int scrollTo) {
-        if (ctx.runner == null) {
-            return;
-        }
-        ctx.runner.runOnRenderThread(() -> {
-            if (!showSource) {
-                return;
-            }
-            String displayLoc = location != null ? 
FileUtil.stripPath(LoggerHelper.sourceNameOnly(location)) : null;
-            sourceTitle = displayLoc != null ? routeId + "  " + displayLoc : 
routeId;
-            sourceLanguage = SyntaxHighlighter.detectLanguage(location);
-            sourceLines = lines;
-            sourceScroll = scrollTo;
-        });
-    }
-
-    private static int findLicenseHeaderEnd(List<JsonObject> codeLines) {
-        // Auto-scroll past leading license/comment headers
-        boolean inBlock = false;
-        int lastCommentLine = -1;
-        for (int i = 0; i < codeLines.size(); i++) {
-            String code = objToString(codeLines.get(i).get("code")).trim();
-            if (i == 0 && code.isEmpty()) {
-                continue;
-            }
-            if (!inBlock && code.startsWith("/*")) {
-                inBlock = true;
-            }
-            if (inBlock) {
-                lastCommentLine = i;
-                if (code.contains("*/")) {
-                    inBlock = false;
-                }
-                continue;
-            }
-            // YAML/shell comment lines or XML comment lines at the top
-            if (code.startsWith("#") || code.startsWith("##") || 
code.startsWith("<!--")) {
-                lastCommentLine = i;
-                continue;
-            }
-            // Empty line right after comment block
-            if (lastCommentLine >= 0 && code.isEmpty()) {
-                lastCommentLine = i;
-                continue;
-            }
-            break;
-        }
-        return lastCommentLine >= 0 ? lastCommentLine + 1 : 0;
-    }
-
-    private static String objToString(Object o) {
-        return o != null ? o.toString() : "";
+        sourceViewer.loadSource(ctx, selectedRoute.routeId, 0);
     }
 
     @Override
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewer.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewer.java
new file mode 100644
index 000000000000..d048e82de5fd
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewer.java
@@ -0,0 +1,341 @@
+/*
+ * 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.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import dev.tamboui.layout.Rect;
+import dev.tamboui.style.Style;
+import dev.tamboui.terminal.Frame;
+import dev.tamboui.text.Line;
+import dev.tamboui.text.Span;
+import dev.tamboui.text.Text;
+import dev.tamboui.tui.event.KeyCode;
+import dev.tamboui.tui.event.KeyEvent;
+import dev.tamboui.widgets.block.Block;
+import dev.tamboui.widgets.block.BorderType;
+import dev.tamboui.widgets.paragraph.Paragraph;
+import dev.tamboui.widgets.scrollbar.Scrollbar;
+import dev.tamboui.widgets.scrollbar.ScrollbarState;
+import org.apache.camel.dsl.jbang.core.common.PathUtils;
+import org.apache.camel.support.LoggerHelper;
+import org.apache.camel.util.FileUtil;
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
+
+import static 
org.apache.camel.dsl.jbang.core.commands.tui.MonitorContext.pollJsonResponse;
+
+/**
+ * Reusable source code viewer with syntax highlighting, scrolling, and 
line-number display. Can be used by any tab that
+ * needs to show route source code.
+ */
+class SourceViewer {
+
+    private boolean visible;
+    private List<String> lines = Collections.emptyList();
+    private String title;
+    private SyntaxHighlighter.Language language = 
SyntaxHighlighter.Language.PLAIN;
+    private int scrollY;
+    private int scrollX;
+    private final ScrollbarState vScrollState = new ScrollbarState();
+    private final ScrollbarState hScrollState = new ScrollbarState();
+    private final AtomicBoolean loading = new AtomicBoolean(false);
+
+    boolean isVisible() {
+        return visible;
+    }
+
+    void hide() {
+        visible = false;
+    }
+
+    void reset() {
+        visible = false;
+        lines = Collections.emptyList();
+        title = null;
+        scrollY = 0;
+        scrollX = 0;
+    }
+
+    boolean handleKeyEvent(KeyEvent ke) {
+        if (!visible) {
+            return false;
+        }
+        if (ke.isChar('c') || ke.isCancel()) {
+            visible = false;
+            return true;
+        }
+        if (ke.isUp()) {
+            scrollY = Math.max(0, scrollY - 1);
+        } else if (ke.isDown()) {
+            scrollY++;
+        } else if (ke.isPageUp() || ke.isKey(KeyCode.PAGE_UP)) {
+            scrollY = Math.max(0, scrollY - 20);
+        } else if (ke.isPageDown() || ke.isKey(KeyCode.PAGE_DOWN)) {
+            scrollY += 20;
+        } else if (ke.isLeft()) {
+            scrollX = Math.max(0, scrollX - 1);
+        } else if (ke.isRight()) {
+            scrollX++;
+        } else if (ke.isHome()) {
+            scrollY = 0;
+            scrollX = 0;
+        } else if (ke.isEnd()) {
+            scrollY = Integer.MAX_VALUE;
+        } else {
+            return false;
+        }
+        return true;
+    }
+
+    void render(Frame frame, Rect area) {
+        Block block = Block.builder()
+                .borderType(BorderType.ROUNDED)
+                .title(" Source [" + (title != null ? title : "") + "] ")
+                .build();
+        Rect inner = block.inner(area);
+        frame.renderWidget(block, area);
+
+        if (lines.isEmpty()) {
+            return;
+        }
+
+        int visibleLines = inner.height();
+        int maxScroll = Math.max(0, lines.size() - visibleLines);
+        scrollY = Math.min(scrollY, maxScroll);
+
+        int maxLineWidth = 
lines.stream().mapToInt(String::length).max().orElse(0);
+        int maxHScroll = Math.max(0, maxLineWidth - inner.width());
+        scrollX = Math.min(scrollX, maxHScroll);
+
+        int end = Math.min(scrollY + visibleLines, lines.size());
+        List<Line> visible = new ArrayList<>();
+        for (int i = scrollY; i < end; i++) {
+            String raw = lines.get(i);
+            visible.add(highlightSourceLine(raw, scrollX));
+        }
+        
frame.renderWidget(Paragraph.builder().text(Text.from(visible)).build(), inner);
+
+        if (lines.size() > visibleLines) {
+            
vScrollState.contentLength(lines.size()).viewportContentLength(visibleLines).position(scrollY);
+            frame.renderStatefulWidget(Scrollbar.builder().build(), inner, 
vScrollState);
+        }
+        if (maxHScroll > 0) {
+            
hScrollState.contentLength(maxLineWidth).viewportContentLength(inner.width()).position(scrollX);
+            frame.renderStatefulWidget(Scrollbar.horizontal(), inner, 
hScrollState);
+        }
+    }
+
+    void renderFooter(List<Span> spans) {
+        MonitorContext.hint(spans, "Esc/c", "close");
+        MonitorContext.hint(spans, "↑↓", "scroll");
+        MonitorContext.hint(spans, "←→", "horizontal");
+        MonitorContext.hint(spans, "PgUp/PgDn", "page");
+    }
+
+    /**
+     * Load source for a route, scrolling to the given source line number.
+     */
+    void loadSource(MonitorContext ctx, String routeId, int targetLine) {
+        if (ctx.selectedPid == null || ctx.runner == null) {
+            return;
+        }
+        if (!loading.compareAndSet(false, true)) {
+            return;
+        }
+
+        lines = List.of("(Loading source...)");
+        title = routeId;
+        scrollY = 0;
+        scrollX = 0;
+        visible = true;
+
+        String pid = ctx.selectedPid;
+        ctx.runner.scheduler().execute(() -> {
+            try {
+                loadInBackground(ctx, pid, routeId, targetLine);
+            } finally {
+                loading.set(false);
+            }
+        });
+    }
+
+    private void loadInBackground(MonitorContext ctx, String pid, String 
routeId, int targetLine) {
+        Path outputFile = ctx.getOutputFile(pid);
+        PathUtils.deleteFile(outputFile);
+
+        JsonObject root = new JsonObject();
+        root.put("action", "source");
+        root.put("filter", routeId);
+
+        Path actionFile = ctx.getActionFile(pid);
+        PathUtils.writeTextSafely(root.toJson(), actionFile);
+
+        JsonObject jo = pollJsonResponse(outputFile, 5000);
+        PathUtils.deleteFile(outputFile);
+
+        if (jo == null) {
+            applyResult(ctx, routeId, null, List.of("(No response from 
integration)"), 0);
+            return;
+        }
+
+        JsonArray routes = (JsonArray) jo.get("routes");
+        if (routes == null || routes.isEmpty()) {
+            applyResult(ctx, routeId, null, List.of("(No source available for 
route: " + routeId + ")"), 0);
+            return;
+        }
+
+        JsonObject routeObj = (JsonObject) routes.get(0);
+        String sourceLocation = objToString(routeObj.get("source"));
+        List<JsonObject> codeLines = routeObj.getCollection("code");
+        if (codeLines == null || codeLines.isEmpty()) {
+            applyResult(ctx, routeId, sourceLocation, List.of("(No source code 
available)"), 0);
+            return;
+        }
+
+        List<String> result = new ArrayList<>();
+        int maxLineNum = 0;
+        for (JsonObject codeLine : codeLines) {
+            Integer lineNum = codeLine.getInteger("line");
+            if (lineNum != null && lineNum > maxLineNum) {
+                maxLineNum = lineNum;
+            }
+        }
+        int lineNumWidth = String.valueOf(maxLineNum).length();
+        int matchIdx = -1;
+        int idx = 0;
+        for (JsonObject codeLine : codeLines) {
+            Integer lineNum = codeLine.getInteger("line");
+            String code = Jsoner.unescape(objToString(codeLine.get("code")));
+            String prefix = lineNum != null
+                    ? String.format("%" + lineNumWidth + "d  ", lineNum)
+                    : String.format("%" + lineNumWidth + "s  ", "");
+            result.add(prefix + code);
+            if (targetLine > 0 && lineNum != null && lineNum == targetLine && 
matchIdx < 0) {
+                matchIdx = idx;
+            }
+            Boolean match = codeLine.getBoolean("match");
+            if (targetLine <= 0 && Boolean.TRUE.equals(match) && matchIdx < 0) 
{
+                matchIdx = idx;
+            }
+            idx++;
+        }
+
+        int scrollTo;
+        if (matchIdx >= 0) {
+            scrollTo = Math.max(0, matchIdx - 2);
+        } else {
+            scrollTo = findLicenseHeaderEnd(codeLines);
+        }
+        applyResult(ctx, routeId, sourceLocation, result, scrollTo);
+    }
+
+    private void applyResult(MonitorContext ctx, String routeId, String 
location, List<String> resultLines, int scrollTo) {
+        if (ctx.runner == null) {
+            return;
+        }
+        ctx.runner.runOnRenderThread(() -> {
+            if (!visible) {
+                return;
+            }
+            String displayLoc = location != null ? 
FileUtil.stripPath(LoggerHelper.sourceNameOnly(location)) : null;
+            title = displayLoc != null ? routeId + "  " + displayLoc : routeId;
+            language = SyntaxHighlighter.detectLanguage(location);
+            lines = resultLines;
+            scrollY = scrollTo;
+        });
+    }
+
+    private Line highlightSourceLine(String raw, int hSkip) {
+        int prefixEnd = 0;
+        while (prefixEnd < raw.length() && (raw.charAt(prefixEnd) == ' ' || 
Character.isDigit(raw.charAt(prefixEnd)))) {
+            prefixEnd++;
+        }
+
+        String prefix = raw.substring(0, prefixEnd);
+        String code = raw.substring(prefixEnd);
+
+        Line highlighted = SyntaxHighlighter.highlightLine(code, language);
+
+        List<Span> spans = new ArrayList<>();
+        if (!prefix.isEmpty()) {
+            spans.add(Span.styled(prefix, Style.EMPTY.dim()));
+        }
+        spans.addAll(highlighted.spans());
+
+        Line full = Line.from(spans);
+
+        if (hSkip <= 0) {
+            return full;
+        }
+        List<Span> scrolled = new ArrayList<>();
+        int skipped = 0;
+        for (Span span : full.spans()) {
+            String content = span.content();
+            if (skipped >= hSkip) {
+                scrolled.add(span);
+            } else if (skipped + content.length() > hSkip) {
+                int offset = hSkip - skipped;
+                scrolled.add(Span.styled(content.substring(offset), 
span.style()));
+                skipped = hSkip;
+            } else {
+                skipped += content.length();
+            }
+        }
+        return scrolled.isEmpty() ? Line.from(List.of(Span.raw(""))) : 
Line.from(scrolled);
+    }
+
+    static int findLicenseHeaderEnd(List<JsonObject> codeLines) {
+        boolean inBlock = false;
+        int lastCommentLine = -1;
+        for (int i = 0; i < codeLines.size(); i++) {
+            String code = objToString(codeLines.get(i).get("code")).trim();
+            if (i == 0 && code.isEmpty()) {
+                continue;
+            }
+            if (!inBlock && code.startsWith("/*")) {
+                inBlock = true;
+            }
+            if (inBlock) {
+                lastCommentLine = i;
+                if (code.contains("*/")) {
+                    inBlock = false;
+                }
+                continue;
+            }
+            if (code.startsWith("#") || code.startsWith("##") || 
code.startsWith("<!--")) {
+                lastCommentLine = i;
+                continue;
+            }
+            if (lastCommentLine >= 0 && code.isEmpty()) {
+                lastCommentLine = i;
+                continue;
+            }
+            break;
+        }
+        return lastCommentLine >= 0 ? lastCommentLine + 1 : 0;
+    }
+
+    private static String objToString(Object o) {
+        return o != null ? o.toString() : "";
+    }
+}


Reply via email to