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 52133801be09 CAMEL-24374: Add Ctrl+G go-to-node popup in TUI Source 
editor
52133801be09 is described below

commit 52133801be09213a0b4b3224d0a07865f51143ca
Author: Omar Atie <[email protected]>
AuthorDate: Sun Aug 9 22:50:43 2026 -0700

    CAMEL-24374: Add Ctrl+G go-to-node popup in TUI Source editor
    
    Implements a tree-style go-to-node popup (Ctrl+G) in the Source tab that
    shows routes and their individual processors/EIPs with fuzzy filtering.
    The existing 'g' shortcut remains for quick route-level navigation.
    
    Adds YamlRouteNodeScanner to index route headers and navigable processor
    lines, GotoSourceNodePopup for the tree popup, and extends SourceViewer
    with goToLine() cursor positioning in edit mode. Cross-file navigation
    is blocked when the edit buffer has unsaved changes.
    
    Closes #25411
    
    Co-Authored-By: Cursor Agent <[email protected]>
---
 .../core/commands/tui/GotoSourceNodePopup.java     | 310 +++++++++++++++++
 .../dsl/jbang/core/commands/tui/SourceTab.java     |  48 ++-
 .../dsl/jbang/core/commands/tui/SourceViewer.java  |  12 +
 .../core/commands/tui/YamlRouteNodeScanner.java    | 382 +++++++++++++++++++++
 .../core/commands/tui/GotoSourceNodePopupTest.java | 138 ++++++++
 .../commands/tui/SourceViewerGoToLineTest.java     |  71 ++++
 .../commands/tui/YamlRouteNodeScannerTest.java     | 221 ++++++++++++
 7 files changed, 1181 insertions(+), 1 deletion(-)

diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/GotoSourceNodePopup.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/GotoSourceNodePopup.java
new file mode 100644
index 000000000000..df36e44cdac5
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/GotoSourceNodePopup.java
@@ -0,0 +1,310 @@
+/*
+ * 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.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import dev.tamboui.layout.Rect;
+import dev.tamboui.style.Color;
+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;
+import org.apache.camel.dsl.jbang.core.commands.tui.diagram.DiagramColors;
+
+class GotoSourceNodePopup {
+
+    private boolean visible;
+    private final FuzzyFilter filter = new FuzzyFilter();
+    private final ListState listState = new ListState();
+    private final ScrollbarState scrollbarState = new ScrollbarState();
+    private List<YamlRouteNodeScanner.NodeEntry> allEntries;
+    private List<YamlRouteNodeScanner.NodeEntry> filteredEntries;
+    private YamlRouteNodeScanner.NodeEntry selectedEntry;
+
+    boolean isVisible() {
+        return visible;
+    }
+
+    void open(List<YamlRouteNodeScanner.NodeEntry> entries) {
+        allEntries = entries != null ? new ArrayList<>(entries) : List.of();
+        visible = true;
+        filter.clearFilter();
+        rebuildList();
+    }
+
+    void close() {
+        visible = false;
+        filter.clearFilter();
+    }
+
+    YamlRouteNodeScanner.NodeEntry consumeSelection() {
+        YamlRouteNodeScanner.NodeEntry 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(90, 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()))));
+
+        int maxTypeW = 0;
+        for (YamlRouteNodeScanner.NodeEntry entry : filteredEntries) {
+            if (entry.kind() == YamlRouteNodeScanner.EntryKind.PROCESSOR) {
+                maxTypeW = Math.max(maxTypeW, entry.type().length());
+            }
+        }
+        int maxLabelW = Math.max(10, popupW - maxTypeW - 14);
+
+        Style normalStyle = Style.EMPTY;
+        Style matchStyle = Theme.label().bold();
+        Style dimStyle = Style.EMPTY.dim();
+        Style routeStyle = Theme.label().bold();
+
+        for (YamlRouteNodeScanner.NodeEntry entry : filteredEntries) {
+            List<Span> spans = new ArrayList<>();
+            String indent = " ".repeat(entry.indent() * 2 + 1);
+
+            if (entry.kind() == YamlRouteNodeScanner.EntryKind.ROUTE) {
+                spans.add(Span.raw(indent));
+                String routeLabel = entry.routeId() + "  " + entry.fromUri();
+                if (filter.hasFilter()) {
+                    int[] match = FuzzyFilter.fuzzyMatch(routeLabel, 
filter.filter());
+                    if (match != null) {
+                        spans.addAll(FuzzyFilter.highlightLine(routeLabel, 
match, routeStyle, matchStyle).spans());
+                    } else {
+                        spans.add(Span.styled(routeLabel, routeStyle));
+                    }
+                } else {
+                    spans.add(Span.styled(routeLabel, routeStyle));
+                }
+                String fileName = shortFileName(entry.filePath());
+                spans.add(Span.styled("  " + fileName + ":" + 
(entry.lineIndex() + 1), dimStyle));
+            } else {
+                spans.add(Span.raw(indent));
+                String typeTag = entry.type();
+                String typePad = " ".repeat(Math.max(0, maxTypeW - 
typeTag.length()));
+                Color eipColor = 
DiagramColors.getEipColor(SourceViewer.dashToCamelCase(typeTag));
+                spans.add(Span.styled("[" + typeTag + "]" + typePad, 
Style.EMPTY.fg(eipColor).bold()));
+                spans.add(Span.raw(" "));
+
+                String searchable = entry.label().isBlank() ? entry.type() : 
entry.label();
+                if (searchable.length() > maxLabelW && maxLabelW > 3) {
+                    searchable = searchable.substring(0, maxLabelW - 1) + "…";
+                }
+
+                if (filter.hasFilter()) {
+                    int[] nameMatch = FuzzyFilter.fuzzyMatch(searchable, 
filter.filter());
+                    if (nameMatch != null) {
+                        Line hl = FuzzyFilter.highlightLine(searchable, 
nameMatch, normalStyle, matchStyle);
+                        spans.addAll(hl.spans());
+                    } else {
+                        spans.add(Span.styled(searchable, normalStyle));
+                    }
+                } else {
+                    spans.add(Span.styled(searchable, normalStyle));
+                }
+            }
+
+            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 Node (" + total + ") "
+                : " Go to Node (" + 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 static String shortFileName(String filePath) {
+        int lastSep = filePath.lastIndexOf('/');
+        return lastSep >= 0 ? filePath.substring(lastSep + 1) : filePath;
+    }
+
+    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().toLowerCase();
+            Map<String, Boolean> routeMatched = new LinkedHashMap<>();
+
+            for (YamlRouteNodeScanner.NodeEntry entry : allEntries) {
+                if (entry.kind() == YamlRouteNodeScanner.EntryKind.ROUTE) {
+                    routeMatched.put(routeKey(entry), matches(entry, f));
+                }
+            }
+
+            for (YamlRouteNodeScanner.NodeEntry entry : allEntries) {
+                String key = routeKey(entry);
+                if (entry.kind() == YamlRouteNodeScanner.EntryKind.ROUTE) {
+                    if (Boolean.TRUE.equals(routeMatched.get(key)) || 
hasMatchingProcessor(entry, f)) {
+                        filteredEntries.add(entry);
+                    }
+                } else if (Boolean.TRUE.equals(routeMatched.get(key)) || 
matches(entry, f)) {
+                    filteredEntries.add(entry);
+                }
+            }
+        }
+        listState.select(filteredEntries.isEmpty() ? null : 0);
+    }
+
+    private boolean hasMatchingProcessor(YamlRouteNodeScanner.NodeEntry 
routeEntry, String f) {
+        String key = routeKey(routeEntry);
+        for (YamlRouteNodeScanner.NodeEntry entry : allEntries) {
+            if (entry.kind() == YamlRouteNodeScanner.EntryKind.PROCESSOR
+                    && routeKey(entry).equals(key)
+                    && matches(entry, f)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private static String routeKey(YamlRouteNodeScanner.NodeEntry entry) {
+        return entry.filePath() + "#" + entry.routeFromLine();
+    }
+
+    private boolean matches(YamlRouteNodeScanner.NodeEntry entry, String f) {
+        String searchText = entry.kind() == 
YamlRouteNodeScanner.EntryKind.ROUTE
+                ? entry.routeId() + " " + entry.fromUri()
+                : entry.type() + " " + entry.label() + " " + entry.routeId();
+        if (f.length() <= 2) {
+            return searchText.toLowerCase().contains(f);
+        }
+        return filter.match(searchText) != null;
+    }
+}
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 618ee3f38d77..cce9ca721d92 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
@@ -131,6 +131,7 @@ class SourceTab extends AbstractTab {
     private List<RouteEntry> routeIndex = Collections.emptyList();
     private List<ToEntry> toIndex = Collections.emptyList();
     private final GotoRoutePopup gotoRoutePopup = new GotoRoutePopup();
+    private final GotoSourceNodePopup gotoSourceNodePopup = new 
GotoSourceNodePopup();
 
     SourceTab(MonitorContext ctx) {
         super(ctx);
@@ -188,6 +189,20 @@ class SourceTab extends AbstractTab {
             return true;
         }
 
+        if (gotoSourceNodePopup.isVisible()) {
+            gotoSourceNodePopup.handleKeyEvent(ke);
+            YamlRouteNodeScanner.NodeEntry sel = 
gotoSourceNodePopup.consumeSelection();
+            if (sel != null) {
+                openFileAt(sel.filePath(), sel.lineIndex());
+            }
+            return true;
+        }
+
+        if (ke.hasCtrl() && ke.isCharIgnoreCase('g') && !routeIndex.isEmpty()) 
{
+            gotoSourceNodePopup.open(buildSourceNodeIndex());
+            return true;
+        }
+
         if (sourceViewer.isEditMode() && sourceViewer.isVisible()) {
             return sourceViewer.handleKeyEvent(ke);
         }
@@ -339,6 +354,9 @@ class SourceTab extends AbstractTab {
         if (gotoRoutePopup.isVisible()) {
             gotoRoutePopup.render(frame, area);
         }
+        if (gotoSourceNodePopup.isVisible()) {
+            gotoSourceNodePopup.render(frame, area);
+        }
     }
 
     @Override
@@ -358,7 +376,8 @@ class SourceTab extends AbstractTab {
                 TuiHelper.hint(spans, "Tab", "viewer");
             }
             if (!routeIndex.isEmpty()) {
-                TuiHelper.hint(spans, "g", "go to");
+                TuiHelper.hint(spans, "g", "go to route");
+                TuiHelper.hint(spans, "Ctrl+G", "go to node");
             }
         }
     }
@@ -435,6 +454,12 @@ class SourceTab extends AbstractTab {
                   Type to fuzzy-filter by route ID or endpoint URI, then press 
**Enter** to
                   navigate to the selected route.
 
+                ## Go to Node
+                - **Ctrl+G** — open an expanded popup showing routes and their 
individual
+                  processors/EIPs in a tree structure. Type to fuzzy-filter by 
route ID,
+                  EIP type, or label, then press **Enter** to jump directly to 
the selected
+                  node in the source editor.
+
                 ## General
                 - **Tab** — toggle focus between file list and source viewer
                 - The focused panel title is highlighted; the unfocused panel 
dims
@@ -2112,6 +2137,21 @@ class SourceTab extends AbstractTab {
         toIndex = toEntries;
     }
 
+    private List<YamlRouteNodeScanner.NodeEntry> buildSourceNodeIndex() {
+        List<YamlRouteNodeScanner.NodeEntry> nodes = new ArrayList<>();
+        for (FilesBrowser.FileEntry entry : entries) {
+            if (entry.directory()) {
+                continue;
+            }
+            Path path = Path.of(entry.path());
+            if (!isCamelSourceFile(path) || !isYamlFile(path)) {
+                continue;
+            }
+            nodes.addAll(YamlRouteNodeScanner.scanFile(path));
+        }
+        return nodes;
+    }
+
     private void scanYamlRoutes(Path file, List<RouteEntry> fromEntries, 
List<ToEntry> toEntries) {
         List<String> lines;
         try {
@@ -2352,6 +2392,12 @@ class SourceTab extends AbstractTab {
             sourceViewer.goToLine(targetLine);
             return;
         }
+        if (sourceViewer.isEditMode() && sourceViewer.isDirty()) {
+            if (ctx.notificationCallback != null) {
+                ctx.notificationCallback.accept("Save or discard edits before 
navigating to another file", false);
+            }
+            return;
+        }
         for (int idx = 0; idx < entries.size(); idx++) {
             FilesBrowser.FileEntry entry = entries.get(idx);
             if (!entry.directory() && entry.path().equals(targetFilePath)) {
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 64104630462f..7735e9e97ef4 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
@@ -268,6 +268,10 @@ class SourceViewer {
         return editMode;
     }
 
+    boolean isDirty() {
+        return dirty;
+    }
+
     TextAreaState editState() {
         return editState;
     }
@@ -339,6 +343,14 @@ class SourceViewer {
         if (lineIndex >= 0 && lineIndex < lines.size()) {
             selectedLine = lineIndex;
             pendingScroll = true;
+            if (editMode) {
+                editState.moveCursorToStart();
+                int targetRow = Math.max(0, lineIndex);
+                for (int i = 0; i < targetRow && i < editState.lineCount() - 
1; i++) {
+                    editState.moveCursorDown();
+                }
+                editState.moveCursorToLineStart();
+            }
         }
     }
 
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/YamlRouteNodeScanner.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/YamlRouteNodeScanner.java
new file mode 100644
index 000000000000..c3f74c72b2e4
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/YamlRouteNodeScanner.java
@@ -0,0 +1,382 @@
+/*
+ * 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.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Scans Camel YAML route files and builds a flat tree of route headers and 
navigable processor/EIP nodes.
+ */
+class YamlRouteNodeScanner {
+
+    private static final Set<String> STRUCTURAL_KEYS = Set.of(
+            "steps", "uri", "parameters", "from", "expression", 
"routeConfiguration",
+            "routeTemplate", "templatedRoute", "rest", "beans");
+
+    private static final Set<String> BOILERPLATE_KEYS = Set.of("id", "note", 
"description", "disabled");
+
+    private static final Set<String> ENDPOINT_EIPS = Set.of(
+            "from", "to", "toD", "to-d", "wireTap", "wire-tap", "enrich",
+            "pollEnrich", "poll-enrich", "poll", "interceptFrom", 
"intercept-from",
+            "interceptSendToEndpoint", "intercept-send-to-endpoint");
+
+    enum EntryKind {
+        ROUTE,
+        PROCESSOR
+    }
+
+    record NodeEntry(
+            EntryKind kind,
+            String routeId,
+            String fromUri,
+            String type,
+            String label,
+            String filePath,
+            int lineIndex,
+            int indent,
+            int routeFromLine) {
+    }
+
+    static List<NodeEntry> scanFile(Path file) {
+        List<String> lines;
+        try {
+            lines = Files.readAllLines(file, StandardCharsets.UTF_8);
+        } catch (IOException e) {
+            return List.of();
+        }
+        return scanLines(lines, file.toString());
+    }
+
+    static List<NodeEntry> scanLines(List<String> lines, String filePath) {
+        List<NodeEntry> result = new ArrayList<>();
+
+        String currentRouteId = null;
+        String currentFromUri = null;
+        int pendingFromLine = -1;
+        boolean routeHeaderEmitted = false;
+        int activeRouteFromLine = -1;
+        int activeRouteIndent = -1;
+        String pendingEndpointEip = null;
+        int pendingEndpointIndent = -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);
+
+            if (routeHeaderEmitted && activeRouteIndent >= 0 && indent <= 
activeRouteIndent
+                    && !trimmed.startsWith("uri:") && 
!trimmed.startsWith("from:")
+                    && !trimmed.startsWith("- from:")) {
+                routeHeaderEmitted = false;
+                activeRouteFromLine = -1;
+                activeRouteIndent = -1;
+                pendingEndpointEip = null;
+                pendingEndpointIndent = -1;
+            }
+
+            if (trimmed.startsWith("id:") && !trimmed.startsWith("id: \"\"")) {
+                if (!routeHeaderEmitted) {
+                    String val = extractYamlValue(trimmed, "id");
+                    if (val != null && !val.isEmpty()) {
+                        currentRouteId = val;
+                    }
+                }
+                continue;
+            }
+
+            if (trimmed.startsWith("- route:") || trimmed.equals("route:")) {
+                currentRouteId = null;
+                currentFromUri = null;
+                pendingFromLine = -1;
+                routeHeaderEmitted = false;
+                activeRouteFromLine = -1;
+                activeRouteIndent = -1;
+                pendingEndpointEip = null;
+                pendingEndpointIndent = -1;
+                continue;
+            }
+
+            if (trimmed.startsWith("from:") || trimmed.startsWith("- from:")) {
+                if (trimmed.startsWith("- from:")) {
+                    currentRouteId = null;
+                }
+                currentFromUri = null;
+                routeHeaderEmitted = false;
+                activeRouteFromLine = -1;
+                activeRouteIndent = -1;
+                pendingEndpointEip = null;
+                pendingEndpointIndent = -1;
+                String inlineUri = extractInlineUri(trimmed, "from");
+                if (inlineUri != null) {
+                    currentFromUri = stripQueryParams(inlineUri);
+                    activeRouteFromLine = i;
+                    activeRouteIndent = indent;
+                    emitRouteHeader(result, currentRouteId, currentFromUri, 
filePath, i);
+                    routeHeaderEmitted = true;
+                    pendingFromLine = -1;
+                } else {
+                    pendingFromLine = i;
+                    activeRouteIndent = indent;
+                }
+                continue;
+            }
+
+            if (pendingFromLine >= 0 && trimmed.startsWith("uri:")) {
+                String uri = extractYamlValue(trimmed, "uri");
+                if (uri != null) {
+                    currentFromUri = stripQueryParams(uri);
+                    activeRouteFromLine = pendingFromLine;
+                    emitRouteHeader(result, currentRouteId, currentFromUri, 
filePath, pendingFromLine);
+                    routeHeaderEmitted = true;
+                }
+                pendingFromLine = -1;
+                continue;
+            }
+
+            if (pendingFromLine >= 0 && indent <= 
lineIndent(lines.get(pendingFromLine))) {
+                pendingFromLine = -1;
+            }
+
+            if (!routeHeaderEmitted || activeRouteFromLine < 0) {
+                continue;
+            }
+
+            if (isBlockEndpointEipLine(trimmed)) {
+                String eip = extractNodeType(line);
+                if (eip != null && ENDPOINT_EIPS.contains(eip)) {
+                    pendingEndpointEip = eip;
+                    pendingEndpointIndent = indent;
+                }
+                continue;
+            }
+
+            if (isNavigableNodeLine(line)) {
+                String type = extractNodeType(line);
+                if (type == null || BOILERPLATE_KEYS.contains(type)) {
+                    continue;
+                }
+                if (STRUCTURAL_KEYS.contains(type) && !isUriLine(line)) {
+                    continue;
+                }
+                if ("uri".equals(type) && pendingEndpointEip != null && indent 
> pendingEndpointIndent) {
+                    type = pendingEndpointEip;
+                    pendingEndpointEip = null;
+                    pendingEndpointIndent = -1;
+                }
+                String routeId = resolveRouteId(currentRouteId, 
currentFromUri);
+                String label = buildNodeLabel(line, lines, i);
+                int nodeIndent = Math.max(1, (indent - activeRouteIndent) / 2);
+                result.add(new NodeEntry(
+                        EntryKind.PROCESSOR, routeId, null, type, label, 
filePath, i, nodeIndent,
+                        activeRouteFromLine));
+            }
+        }
+
+        return result;
+    }
+
+    private static void emitRouteHeader(
+            List<NodeEntry> result, String routeId, String fromUri, String 
filePath, int fromLine) {
+        String resolvedId = resolveRouteId(routeId, fromUri);
+        result.add(new NodeEntry(
+                EntryKind.ROUTE, resolvedId, fromUri, "route", fromUri, 
filePath, fromLine, 0, fromLine));
+    }
+
+    private static String resolveRouteId(String routeId, String fromUri) {
+        if (routeId != null && !routeId.isEmpty()) {
+            return routeId;
+        }
+        if (fromUri == null || fromUri.isEmpty()) {
+            return "route";
+        }
+        int colon = fromUri.indexOf(':');
+        String derived = colon >= 0 ? fromUri.substring(colon + 1) : fromUri;
+        if (derived.startsWith("//")) {
+            derived = derived.substring(2);
+        }
+        return derived.isEmpty() ? "route" : derived;
+    }
+
+    static boolean isNavigableNodeLine(String line) {
+        String trimmed = line.trim();
+        if (trimmed.isEmpty() || trimmed.startsWith("#")) {
+            return false;
+        }
+
+        if (isUriLine(line)) {
+            return true;
+        }
+
+        String content = trimmed.startsWith("- ") ? 
trimmed.substring(2).trim() : trimmed;
+        int colonIdx = content.indexOf(':');
+        if (colonIdx <= 0) {
+            return false;
+        }
+
+        String key = content.substring(0, colonIdx).trim();
+        if (BOILERPLATE_KEYS.contains(key) || STRUCTURAL_KEYS.contains(key)) {
+            return false;
+        }
+
+        String after = content.substring(colonIdx + 1).trim();
+        if (ENDPOINT_EIPS.contains(key)) {
+            return !after.isEmpty() && !after.equals("{");
+        }
+
+        if (trimmed.startsWith("- ")) {
+            return !after.startsWith("#");
+        }
+
+        return false;
+    }
+
+    private static boolean isBlockEndpointEipLine(String trimmed) {
+        if (!trimmed.startsWith("- ")) {
+            return false;
+        }
+        String content = trimmed.substring(2).trim();
+        int colonIdx = content.indexOf(':');
+        if (colonIdx <= 0) {
+            return false;
+        }
+        String key = content.substring(0, colonIdx).trim();
+        if (!ENDPOINT_EIPS.contains(key)) {
+            return false;
+        }
+        String after = content.substring(colonIdx + 1).trim();
+        return after.isEmpty() || after.equals("{");
+    }
+
+    private static boolean isUriLine(String line) {
+        String content = line.trim();
+        if (content.startsWith("- ")) {
+            content = content.substring(2).trim();
+        }
+        return content.startsWith("uri:");
+    }
+
+    static String extractNodeType(String line) {
+        String trimmed = line.trim();
+        String content = trimmed.startsWith("- ") ? 
trimmed.substring(2).trim() : trimmed;
+        if (content.startsWith("uri:")) {
+            return "uri";
+        }
+        int colonIdx = content.indexOf(':');
+        if (colonIdx > 0) {
+            return content.substring(0, colonIdx).trim();
+        }
+        return null;
+    }
+
+    static String buildNodeLabel(String line, List<String> lines, int 
lineIndex) {
+        String trimmed = line.trim();
+        String content = trimmed.startsWith("- ") ? 
trimmed.substring(2).trim() : trimmed;
+
+        if (content.startsWith("uri:")) {
+            String uri = extractYamlValue(content, "uri");
+            return uri != null ? uri : "";
+        }
+
+        int colonIdx = content.indexOf(':');
+        if (colonIdx > 0) {
+            String after = content.substring(colonIdx + 1).trim();
+            if (!after.isEmpty() && !after.equals("{") && 
!after.startsWith("#")) {
+                return unquote(after);
+            }
+        }
+
+        int baseIndent = lineIndent(line);
+        for (int j = lineIndex + 1; j < lines.size(); j++) {
+            String next = lines.get(j);
+            if (next.isBlank()) {
+                continue;
+            }
+            int nextIndent = lineIndent(next);
+            if (nextIndent <= baseIndent) {
+                break;
+            }
+            String nt = next.trim();
+            for (String prop : List.of("message:", "name:", "simple:", 
"constant:", "language:")) {
+                if (nt.startsWith(prop)) {
+                    String val = nt.substring(prop.length()).trim();
+                    return unquote(val);
+                }
+            }
+        }
+        return "";
+    }
+
+    private static String extractYamlValue(String trimmed, String key) {
+        String prefix = key + ":";
+        if (!trimmed.startsWith(prefix)) {
+            return null;
+        }
+        return unquote(trimmed.substring(prefix.length()).trim());
+    }
+
+    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/test/java/org/apache/camel/dsl/jbang/core/commands/tui/GotoSourceNodePopupTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/GotoSourceNodePopupTest.java
new file mode 100644
index 000000000000..4eec2f5fe2b9
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/GotoSourceNodePopupTest.java
@@ -0,0 +1,138 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.tui;
+
+import java.util.List;
+
+import dev.tamboui.tui.event.KeyCode;
+import dev.tamboui.tui.event.KeyEvent;
+import dev.tamboui.tui.event.KeyModifiers;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class GotoSourceNodePopupTest {
+
+    @Test
+    void escClosesPopup() {
+        var popup = new GotoSourceNodePopup();
+        popup.open(sampleEntries());
+        assertThat(popup.isVisible()).isTrue();
+
+        popup.handleKeyEvent(KeyEvent.ofKey(KeyCode.ESCAPE, 
KeyModifiers.NONE));
+        assertThat(popup.isVisible()).isFalse();
+    }
+
+    @Test
+    void enterSelectsRouteEntry() {
+        var popup = new GotoSourceNodePopup();
+        popup.open(sampleEntries());
+
+        popup.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER, KeyModifiers.NONE));
+
+        YamlRouteNodeScanner.NodeEntry selected = popup.consumeSelection();
+        assertThat(selected).isNotNull();
+        
assertThat(selected.kind()).isEqualTo(YamlRouteNodeScanner.EntryKind.ROUTE);
+        assertThat(selected.routeId()).isEqualTo("myRoute");
+    }
+
+    @Test
+    void downThenEnterSelectsProcessor() {
+        var popup = new GotoSourceNodePopup();
+        popup.open(sampleEntries());
+
+        popup.handleKeyEvent(KeyEvent.ofKey(KeyCode.DOWN, KeyModifiers.NONE));
+        popup.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER, KeyModifiers.NONE));
+
+        YamlRouteNodeScanner.NodeEntry selected = popup.consumeSelection();
+        assertThat(selected).isNotNull();
+        
assertThat(selected.kind()).isEqualTo(YamlRouteNodeScanner.EntryKind.PROCESSOR);
+        assertThat(selected.type()).isEqualTo("log");
+    }
+
+    @Test
+    void typingFiltersToMatchingProcessor() {
+        var popup = new GotoSourceNodePopup();
+        popup.open(sampleEntries());
+
+        popup.handleKeyEvent(KeyEvent.ofChar('k', KeyModifiers.NONE));
+        popup.handleKeyEvent(KeyEvent.ofChar('a', KeyModifiers.NONE));
+        popup.handleKeyEvent(KeyEvent.ofChar('f', KeyModifiers.NONE));
+        popup.handleKeyEvent(KeyEvent.ofChar('k', KeyModifiers.NONE));
+        popup.handleKeyEvent(KeyEvent.ofChar('a', KeyModifiers.NONE));
+        popup.handleKeyEvent(KeyEvent.ofKey(KeyCode.DOWN, KeyModifiers.NONE));
+        popup.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER, KeyModifiers.NONE));
+
+        YamlRouteNodeScanner.NodeEntry selected = popup.consumeSelection();
+        assertThat(selected).isNotNull();
+        assertThat(selected.type()).isEqualTo("to");
+        assertThat(selected.label()).contains("kafka");
+    }
+
+    @Test
+    void duplicateRouteIdsFilteredIndependently() {
+        var entries = List.of(
+                new YamlRouteNodeScanner.NodeEntry(
+                        YamlRouteNodeScanner.EntryKind.ROUTE,
+                        "dup", "timer:a", "route", "timer:a",
+                        "/tmp/routes.yaml", 0, 0, 0),
+                new YamlRouteNodeScanner.NodeEntry(
+                        YamlRouteNodeScanner.EntryKind.PROCESSOR,
+                        "dup", null, "log", "alpha",
+                        "/tmp/routes.yaml", 3, 1, 0),
+                new YamlRouteNodeScanner.NodeEntry(
+                        YamlRouteNodeScanner.EntryKind.ROUTE,
+                        "dup", "timer:b", "route", "timer:b",
+                        "/tmp/routes.yaml", 5, 0, 5),
+                new YamlRouteNodeScanner.NodeEntry(
+                        YamlRouteNodeScanner.EntryKind.PROCESSOR,
+                        "dup", null, "log", "beta",
+                        "/tmp/routes.yaml", 8, 1, 5));
+
+        var popup = new GotoSourceNodePopup();
+        popup.open(entries);
+
+        popup.handleKeyEvent(KeyEvent.ofChar('a', KeyModifiers.NONE));
+        popup.handleKeyEvent(KeyEvent.ofChar('l', KeyModifiers.NONE));
+        popup.handleKeyEvent(KeyEvent.ofChar('p', KeyModifiers.NONE));
+        popup.handleKeyEvent(KeyEvent.ofChar('h', KeyModifiers.NONE));
+        popup.handleKeyEvent(KeyEvent.ofChar('a', KeyModifiers.NONE));
+        popup.handleKeyEvent(KeyEvent.ofKey(KeyCode.DOWN, KeyModifiers.NONE));
+        popup.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER, KeyModifiers.NONE));
+
+        YamlRouteNodeScanner.NodeEntry selected = popup.consumeSelection();
+        assertThat(selected).isNotNull();
+        assertThat(selected.label()).isEqualTo("alpha");
+        assertThat(selected.routeFromLine()).isEqualTo(0);
+    }
+
+    private static List<YamlRouteNodeScanner.NodeEntry> sampleEntries() {
+        return List.of(
+                new YamlRouteNodeScanner.NodeEntry(
+                        YamlRouteNodeScanner.EntryKind.ROUTE,
+                        "myRoute", "timer:tick", "route", "timer:tick",
+                        "/tmp/route.camel.yaml", 2, 0, 2),
+                new YamlRouteNodeScanner.NodeEntry(
+                        YamlRouteNodeScanner.EntryKind.PROCESSOR,
+                        "myRoute", null, "log", "hello",
+                        "/tmp/route.camel.yaml", 5, 1, 2),
+                new YamlRouteNodeScanner.NodeEntry(
+                        YamlRouteNodeScanner.EntryKind.PROCESSOR,
+                        "myRoute", null, "to", "kafka:orders",
+                        "/tmp/route.camel.yaml", 8, 1, 2));
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewerGoToLineTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewerGoToLineTest.java
new file mode 100644
index 000000000000..c574b03c76a3
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewerGoToLineTest.java
@@ -0,0 +1,71 @@
+/*
+ * 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.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class SourceViewerGoToLineTest {
+
+    @TempDir
+    Path tempDir;
+
+    @Test
+    void goToLinePositionsCursorInEditMode() throws IOException {
+        String yaml = String.join("\n",
+                "- from:",
+                "    uri: timer:tick",
+                "    steps:",
+                "      - log:",
+                "          message: hello",
+                "      - to:",
+                "          uri: kafka:orders",
+                "");
+
+        Path file = tempDir.resolve("route.camel.yaml");
+        Files.writeString(file, yaml);
+
+        SourceViewer viewer = new SourceViewer();
+        viewer.loadFile(file);
+        viewer.enterEditMode();
+
+        viewer.goToLine(4);
+
+        assertThat(viewer.getSelectedLine()).isEqualTo(4);
+        assertThat(viewer.editState().cursorRow()).isEqualTo(4);
+    }
+
+    @Test
+    void goToLineWorksInViewMode() throws IOException {
+        Path file = tempDir.resolve("route.camel.yaml");
+        Files.writeString(file, "line0\nline1\nline2\n");
+
+        SourceViewer viewer = new SourceViewer();
+        viewer.loadFile(file);
+
+        viewer.goToLine(2);
+
+        assertThat(viewer.getSelectedLine()).isEqualTo(2);
+        assertThat(viewer.isEditMode()).isFalse();
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/YamlRouteNodeScannerTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/YamlRouteNodeScannerTest.java
new file mode 100644
index 000000000000..0a1349aba336
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/YamlRouteNodeScannerTest.java
@@ -0,0 +1,221 @@
+/*
+ * 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.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class YamlRouteNodeScannerTest {
+
+    @TempDir
+    Path tempDir;
+
+    @Test
+    void scanSimpleRouteWithSteps() throws IOException {
+        String yaml = String.join("\n",
+                "- route:",
+                "    id: myRoute",
+                "    from:",
+                "      uri: timer:tick",
+                "      steps:",
+                "        - log:",
+                "            message: hello",
+                "        - to:",
+                "            uri: kafka:orders",
+                "");
+
+        Path file = tempDir.resolve("route.camel.yaml");
+        Files.writeString(file, yaml);
+
+        List<YamlRouteNodeScanner.NodeEntry> entries = 
YamlRouteNodeScanner.scanFile(file);
+
+        assertThat(entries).hasSize(3);
+        
assertThat(entries.get(0).kind()).isEqualTo(YamlRouteNodeScanner.EntryKind.ROUTE);
+        assertThat(entries.get(0).routeId()).isEqualTo("myRoute");
+        assertThat(entries.get(0).fromUri()).isEqualTo("timer:tick");
+        assertThat(entries.get(0).lineIndex()).isEqualTo(2);
+
+        
assertThat(entries.get(1).kind()).isEqualTo(YamlRouteNodeScanner.EntryKind.PROCESSOR);
+        assertThat(entries.get(1).type()).isEqualTo("log");
+        assertThat(entries.get(1).label()).isEqualTo("hello");
+        assertThat(entries.get(1).lineIndex()).isEqualTo(5);
+
+        assertThat(entries.get(2).type()).isEqualTo("to");
+        assertThat(entries.get(2).label()).isEqualTo("kafka:orders");
+        assertThat(entries.get(2).lineIndex()).isEqualTo(8);
+    }
+
+    @Test
+    void scanFlatFromRouteDerivesRouteIdFromUri() throws IOException {
+        String yaml = String.join("\n",
+                "- from:",
+                "    uri: kafka:my-topic",
+                "    steps:",
+                "      - log:",
+                "          message: ping",
+                "");
+
+        Path file = tempDir.resolve("route.camel.yaml");
+        Files.writeString(file, yaml);
+
+        List<YamlRouteNodeScanner.NodeEntry> entries = 
YamlRouteNodeScanner.scanFile(file);
+
+        assertThat(entries.get(0).routeId()).isEqualTo("my-topic");
+        assertThat(entries.get(0).fromUri()).isEqualTo("kafka:my-topic");
+    }
+
+    @Test
+    void scanInlineFromUri() throws IOException {
+        String yaml = String.join("\n",
+                "- from: timer:hello",
+                "  steps:",
+                "    - setBody:",
+                "        constant: test",
+                "");
+
+        Path file = tempDir.resolve("route.camel.yaml");
+        Files.writeString(file, yaml);
+
+        List<YamlRouteNodeScanner.NodeEntry> entries = 
YamlRouteNodeScanner.scanFile(file);
+
+        
assertThat(entries.get(0).kind()).isEqualTo(YamlRouteNodeScanner.EntryKind.ROUTE);
+        assertThat(entries.get(0).lineIndex()).isEqualTo(0);
+        assertThat(entries.get(1).type()).isEqualTo("setBody");
+        assertThat(entries.get(1).label()).isEqualTo("test");
+    }
+
+    @Test
+    void processorIdDoesNotOverwriteRouteId() throws IOException {
+        String yaml = String.join("\n",
+                "- route:",
+                "    id: myRoute",
+                "    from:",
+                "      uri: timer:tick",
+                "      steps:",
+                "        - log:",
+                "            id: myLog",
+                "            message: hello",
+                "        - to:",
+                "            uri: kafka:orders",
+                "");
+
+        Path file = tempDir.resolve("route.camel.yaml");
+        Files.writeString(file, yaml);
+
+        List<YamlRouteNodeScanner.NodeEntry> entries = 
YamlRouteNodeScanner.scanFile(file);
+
+        assertThat(entries.get(0).routeId()).isEqualTo("myRoute");
+        assertThat(entries.get(1).routeId()).isEqualTo("myRoute");
+        assertThat(entries.get(1).label()).isEqualTo("hello");
+        assertThat(entries.get(2).routeId()).isEqualTo("myRoute");
+        assertThat(entries.get(2).type()).isEqualTo("to");
+    }
+
+    @Test
+    void newFromRouteClearsPreviousRouteId() throws IOException {
+        String yaml = String.join("\n",
+                "- route:",
+                "    id: myRoute",
+                "    from:",
+                "      uri: timer:a",
+                "- from:",
+                "    uri: timer:b",
+                "");
+
+        Path file = tempDir.resolve("routes.camel.yaml");
+        Files.writeString(file, yaml);
+
+        List<YamlRouteNodeScanner.NodeEntry> entries = 
YamlRouteNodeScanner.scanFile(file);
+
+        assertThat(entries).hasSize(2);
+        assertThat(entries.get(0).routeId()).isEqualTo("myRoute");
+        assertThat(entries.get(1).routeId()).isEqualTo("b");
+    }
+
+    @Test
+    void beansSectionNotIndexedAsProcessors() throws IOException {
+        String yaml = String.join("\n",
+                "- from:",
+                "    uri: timer:a",
+                "    steps:",
+                "      - log:",
+                "          message: hi",
+                "- beans:",
+                "    - name: myBean",
+                "      type: java.lang.String",
+                "");
+
+        Path file = tempDir.resolve("routes.camel.yaml");
+        Files.writeString(file, yaml);
+
+        List<YamlRouteNodeScanner.NodeEntry> entries = 
YamlRouteNodeScanner.scanFile(file);
+
+        assertThat(entries).hasSize(2);
+        assertThat(entries.get(1).type()).isEqualTo("log");
+    }
+
+    @Test
+    void isNavigableNodeLineSkipsStructuralKeys() {
+        assertThat(YamlRouteNodeScanner.isNavigableNodeLine("    
steps:")).isFalse();
+        assertThat(YamlRouteNodeScanner.isNavigableNodeLine("      - 
expression:")).isFalse();
+        assertThat(YamlRouteNodeScanner.isNavigableNodeLine("        uri: 
kafka:foo")).isTrue();
+        assertThat(YamlRouteNodeScanner.isNavigableNodeLine("      - 
log:")).isTrue();
+        assertThat(YamlRouteNodeScanner.isNavigableNodeLine("      - 
to:")).isFalse();
+        assertThat(YamlRouteNodeScanner.isNavigableNodeLine("          uri: 
kafka:foo")).isTrue();
+    }
+
+    @Test
+    void scanMultipleRoutesInOneFile() throws IOException {
+        String yaml = String.join("\n",
+                "- from:",
+                "    uri: timer:a",
+                "    steps:",
+                "      - log:",
+                "          message: first",
+                "- from:",
+                "    uri: timer:b",
+                "    steps:",
+                "      - log:",
+                "          message: second",
+                "");
+
+        Path file = tempDir.resolve("routes.camel.yaml");
+        Files.writeString(file, yaml);
+
+        List<YamlRouteNodeScanner.NodeEntry> entries = 
YamlRouteNodeScanner.scanFile(file);
+
+        assertThat(entries.stream().filter(e -> e.kind() == 
YamlRouteNodeScanner.EntryKind.ROUTE))
+                .hasSize(2);
+        assertThat(entries.stream().filter(e -> e.kind() == 
YamlRouteNodeScanner.EntryKind.PROCESSOR))
+                .hasSize(2);
+    }
+
+    @Test
+    void scanEmptyFileReturnsEmptyList() throws IOException {
+        Path file = tempDir.resolve("empty.camel.yaml");
+        Files.writeString(file, "");
+
+        assertThat(YamlRouteNodeScanner.scanFile(file)).isEmpty();
+    }
+}

Reply via email to