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() : ""; + } +}
