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