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 14d08e8ec154 camel-jbang-tui: Add jump indicators to Source tab for 
cross-route navigation
14d08e8ec154 is described below

commit 14d08e8ec1548cf7dc2e2bc91355125eae44440d
Author: Claus Ibsen <[email protected]>
AuthorDate: Thu Aug 6 23:13:10 2026 +0200

    camel-jbang-tui: Add jump indicators to Source tab for cross-route 
navigation
    
    Add route jump links (↵ routeId) to the Source tab, similar to the
    Diagram tab's jump indicators. When viewing YAML route files, lines
    containing to/toD/wireTap/enrich/pollEnrich URIs that reference
    another route's from endpoint show a jump indicator. Pressing Enter
    on such a line navigates to the target route's definition.
    
    Reverse links are also shown on from: lines, indicating which route
    calls the current route, enabling bidirectional navigation.
    
    The feature works offline (phantom/stopped integrations) by statically
    scanning source files to match URIs across routes.
    
    camel-jbang-tui: Add Go to Route popup, plain mode fix, and F1 help
    
    - Add GotoRoutePopup: press g in Source tab to open a fuzzy-filterable
      list of all routes, with Enter to navigate to the selected route.
    - Align columns in the popup (route ID, from URI, file:line).
    - Disable jump indicators in plain mode (for clean copy/paste).
    - Document jump links and go-to route in the F1 help text.
    - Fix route ID tracking in scanYamlRoutes so reverse links show the
      correct caller route ID.
    
    Co-Authored-By: Claude Opus 4.6 <[email protected]>
    Signed-off-by: Claus Ibsen <[email protected]>
---
 .../jbang/core/commands/tui/GotoRoutePopup.java    | 258 +++++++++++++
 .../dsl/jbang/core/commands/tui/SourceTab.java     | 397 +++++++++++++++++++++
 .../dsl/jbang/core/commands/tui/SourceViewer.java  |  50 +++
 3 files changed, 705 insertions(+)

diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/GotoRoutePopup.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/GotoRoutePopup.java
new file mode 100644
index 000000000000..a2124cd8b64c
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/GotoRoutePopup.java
@@ -0,0 +1,258 @@
+/*
+ * 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.ArrayList;
+import java.util.List;
+
+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.tui.event.KeyCode;
+import dev.tamboui.tui.event.KeyEvent;
+import dev.tamboui.widgets.Clear;
+import dev.tamboui.widgets.block.Block;
+import dev.tamboui.widgets.block.BorderType;
+import dev.tamboui.widgets.block.Borders;
+import dev.tamboui.widgets.list.ListItem;
+import dev.tamboui.widgets.list.ListState;
+import dev.tamboui.widgets.list.ListWidget;
+import dev.tamboui.widgets.list.ScrollMode;
+import dev.tamboui.widgets.scrollbar.Scrollbar;
+import dev.tamboui.widgets.scrollbar.ScrollbarState;
+
+class GotoRoutePopup {
+
+    private boolean visible;
+    private final FuzzyFilter filter = new FuzzyFilter();
+    private final ListState listState = new ListState();
+    private final ScrollbarState scrollbarState = new ScrollbarState();
+    private List<RouteItem> allEntries;
+    private List<RouteItem> filteredEntries;
+    private RouteItem selectedEntry;
+
+    record RouteItem(String routeId, String fromUri, String filePath, int 
fromLine) {
+    }
+
+    boolean isVisible() {
+        return visible;
+    }
+
+    void open(List<SourceTab.RouteEntry> routeIndex) {
+        allEntries = new ArrayList<>();
+        for (SourceTab.RouteEntry re : routeIndex) {
+            allEntries.add(new RouteItem(re.routeId(), re.fromUri(), 
re.filePath(), re.fromLine()));
+        }
+        visible = true;
+        filter.clearFilter();
+        rebuildList();
+    }
+
+    void close() {
+        visible = false;
+        filter.clearFilter();
+    }
+
+    RouteItem consumeSelection() {
+        RouteItem entry = selectedEntry;
+        selectedEntry = null;
+        return entry;
+    }
+
+    boolean handleKeyEvent(KeyEvent ke) {
+        int size = filteredEntries != null ? filteredEntries.size() : 0;
+        if (ke.isCancel()) {
+            close();
+            return true;
+        }
+        if (ke.isUp()) {
+            listState.selectPrevious();
+            return true;
+        }
+        if (ke.isDown()) {
+            listState.selectNext(size);
+            return true;
+        }
+        if (ke.isPageUp() || ke.isKey(KeyCode.PAGE_UP)) {
+            for (int i = 0; i < 5; i++) {
+                listState.selectPrevious();
+            }
+            return true;
+        }
+        if (ke.isPageDown() || ke.isKey(KeyCode.PAGE_DOWN)) {
+            for (int i = 0; i < 5; i++) {
+                listState.selectNext(size);
+            }
+            return true;
+        }
+        if (ke.isHome() || ke.isKey(KeyCode.HOME)) {
+            listState.selectFirst();
+            return true;
+        }
+        if (ke.isEnd() || ke.isKey(KeyCode.END)) {
+            listState.selectLast(size);
+            return true;
+        }
+        if (ke.isConfirm()) {
+            Integer sel = listState.selected();
+            if (sel != null && filteredEntries != null && sel < 
filteredEntries.size()) {
+                selectedEntry = filteredEntries.get(sel);
+                close();
+            }
+            return true;
+        }
+        if (ke.isKey(KeyCode.BACKSPACE)) {
+            filter.deleteChar();
+            rebuildList();
+            return true;
+        }
+        if (ke.code() == KeyCode.CHAR && !ke.hasCtrl() && !ke.hasAlt()) {
+            filter.appendChar(ke.string().charAt(0));
+            rebuildList();
+            return true;
+        }
+        return true;
+    }
+
+    void render(Frame frame, Rect area) {
+        if (filteredEntries == null) {
+            return;
+        }
+        int popupW = Math.min(80, area.width() - 4);
+        int contentH = filteredEntries.size() + 2;
+        int maxH = area.height() - 4;
+        int popupH = contentH + 2 <= maxH ? contentH + 2 : Math.min(contentH + 
2, maxH - 6);
+        int x = area.left() + Math.max(0, (area.width() - popupW) / 2);
+        int y = area.top() + 2;
+        Rect popup = new Rect(x, y, Math.min(popupW, area.width()), 
Math.min(popupH, area.height() - 2));
+
+        frame.renderWidget(Clear.INSTANCE, popup);
+
+        String filterText = filter.hasFilter() ? filter.filter() : "";
+        String prompt = "> " + filterText + "█";
+
+        List<ListItem> items = new ArrayList<>();
+        items.add(ListItem.from(Line.from(Span.styled(prompt, Theme.info()))));
+        String sep = "─".repeat(Math.max(1, popupW - 2));
+        items.add(ListItem.from(Line.from(Span.styled(sep, 
Style.EMPTY.dim()))));
+
+        // compute column widths for alignment
+        int maxIdW = 0;
+        int maxUriW = 0;
+        for (RouteItem entry : filteredEntries) {
+            maxIdW = Math.max(maxIdW, entry.routeId().length());
+            maxUriW = Math.max(maxUriW, entry.fromUri().length());
+        }
+
+        Style normalStyle = Style.EMPTY;
+        Style matchStyle = Theme.label().bold();
+        Style dimStyle = Style.EMPTY.dim();
+        for (RouteItem entry : filteredEntries) {
+            List<Span> spans = new ArrayList<>();
+            spans.add(Span.raw(" "));
+
+            String routeId = entry.routeId();
+            String padded = routeId + " ".repeat(Math.max(0, maxIdW - 
routeId.length()));
+
+            if (filter.hasFilter()) {
+                int[] nameMatch = FuzzyFilter.fuzzyMatch(routeId, 
filter.filter());
+                if (nameMatch != null) {
+                    Line hl = FuzzyFilter.highlightLine(routeId, nameMatch, 
normalStyle, matchStyle);
+                    spans.addAll(hl.spans());
+                    spans.add(Span.raw(" ".repeat(Math.max(0, maxIdW - 
routeId.length()))));
+                } else {
+                    spans.add(Span.styled(padded, normalStyle));
+                }
+            } else {
+                spans.add(Span.styled(padded, normalStyle));
+            }
+
+            String uri = entry.fromUri();
+            String paddedUri = uri + " ".repeat(Math.max(0, maxUriW - 
uri.length()));
+            spans.add(Span.styled("  " + paddedUri, dimStyle));
+
+            String fileName = entry.filePath();
+            int lastSep = fileName.lastIndexOf('/');
+            if (lastSep >= 0) {
+                fileName = fileName.substring(lastSep + 1);
+            }
+            spans.add(Span.styled("  " + fileName + ":" + (entry.fromLine() + 
1), dimStyle));
+            items.add(ListItem.from(Line.from(spans)));
+        }
+
+        ListState renderState = new ListState();
+        Integer sel = listState.selected();
+        if (sel != null) {
+            renderState.select(sel + 2);
+        }
+
+        int total = allEntries != null ? allEntries.size() : 0;
+        int shown = filteredEntries.size();
+        String title = shown == total
+                ? " Go to Route (" + total + ") "
+                : " Go to Route (" + shown + "/" + total + ") ";
+
+        ListWidget list = ListWidget.builder()
+                .items(items.toArray(ListItem[]::new))
+                .highlightStyle(Theme.selectionBg())
+                .highlightSymbol("")
+                .scrollMode(ScrollMode.AUTO_SCROLL)
+                .block(Block.builder()
+                        .borderType(BorderType.ROUNDED).borders(Borders.ALL)
+                        .title(title)
+                        .build())
+                .build();
+        frame.renderStatefulWidget(list, popup, renderState);
+
+        int visibleRows = Math.max(1, popup.height() - 2);
+        if (shown + 2 > visibleRows) {
+            scrollbarState
+                    .contentLength(shown)
+                    .viewportContentLength(visibleRows)
+                    .position(sel != null ? sel : 0);
+            frame.renderStatefulWidget(Scrollbar.builder().build(), popup, 
scrollbarState);
+        }
+    }
+
+    private void rebuildList() {
+        if (allEntries == null) {
+            filteredEntries = List.of();
+            return;
+        }
+        if (!filter.hasFilter()) {
+            filteredEntries = new ArrayList<>(allEntries);
+        } else {
+            filteredEntries = new ArrayList<>();
+            String f = filter.filter();
+            for (RouteItem entry : allEntries) {
+                String searchText = entry.routeId() + " " + entry.fromUri();
+                boolean matches;
+                if (f.length() <= 2) {
+                    matches = searchText.toLowerCase().contains(f);
+                } else {
+                    matches = filter.match(searchText) != null;
+                }
+                if (matches) {
+                    filteredEntries.add(entry);
+                }
+            }
+        }
+        listState.select(filteredEntries.isEmpty() ? null : 0);
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceTab.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceTab.java
index 0df23241f51b..c184befb3a86 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceTab.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceTab.java
@@ -17,6 +17,7 @@
 package org.apache.camel.dsl.jbang.core.commands.tui;
 
 import java.io.IOException;
+import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.attribute.BasicFileAttributes;
@@ -118,6 +119,19 @@ class SourceTab extends AbstractTab {
     private static final Pattern YAML_KEY_PATTERN = Pattern.compile(
             "^\\s*-?\\s*([a-zA-Z][a-zA-Z0-9]*)\\s*:");
 
+    private static final Set<String> LINKABLE_KEYWORDS = Set.of(
+            "to", "toD", "wireTap", "enrich", "pollEnrich", 
"deadLetterChannel");
+
+    record RouteEntry(String routeId, String fromUri, String filePath, int 
fromLine) {
+    }
+
+    record ToEntry(String routeId, String toUri, String filePath, int toLine) {
+    }
+
+    private List<RouteEntry> routeIndex = Collections.emptyList();
+    private List<ToEntry> toIndex = Collections.emptyList();
+    private final GotoRoutePopup gotoRoutePopup = new GotoRoutePopup();
+
     SourceTab(MonitorContext ctx) {
         super(ctx);
         sourceViewer.setNotificationCallback((msg, error) -> {
@@ -126,6 +140,7 @@ class SourceTab extends AbstractTab {
             }
         });
         sourceViewer.setValidateOnSave(ctx.validateOnSave);
+        sourceViewer.setOnJumpLink(this::handleJumpLink);
     }
 
     boolean isSourceViewerEditMode() {
@@ -157,11 +172,22 @@ class SourceTab extends AbstractTab {
         leftPanelWidth = -1;
         completionTreeLoaded = false;
         completionTree = null;
+        routeIndex = Collections.emptyList();
+        toIndex = Collections.emptyList();
         refreshFiles();
     }
 
     @Override
     public boolean handleKeyEvent(KeyEvent ke) {
+        if (gotoRoutePopup.isVisible()) {
+            gotoRoutePopup.handleKeyEvent(ke);
+            GotoRoutePopup.RouteItem sel = gotoRoutePopup.consumeSelection();
+            if (sel != null) {
+                openFileAt(sel.filePath(), sel.fromLine());
+            }
+            return true;
+        }
+
         if (sourceViewer.isEditMode() && sourceViewer.isVisible()) {
             return sourceViewer.handleKeyEvent(ke);
         }
@@ -187,6 +213,11 @@ class SourceTab extends AbstractTab {
             }
         }
 
+        if (!routeIndex.isEmpty() && ke.isChar('g')) {
+            gotoRoutePopup.open(routeIndex);
+            return true;
+        }
+
         if (!focusOnViewer) {
             return handleFileListKey(ke);
         }
@@ -304,6 +335,10 @@ class SourceTab extends AbstractTab {
         renderInfoPanel(frame, leftChunks.get(1));
         hSplit.setBorderPos(rightArea.x());
         renderSourcePanel(frame, rightArea);
+
+        if (gotoRoutePopup.isVisible()) {
+            gotoRoutePopup.render(frame, area);
+        }
     }
 
     @Override
@@ -322,6 +357,9 @@ class SourceTab extends AbstractTab {
             if (sourceViewer.isVisible()) {
                 TuiHelper.hint(spans, "Tab", "viewer");
             }
+            if (!routeIndex.isEmpty()) {
+                TuiHelper.hint(spans, "g", "go to");
+            }
         }
     }
 
@@ -385,6 +423,18 @@ class SourceTab extends AbstractTab {
                 Use **Up/Down** to navigate, **Enter** to accept, **Esc** to 
dismiss, and
                 type to filter the completion list.
 
+                ## Route Jump Links
+                Lines with `to:`, `toD:`, `wireTap:`, or similar endpoints 
that reference
+                another route show a **↵ routeId** indicator. Press **Enter** 
on such a line
+                to jump to the target route's definition (within the same file 
or across files).
+                Reverse links are shown on `from:` lines, indicating which 
route calls this one.
+                Jump indicators are hidden in plain mode.
+
+                ## Go to Route
+                - **g** — open a filterable popup listing all routes found in 
the source files.
+                  Type to fuzzy-filter by route ID or endpoint URI, then press 
**Enter** to
+                  navigate to the selected route.
+
                 ## General
                 - **Tab** — toggle focus between file list and source viewer
                 - The focused panel title is highlighted; the unfocused panel 
dims
@@ -499,6 +549,7 @@ class SourceTab extends AbstractTab {
         entries = found;
         listState.select(0);
         currentDir = dir;
+        buildRouteIndex();
         return true;
     }
 
@@ -587,6 +638,9 @@ class SourceTab extends AbstractTab {
                     sourceViewer.setAutocompleteValueProvider(null);
                 }
                 sourceViewer.loadFile(filePath);
+                if (isCamelSourceFile(filePath)) {
+                    sourceViewer.setJumpLinks(computeJumpLinks(filePath));
+                }
                 focusOnViewer = true;
             }
         }
@@ -2020,4 +2074,347 @@ class SourceTab extends AbstractTab {
                     area);
         }
     }
+
+    // ---- Route jump links ----
+
+    private void buildRouteIndex() {
+        List<RouteEntry> fromEntries = new ArrayList<>();
+        List<ToEntry> toEntries = new ArrayList<>();
+        for (FilesBrowser.FileEntry entry : entries) {
+            if (entry.directory()) {
+                continue;
+            }
+            Path path = Path.of(entry.path());
+            if (!isCamelSourceFile(path)) {
+                continue;
+            }
+            if (isYamlFile(path)) {
+                scanYamlRoutes(path, fromEntries, toEntries);
+            }
+        }
+        routeIndex = fromEntries;
+        toIndex = toEntries;
+    }
+
+    private void scanYamlRoutes(Path file, List<RouteEntry> fromEntries, 
List<ToEntry> toEntries) {
+        List<String> lines;
+        try {
+            lines = Files.readAllLines(file, StandardCharsets.UTF_8);
+        } catch (IOException e) {
+            return;
+        }
+
+        String filePath = file.toString();
+        String currentRouteId = null;
+        int routeIdIndent = -1;
+        int pendingFromLine = -1;
+        boolean inLinkableBlock = false;
+        int linkableBlockIndent = -1;
+
+        for (int i = 0; i < lines.size(); i++) {
+            String line = lines.get(i);
+            String trimmed = line.trim();
+            if (trimmed.isEmpty() || trimmed.startsWith("#")) {
+                continue;
+            }
+
+            int indent = lineIndent(line);
+
+            // reset linkable context when dedented
+            if (linkableBlockIndent >= 0 && indent <= linkableBlockIndent) {
+                inLinkableBlock = false;
+                linkableBlockIndent = -1;
+            }
+
+            // detect route id
+            if (trimmed.startsWith("id:") && !trimmed.startsWith("id: \"\"")) {
+                String val = extractYamlValue(trimmed, "id");
+                if (val != null && !val.isEmpty()) {
+                    currentRouteId = val;
+                    routeIdIndent = indent;
+                }
+                continue;
+            }
+
+            // detect from: with inline URI
+            if (trimmed.startsWith("from:") || trimmed.startsWith("- from:")) {
+                inLinkableBlock = false;
+                linkableBlockIndent = -1;
+                String inlineUri = extractInlineUri(trimmed, "from");
+                if (inlineUri != null) {
+                    emitRouteEntry(fromEntries, currentRouteId, inlineUri, 
filePath, i);
+                } else {
+                    pendingFromLine = i;
+                }
+                continue;
+            }
+
+            // detect uri: line following a from: block
+            if (pendingFromLine >= 0 && trimmed.startsWith("uri:")) {
+                String uri = extractYamlValue(trimmed, "uri");
+                if (uri != null) {
+                    emitRouteEntry(fromEntries, currentRouteId, uri, filePath, 
pendingFromLine);
+                }
+                pendingFromLine = -1;
+                continue;
+            }
+
+            // reset pending from if we've moved past it
+            if (pendingFromLine >= 0 && indent <= 
lineIndent(lines.get(pendingFromLine))) {
+                pendingFromLine = -1;
+            }
+
+            // reset route id when a new route block starts
+            if (trimmed.startsWith("- route:") || trimmed.equals("route:")) {
+                currentRouteId = null;
+                routeIdIndent = -1;
+            }
+
+            // detect linkable keywords (to, toD, wireTap, etc.) and index 
their URIs
+            for (String kw : LINKABLE_KEYWORDS) {
+                String prefix1 = kw + ":";
+                String prefix2 = "- " + kw + ":";
+                if (trimmed.startsWith(prefix1) || 
trimmed.startsWith(prefix2)) {
+                    String after = trimmed.startsWith(prefix2)
+                            ? trimmed.substring(prefix2.length()).trim()
+                            : trimmed.substring(prefix1.length()).trim();
+                    if (!after.isEmpty() && !after.equals("{") && 
!after.startsWith("#")) {
+                        String toUri = stripQueryParams(unquote(after));
+                        if (toUri != null && !toUri.isEmpty()) {
+                            toEntries.add(new ToEntry(
+                                    currentRouteId != null ? currentRouteId : 
"", toUri, filePath, i));
+                        }
+                    } else {
+                        inLinkableBlock = true;
+                        linkableBlockIndent = indent;
+                    }
+                    break;
+                }
+            }
+
+            // uri: under a linkable block → index it as a to entry
+            if (inLinkableBlock && trimmed.startsWith("uri:")) {
+                String val = extractYamlValue(trimmed, "uri");
+                if (val != null && !val.isEmpty()) {
+                    String toUri = stripQueryParams(val);
+                    if (toUri != null && !toUri.isEmpty()) {
+                        toEntries.add(new ToEntry(
+                                currentRouteId != null ? currentRouteId : "", 
toUri, filePath, i));
+                    }
+                }
+            }
+        }
+    }
+
+    private void emitRouteEntry(List<RouteEntry> index, String routeId, String 
fromUri, String filePath, int fromLine) {
+        String baseUri = stripQueryParams(fromUri);
+        if (baseUri == null || baseUri.isEmpty()) {
+            return;
+        }
+        if (routeId == null || routeId.isEmpty()) {
+            // derive route id from the from URI
+            int colon = baseUri.indexOf(':');
+            routeId = colon >= 0 ? baseUri.substring(colon + 1) : baseUri;
+            if (routeId.startsWith("//")) {
+                routeId = routeId.substring(2);
+            }
+        }
+        index.add(new RouteEntry(routeId, baseUri, filePath, fromLine));
+    }
+
+    private Map<Integer, SourceViewer.JumpLink> computeJumpLinks(Path 
currentFile) {
+        if (routeIndex.isEmpty()) {
+            return Collections.emptyMap();
+        }
+
+        List<String> lines;
+        try {
+            lines = Files.readAllLines(currentFile, StandardCharsets.UTF_8);
+        } catch (IOException e) {
+            return Collections.emptyMap();
+        }
+
+        String currentFilePath = currentFile.toString();
+        Map<Integer, SourceViewer.JumpLink> result = new LinkedHashMap<>();
+
+        Map<String, RouteEntry> fromUriToRoute = new HashMap<>();
+        for (RouteEntry re : routeIndex) {
+            fromUriToRoute.put(re.fromUri(), re);
+        }
+
+        // forward links: to/toD/wireTap → target route's from
+        boolean inLinkableBlock = false;
+        int linkableBlockIndent = -1;
+        String currentRouteId = null;
+
+        for (int i = 0; i < lines.size(); i++) {
+            String line = lines.get(i);
+            String trimmed = line.trim();
+            if (trimmed.isEmpty() || trimmed.startsWith("#")) {
+                continue;
+            }
+
+            int indent = lineIndent(line);
+
+            if (linkableBlockIndent >= 0 && indent <= linkableBlockIndent) {
+                inLinkableBlock = false;
+                linkableBlockIndent = -1;
+            }
+
+            if (trimmed.startsWith("id:") && !trimmed.startsWith("id: \"\"")) {
+                String val = extractYamlValue(trimmed, "id");
+                if (val != null && !val.isEmpty()) {
+                    currentRouteId = val;
+                }
+            }
+
+            if (trimmed.startsWith("from:") || trimmed.startsWith("- from:")) {
+                inLinkableBlock = false;
+                linkableBlockIndent = -1;
+                continue;
+            }
+
+            String uri = null;
+            for (String kw : LINKABLE_KEYWORDS) {
+                String prefix1 = kw + ":";
+                String prefix2 = "- " + kw + ":";
+                if (trimmed.startsWith(prefix1) || 
trimmed.startsWith(prefix2)) {
+                    String after = trimmed.startsWith(prefix2)
+                            ? trimmed.substring(prefix2.length()).trim()
+                            : trimmed.substring(prefix1.length()).trim();
+                    if (!after.isEmpty() && !after.equals("{") && 
!after.startsWith("#")) {
+                        uri = unquote(after);
+                    } else {
+                        inLinkableBlock = true;
+                        linkableBlockIndent = indent;
+                    }
+                    break;
+                }
+            }
+
+            if (uri == null && inLinkableBlock && trimmed.startsWith("uri:")) {
+                String val = extractYamlValue(trimmed, "uri");
+                if (val != null && !val.isEmpty()) {
+                    uri = val;
+                }
+            }
+
+            if (uri != null) {
+                String baseUri = stripQueryParams(uri);
+                RouteEntry target = fromUriToRoute.get(baseUri);
+                if (target != null && 
!target.routeId().equals(currentRouteId)) {
+                    result.put(i, new SourceViewer.JumpLink(target.routeId(), 
target.filePath(), target.fromLine()));
+                }
+            }
+        }
+
+        // reverse links: from URI ← routes that send to it (jump to the 
caller's to: line)
+        for (RouteEntry re : routeIndex) {
+            if (!currentFilePath.equals(re.filePath())) {
+                continue;
+            }
+            for (ToEntry te : toIndex) {
+                if (te.routeId().equals(re.routeId())) {
+                    continue;
+                }
+                if (re.fromUri().equals(te.toUri())) {
+                    // add jump link on the from: line pointing to the caller
+                    String callerRouteId = te.routeId().isEmpty() ? "route" : 
te.routeId();
+                    result.putIfAbsent(re.fromLine(),
+                            new SourceViewer.JumpLink(callerRouteId, 
te.filePath(), te.toLine()));
+                    break;
+                }
+            }
+        }
+
+        return result;
+    }
+
+    private void openFileAt(String targetFilePath, int targetLine) {
+        String currentFile = sourceViewer.getCurrentFilePath();
+        if (currentFile != null && currentFile.equals(targetFilePath)) {
+            sourceViewer.goToLine(targetLine);
+            return;
+        }
+        for (int idx = 0; idx < entries.size(); idx++) {
+            FilesBrowser.FileEntry entry = entries.get(idx);
+            if (!entry.directory() && entry.path().equals(targetFilePath)) {
+                listState.select(idx);
+                Path filePath = Path.of(entry.path());
+                if (isCamelSourceFile(filePath)) {
+                    
sourceViewer.setQuickDocProvider(this::provideCamelQuickDocs);
+                    sourceViewer.setDeprecatedLineScanner(null);
+                    if (isYamlFile(filePath)) {
+                        
sourceViewer.setAutocompleteProvider(this::provideYamlKeyCompletions);
+                        
sourceViewer.setAutocompleteValueProvider(this::provideYamlValueCompletions);
+                        
sourceViewer.setEndpointValidator(this::validateYamlEndpoints);
+                        
sourceViewer.setListItemNodeChecker(this::isListChildrenNode);
+                    } else {
+                        sourceViewer.setAutocompleteProvider(null);
+                        sourceViewer.setAutocompleteValueProvider(null);
+                    }
+                }
+                sourceViewer.loadFile(filePath);
+                sourceViewer.setJumpLinks(computeJumpLinks(filePath));
+                sourceViewer.goToLine(targetLine);
+                focusOnViewer = true;
+                break;
+            }
+        }
+    }
+
+    private void handleJumpLink(SourceViewer.JumpLink link) {
+        openFileAt(link.filePath(), link.targetLine());
+    }
+
+    private static String extractYamlValue(String trimmed, String key) {
+        String prefix = key + ":";
+        if (!trimmed.startsWith(prefix)) {
+            return null;
+        }
+        String val = trimmed.substring(prefix.length()).trim();
+        return unquote(val);
+    }
+
+    private static String extractInlineUri(String trimmed, String key) {
+        String prefix = trimmed.startsWith("- ") ? "- " + key + ":" : key + 
":";
+        if (!trimmed.startsWith(prefix)) {
+            return null;
+        }
+        String val = trimmed.substring(prefix.length()).trim();
+        if (val.isEmpty() || val.equals("{") || val.startsWith("#")) {
+            return null;
+        }
+        return unquote(val);
+    }
+
+    private static String unquote(String val) {
+        if (val.length() >= 2 && val.startsWith("\"") && val.endsWith("\"")) {
+            return val.substring(1, val.length() - 1);
+        }
+        if (val.length() >= 2 && val.startsWith("'") && val.endsWith("'")) {
+            return val.substring(1, val.length() - 1);
+        }
+        return val;
+    }
+
+    private static String stripQueryParams(String uri) {
+        if (uri == null) {
+            return null;
+        }
+        int q = uri.indexOf('?');
+        return q >= 0 ? uri.substring(0, q) : uri;
+    }
+
+    private static int lineIndent(String line) {
+        int indent = 0;
+        for (int i = 0; i < line.length(); i++) {
+            if (line.charAt(i) == ' ') {
+                indent++;
+            } else {
+                break;
+            }
+        }
+        return indent;
+    }
 }
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
index 3ad8bf3d02c1..f0a4f9e8b561 100644
--- 
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
@@ -28,6 +28,7 @@ import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.function.BiConsumer;
+import java.util.function.Consumer;
 import java.util.function.IntConsumer;
 
 import com.networknt.schema.Error;
@@ -97,6 +98,9 @@ class SourceViewer {
         Set<Integer> scan(List<JsonObject> codeData);
     }
 
+    record JumpLink(String routeId, String filePath, int targetLine) {
+    }
+
     private boolean visible;
     private List<String> lines = Collections.emptyList();
     private List<JsonObject> codeData = Collections.emptyList();
@@ -129,6 +133,9 @@ class SourceViewer {
     private Map<Integer, List<DocEntry>> quickDocEntries = 
Collections.emptyMap();
     private DeprecatedLineScanner deprecatedLineScanner;
     private Set<Integer> deprecatedLines = Collections.emptySet();
+    private Map<Integer, JumpLink> jumpLinks = Collections.emptyMap();
+    private Consumer<JumpLink> onJumpLink;
+    private String loadedFilePath;
     private Style titleStyle;
     private Style borderStyle;
     private boolean focused = true;
@@ -243,6 +250,8 @@ class SourceViewer {
         quickDocEntries = Collections.emptyMap();
         deprecatedLineScanner = null;
         deprecatedLines = Collections.emptySet();
+        jumpLinks = Collections.emptyMap();
+        loadedFilePath = null;
         autocompleteProvider = null;
         autocompleteValueProvider = null;
         autocompletePopup = null;
@@ -310,6 +319,33 @@ class SourceViewer {
         this.onLineSelected = callback;
     }
 
+    void setJumpLinks(Map<Integer, JumpLink> links) {
+        this.jumpLinks = links != null ? links : Collections.emptyMap();
+    }
+
+    JumpLink getJumpLink(int lineIndex) {
+        return jumpLinks.get(lineIndex);
+    }
+
+    void setOnJumpLink(Consumer<JumpLink> callback) {
+        this.onJumpLink = callback;
+    }
+
+    int getSelectedLine() {
+        return selectedLine;
+    }
+
+    void goToLine(int lineIndex) {
+        if (lineIndex >= 0 && lineIndex < lines.size()) {
+            selectedLine = lineIndex;
+            pendingScroll = true;
+        }
+    }
+
+    String getCurrentFilePath() {
+        return loadedFilePath;
+    }
+
     void toggleQuickDoc() {
         if (quickDocProvider != null) {
             quickDocEnabled = !quickDocEnabled;
@@ -454,6 +490,9 @@ class SourceViewer {
             if (!lines.isEmpty()) {
                 selectedLine = lines.size() - 1;
             }
+        } else if (ke.isConfirm() && onJumpLink != null && 
jumpLinks.containsKey(selectedLine)) {
+            onJumpLink.accept(jumpLinks.get(selectedLine));
+            return true;
         } else if (ke.isConfirm() && onLineSelected != null) {
             if (selectedLine >= 0 && selectedLine < codeData.size()) {
                 Integer lineNum = 
codeData.get(selectedLine).getInteger("line");
@@ -2135,6 +2174,7 @@ class SourceViewer {
         originalFormat = null;
         currentCtx = null;
         currentPid = null;
+        loadedFilePath = filePath.toString();
         editMode = false;
         editState.clear();
         markdownModeBeforeEdit = false;
@@ -2174,6 +2214,7 @@ class SourceViewer {
             }
             editableFile = Files.isWritable(filePath) ? filePath : null;
             scanDeprecatedLines();
+            jumpLinks = Collections.emptyMap();
         } catch (IOException e) {
             title = fileName;
             lines = List.of("(Failed to read file: " + e.getMessage() + ")");
@@ -2616,6 +2657,15 @@ class SourceViewer {
             spans.addAll(highlighted.spans());
         }
 
+        JumpLink jl = plainMode ? null : jumpLinks.get(lineIndex);
+        if (jl != null) {
+            Style linkStyle = Theme.label().bold();
+            if (isSelected) {
+                linkStyle = linkStyle.patch(selBg);
+            }
+            spans.add(Span.styled(" ↵ " + jl.routeId(), linkStyle));
+        }
+
         Line full = Line.from(spans);
 
         if (hSkip > 0) {

Reply via email to