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