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 858c9db34fad chore: camel-jbang - source editor fixes: go to node in
Java and XML, reload after an AI write, edit title with errors, Simple function
quick fix (#27197)
858c9db34fad is described below
commit 858c9db34fada4222860eee455d7cf36d5e41934
Author: Claus Ibsen <[email protected]>
AuthorDate: Thu Oct 1 13:50:34 2026 +0200
chore: camel-jbang - source editor fixes: go to node in Java and XML,
reload after an AI write, edit title with errors, Simple function quick fix
(#27197)
Source editor fixes: Ctrl+G lists the nodes of Java and XML routes and the
when/otherwise of a YAML choice, the view shows a file an AI tool wrote, the
edit title keeps the file name when there are errors, and a Simple function the
error names has a quick fix.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
---
.../modules/ROOT/pages/camel-jbang-tui.adoc | 2 +
.../dsl/jbang/core/commands/ai/QuickFixes.java | 14 +-
.../dsl/jbang/core/commands/ai/QuickFixesTest.java | 17 +++
.../dsl/jbang/core/commands/tui/CamelMonitor.java | 10 ++
.../dsl/jbang/core/commands/tui/McpFacade.java | 10 ++
.../core/commands/tui/ModelRouteNodeScanner.java | 153 +++++++++++++++++++++
.../dsl/jbang/core/commands/tui/SourceTab.java | 33 ++++-
.../dsl/jbang/core/commands/tui/SourceViewer.java | 24 +++-
.../core/commands/tui/YamlRouteNodeScanner.java | 78 ++++++++++-
.../src/main/resources/tui/help/source.md | 3 +-
.../commands/tui/ModelRouteNodeScannerTest.java | 105 ++++++++++++++
.../core/commands/tui/SourceViewerReloadTest.java | 82 +++++++++++
.../commands/tui/YamlRouteNodeScannerTest.java | 85 ++++++++++++
13 files changed, 604 insertions(+), 12 deletions(-)
diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
index cee7fa671c4e..8dca367565c1 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
@@ -272,6 +272,8 @@ line instead: the file is saved as it is, and the AI panel
opens with the questi
the problem and its text), for you to send with *Enter* or change first.
`camel_validate_source` gives the same fixes to
an agent, as `fixes` with the text to find and its replacement.
+A Simple function the error names the right one of, such as `+${bdy}+` for
`+${body}+`, has a quick fix too.
+
Validation can be toggled off in the Settings (F2 > Settings).
== Activity
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/QuickFixes.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/QuickFixes.java
index b8065931d337..2c8d330cde10 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/QuickFixes.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/QuickFixes.java
@@ -25,8 +25,9 @@ import java.util.regex.Pattern;
/**
* The fix of a problem the validation reports, when the problem says it: an
option that is a typo of another (Did you
* mean: [period]), an enum value of the wrong case, a to that needs toD, a
Simple expression where a property
- * placeholder is meant. The fix is a replacement of text on the line of the
problem, which an editor applies with one
- * key and an agent applies as an edit, whatever the DSL (YAML, Java, XML) the
line is written in.
+ * placeholder is meant, a Simple function the error names the right one of
(did you mean ${body}?). The fix is a
+ * replacement of text on the line of the problem, which an editor applies
with one key and an agent applies as an edit,
+ * whatever the DSL (YAML, Java, XML) the line is written in.
* <p/>
* A fix is only given when it is certain: one suggestion, a value that
matches but for its case or a letter or two.
*/
@@ -58,6 +59,9 @@ public final class QuickFixes {
private static final Pattern SIMPLE_AS_PLACEHOLDER = Pattern.compile(
"([\\w.-]+)=(\\$\\{[^}]*\\}) is a Simple expression, which an
endpoint option is not evaluated as.*\\{\\{([^}]+)\\}\\}");
private static final Pattern DYNAMIC_TO = Pattern.compile("holds an
expression \\(\\$\\{");
+ private static final Pattern UNKNOWN_FUNCTION = Pattern.compile("Unknown
function: (.+?) \\((?:the argument goes in"
+ + "
parentheses: )?(?:did you mean |function names are"
+ + " case
sensitive: )(\\$\\{.+\\})\\??\\)");
private QuickFixes() {
}
@@ -105,6 +109,12 @@ public final class QuickFixes {
String placeholder = "{{" + m.group(3) + "}}";
return new Fix(m.group(2) + " → " + placeholder, m.group(2),
placeholder);
}
+ m = UNKNOWN_FUNCTION.matcher(message);
+ if (m.find()) {
+ // ${bdy} (did you mean ${body}?): the function the hint names, in
place of the one written
+ String written = "${" + m.group(1) + "}";
+ return new Fix(written + " → " + m.group(2), written, m.group(2));
+ }
if (DYNAMIC_TO.matcher(message).find() && message.contains("toD")) {
// the step itself: .to( in Java, <to in XML, to: in YAML
for (String[] form : new String[][] { { ".to(", ".toD(" }, { "<to
", "<toD " }, { "to:", "toD:" } }) {
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/QuickFixesTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/QuickFixesTest.java
index 7c73c78e2213..a037a4458122 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/QuickFixesTest.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/QuickFixesTest.java
@@ -81,6 +81,23 @@ class QuickFixesTest {
""")).isEqualTo("<from uri=\"timer:tick?period=1000\"/>");
}
+ @Test
+ void aSimpleFunctionTheErrorNamesTheRightOneOf() {
+ assertThat(fixed("MyRoute.java", java(
+ " from(\"timer:tick\").filter(simple(\"${bdy} >
3\")).to(\"seda:out\");")))
+ .isEqualTo("from(\"timer:tick\").filter(simple(\"${body} >
3\")).to(\"seda:out\");");
+ assertThat(fixed("MyRoute.java", java(
+ "
from(\"timer:tick\").setBody(simple(\"${Body}\")).to(\"seda:out\");")))
+
.isEqualTo("from(\"timer:tick\").setBody(simple(\"${body}\")).to(\"seda:out\");");
+ }
+
+ @Test
+ void aSimpleFunctionWithoutASuggestionHasNoFix() {
+ String error = "Line 6: Simple syntax error: Unknown function: zzzzqqq
(the functions are documented on the"
+ + " simple language page (functions))";
+ assertThat(QuickFixes.fixFor(error, "
.setBody(simple(\"${zzzzqqq}\"))")).isNull();
+ }
+
@Test
void aTypoOfAYamlOption() {
assertThat(fixed("route.camel.yaml", """
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
index 8e2e95da2f10..61bd5a2aac2d 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
@@ -193,6 +193,8 @@ public class CamelMonitor extends CamelCommand {
private final EditReplay editReplay = new EditReplay();
// a live write waiting to start on the UI thread, and the promise its
tool thread waits on
private volatile McpFacade.FileWrite pendingReplay;
+ /** The files AI tools wrote, for the source viewer to show again on the
UI thread. */
+ private final Queue<Path> writtenFiles = new ConcurrentLinkedQueue<>();
private volatile CompletableFuture<McpFacade.ReplayOutcome>
pendingReplayOutcome;
// both are read by the tool thread to decide whether a write can be
replayed
private volatile McpFacade.FileWrite activeReplay;
@@ -820,6 +822,11 @@ public class CamelMonitor extends CamelCommand {
return parked != null && activeReplayOutcome == null ?
parked.file() : null;
}
+ @Override
+ public void fileWritten(Path file) {
+ writtenFiles.add(file);
+ }
+
@Override
public boolean confirmFileWrite(McpFacade.FileWrite
request) {
CompletableFuture<Boolean> answer = new
CompletableFuture<>();
@@ -1875,6 +1882,9 @@ public class CamelMonitor extends CamelCommand {
drawOverlay.tick(now);
captionOverlay.tick(now);
tickEditReplay(now);
+ for (Path written = writtenFiles.poll(); written != null; written =
writtenFiles.poll()) {
+ tabRegistry.sourceTab().reloadIfShowing(written);
+ }
recordingManager.tickRecentKeys(now);
boolean anyDiagramShowing = tabRegistry.routesTab().isShowDiagram()
|| tabRegistry.diagramTab().isShowDiagram();
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java
index abb818bc1628..071a4624249b 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java
@@ -116,6 +116,13 @@ class McpFacade {
default String parkedReplayFile() {
return null;
}
+
+ /**
+ * Tells that a tool wrote a file, so a view of the file shows what is
in it now. Called on the calling (tool)
+ * thread.
+ */
+ default void fileWritten(Path file) {
+ }
}
/** How file writes requested by tools are handled; set by the user with
/write in the AI panel. */
@@ -1219,6 +1226,9 @@ class McpFacade {
} catch (IOException e) {
return writeError("Failed to write " + filePath + ": " +
e.getMessage());
}
+ if (bridge != null) {
+ bridge.fileWritten(filePath);
+ }
JsonObject result = new JsonObject();
result.put("status", exists ? "overwritten" : "created");
result.put("file", file);
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ModelRouteNodeScanner.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ModelRouteNodeScanner.java
new file mode 100644
index 000000000000..9088cee8e209
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ModelRouteNodeScanner.java
@@ -0,0 +1,153 @@
+/*
+ * 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.Collections;
+import java.util.IdentityHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Supplier;
+
+import org.apache.camel.LineNumberAware;
+import org.apache.camel.NamedNode;
+import org.apache.camel.catalog.CamelCatalog;
+import org.apache.camel.dsl.jbang.core.commands.ai.RouteModel;
+import
org.apache.camel.dsl.jbang.core.commands.tui.YamlRouteNodeScanner.EntryKind;
+import
org.apache.camel.dsl.jbang.core.commands.tui.YamlRouteNodeScanner.NodeEntry;
+import org.apache.camel.model.ChoiceDefinition;
+import org.apache.camel.model.OtherwiseDefinition;
+import org.apache.camel.model.ProcessorDefinition;
+import org.apache.camel.model.RouteDefinition;
+import org.apache.camel.model.TryDefinition;
+import org.apache.camel.model.WhenDefinition;
+
+/**
+ * The nodes of the Java and XML DSL routes of a file for Go to Node (Ctrl+G)
of the Source tab, as the YAML scanner
+ * gives them for a YAML file. The file is read into the Camel model as the
route checks read it: a Java source by the
+ * Java DSL parser, without compiling or running it.
+ */
+final class ModelRouteNodeScanner {
+
+ private static final int MAX_DEPTH = 50;
+
+ private ModelRouteNodeScanner() {
+ }
+
+ /**
+ * The nodes of the routes of a Java or XML DSL file; empty when the file
is neither or cannot be read.
+ *
+ * @param javaSources the Java sources of the project by path, for the
constants of other classes
+ */
+ static List<NodeEntry> scan(
+ String filePath, String fileName, String content, Map<String,
Supplier<String>> javaSources,
+ CamelCatalog catalog) {
+ RouteModel model;
+ try {
+ model = RouteModel.read(fileName, content, catalog, javaSources);
+ } catch (RuntimeException e) {
+ return List.of();
+ }
+ if (model == null) {
+ return List.of();
+ }
+ List<NodeEntry> answer = new ArrayList<>();
+ for (RouteDefinition r : model.routes()) {
+ if (r.getInput() == null || r.getInput().getUri() == null) {
+ continue;
+ }
+ String fromUri =
DiagramSupport.stripQueryParams(JavaRouteScanner.uri(r.getInput().getUri()));
+ int fromLine = line(r.getInput(), line(r, 0));
+ String routeId =
YamlRouteNodeScanner.resolveRouteId(r.getRouteId(), fromUri);
+ answer.add(new NodeEntry(EntryKind.ROUTE, routeId, fromUri,
"route", fromUri, filePath, fromLine, 0, fromLine));
+ Set<Object> seen = Collections.newSetFromMap(new
IdentityHashMap<>());
+ for (ProcessorDefinition<?> p : r.getOutputs()) {
+ walk(p, routeId, filePath, fromLine, 1, answer, seen);
+ }
+ }
+ return answer;
+ }
+
+ private static void walk(
+ NamedNode p, String routeId, String filePath, int fromLine, int
depth, List<NodeEntry> answer,
+ Set<Object> seen) {
+ if (p == null || depth > MAX_DEPTH || !seen.add(p)) {
+ return;
+ }
+ answer.add(new NodeEntry(
+ EntryKind.PROCESSOR, routeId, null, p.getShortName(),
label(p), filePath, line(p, fromLine), depth,
+ fromLine));
+ for (NamedNode child : children(p)) {
+ walk(child, routeId, filePath, fromLine, depth + 1, answer, seen);
+ }
+ }
+
+ /** The nodes under a node: the when and otherwise of a choice are nodes
of their own, as the diagram shows them. */
+ private static List<NamedNode> children(NamedNode p) {
+ List<NamedNode> answer = new ArrayList<>();
+ if (p instanceof ChoiceDefinition choice) {
+ answer.addAll(choice.getWhenClauses());
+ if (choice.getOtherwise() != null) {
+ answer.add(choice.getOtherwise());
+ }
+ } else if (p instanceof WhenDefinition when) {
+ answer.addAll(when.getOutputs());
+ } else if (p instanceof OtherwiseDefinition otherwise) {
+ answer.addAll(otherwise.getOutputs());
+ } else if (p instanceof ProcessorDefinition<?> pd) {
+ answer.addAll(pd.getOutputs());
+ if (pd instanceof TryDefinition t) {
+ answer.addAll(t.getCatchClauses());
+ if (t.getFinallyClause() != null) {
+ answer.add(t.getFinallyClause());
+ }
+ }
+ }
+ return answer;
+ }
+
+ /** The label of a node without its EIP name: filter[simple{...}] gives
simple{...}. */
+ static String label(NamedNode p) {
+ if (p instanceof OtherwiseDefinition || p instanceof ChoiceDefinition)
{
+ // the when and otherwise of a choice are listed under it
+ return "";
+ }
+ String label;
+ try {
+ label = p.getLabel();
+ } catch (RuntimeException e) {
+ return "";
+ }
+ if (label == null) {
+ return "";
+ }
+ String name = p.getShortName();
+ if (label.startsWith(name + "[") && label.endsWith("]")) {
+ label = label.substring(name.length() + 1, label.length() - 1);
+ }
+ return label.replace('\n', ' ');
+ }
+
+ /** The line of a node from 0, or the fallback when the model does not
know it. */
+ private static int line(Object node, int fallback) {
+ if (node instanceof LineNumberAware n && n.getLineNumber() > 0) {
+ return n.getLineNumber() - 1;
+ }
+ return fallback;
+ }
+}
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 5bb40ab14646..244bf7f2e51a 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
@@ -1181,6 +1181,17 @@ class SourceTab extends AbstractTab {
return answer;
}
+ /** The Java sources among the files by path, for the constants a Java
route takes from another class. */
+ private static Map<String, Supplier<String>> javaSources(List<Path> files)
{
+ Map<String, Supplier<String>> answer = new LinkedHashMap<>();
+ for (Path f : files) {
+ if (f.getFileName().toString().endsWith(".java")) {
+ answer.put(f.toString(), () -> readQuietly(f));
+ }
+ }
+ return answer;
+ }
+
private static boolean isRouteSourceName(String name) {
String lower = name.toLowerCase();
return lower.endsWith(".java") || lower.endsWith(".xml") ||
lower.endsWith(".yaml") || lower.endsWith(".yml");
@@ -1227,17 +1238,33 @@ class SourceTab extends AbstractTab {
}
}
+ /** Shows a file again when the viewer shows it and it is not being
edited: a file an AI tool wrote. */
+ void reloadIfShowing(Path file) {
+ sourceViewer.reloadIfShowing(file);
+ }
+
private List<YamlRouteNodeScanner.NodeEntry> buildSourceNodeIndex() {
List<YamlRouteNodeScanner.NodeEntry> nodes = new ArrayList<>();
+ Map<String, Supplier<String>> javaSources = null;
for (FilesBrowser.FileEntry entry : entries) {
if (entry.directory()) {
continue;
}
Path path = Path.of(entry.path());
- if (!isCamelSourceFile(path) ||
!SourceEditAssist.isYamlFile(path)) {
- continue;
+ if (isJavaRouteFile(path)
+ || isCamelSourceFile(path) &&
path.getFileName().toString().toLowerCase().endsWith(".xml")) {
+ String content = readQuietly(path);
+ if (content != null) {
+ if (javaSources == null) {
+ javaSources = javaSources(routeSources());
+ }
+ nodes.addAll(ModelRouteNodeScanner.scan(
+ path.toString(), path.getFileName().toString(),
content, javaSources,
+ ArchitectureView.catalog()));
+ }
+ } else if (isCamelSourceFile(path) &&
SourceEditAssist.isYamlFile(path)) {
+ nodes.addAll(YamlRouteNodeScanner.scanFile(path));
}
- nodes.addAll(YamlRouteNodeScanner.scanFile(path));
}
return nodes;
}
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 a9b82841230d..2ebc6e3d5ae1 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
@@ -562,6 +562,20 @@ class SourceViewer {
return selectedLine;
}
+ /**
+ * Shows the file again, at the same line, when it is the file shown and
it is not being edited: a file an AI tool
+ * wrote while the viewer showed the old content. An edit in progress is
left alone, as it holds the user's work.
+ */
+ void reloadIfShowing(Path file) {
+ if (editMode || editableFile == null || file == null
+ ||
!editableFile.toAbsolutePath().normalize().equals(file.toAbsolutePath().normalize()))
{
+ return;
+ }
+ int line = selectedLine;
+ loadFile(editableFile);
+ goToLine(Math.min(line, Math.max(0, getLineCount() - 1)));
+ }
+
int getLineCount() {
if (editMode) {
return editState.lineCount();
@@ -2179,6 +2193,11 @@ class SourceViewer {
}
}
}
+ if (!diffOverlay && !visibleErrors.isEmpty()) {
+ // a block has one title at the top: the error count goes on the
line of the file name, not instead of it
+ Style errorStyle =
Style.EMPTY.fg(dev.tamboui.style.Color.rgb(0xFF, 0x66, 0x66));
+ titleSpans.add(Span.styled(" errors: " + visibleErrors.size() + "
", errorStyle));
+ }
Title posTitle;
if (diffOverlay) {
posTitle = Title.from(
@@ -2198,11 +2217,6 @@ class SourceViewer {
blockBuilder.borders(Borders.ALL)
.title(Title.from(Line.from(titleSpans)))
.titleBottom(posTitle);
- if (!visibleErrors.isEmpty()) {
- Style errorStyle =
Style.EMPTY.fg(dev.tamboui.style.Color.rgb(0xFF, 0x66, 0x66));
- blockBuilder.title(Title.from(Line.from(
- Span.styled(" errors: " + visibleErrors.size() + " ",
errorStyle))).right());
- }
}
if (borderStyle != null) {
blockBuilder.borderStyle(borderStyle);
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
index 01ec6ec7abd5..97acfac224fe 100644
---
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
@@ -20,7 +20,9 @@ import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.util.ArrayDeque;
import java.util.ArrayList;
+import java.util.Deque;
import java.util.List;
import java.util.Set;
@@ -78,6 +80,8 @@ class YamlRouteNodeScanner {
int activeRouteIndent = -1;
String pendingEndpointEip = null;
int pendingEndpointIndent = -1;
+ // the when: lists being read (a choice can be nested in a when), each
as its indent and that of its items
+ Deque<int[]> whens = new ArrayDeque<>();
for (int i = 0; i < lines.size(); i++) {
String line = lines.get(i);
@@ -165,6 +169,33 @@ class YamlRouteNodeScanner {
continue;
}
+ // the when and otherwise of a choice are nodes of their own, as
the diagram (and Java and XML) show them
+ while (!whens.isEmpty() && indent <= whens.peek()[0]) {
+ whens.pop();
+ }
+ if ("when:".equals(trimmed) || "otherwise:".equals(trimmed)) {
+ if (trimmed.startsWith("when")) {
+ whens.push(new int[] { indent, -1 });
+ } else {
+ String routeId = resolveRouteId(currentRouteId,
currentFromUri);
+ result.add(new NodeEntry(
+ EntryKind.PROCESSOR, routeId, null, "otherwise",
"", filePath, i,
+ Math.max(1, (indent - activeRouteIndent) / 2),
activeRouteFromLine));
+ }
+ continue;
+ }
+ int[] when = whens.peek();
+ if (when != null && trimmed.startsWith("- ") && (when[1] < 0 ||
indent == when[1])) {
+ // an item of the when list (- expression: or - simple: ...):
a when, at the indent of when: so it is a
+ // sibling of the otherwise
+ when[1] = indent;
+ String routeId = resolveRouteId(currentRouteId,
currentFromUri);
+ result.add(new NodeEntry(
+ EntryKind.PROCESSOR, routeId, null, "when",
expressionLabel(lines, i), filePath, i,
+ Math.max(1, (when[0] - activeRouteIndent) / 2),
activeRouteFromLine));
+ continue;
+ }
+
if (isBlockEndpointEipLine(trimmed)) {
String eip = extractNodeType(line);
if (eip != null && ENDPOINT_EIPS.contains(eip)) {
@@ -208,7 +239,7 @@ class YamlRouteNodeScanner {
EntryKind.ROUTE, resolvedId, fromUri, "route", fromUri,
filePath, fromLine, 0, fromLine));
}
- private static String resolveRouteId(String routeId, String fromUri) {
+ static String resolveRouteId(String routeId, String fromUri) {
if (routeId != null && !routeId.isEmpty()) {
return routeId;
}
@@ -332,6 +363,51 @@ class YamlRouteNodeScanner {
return "";
}
+ /**
+ * The expression of a when item as language{text}, as the model labels
it: from {@code - simple: "${body} > 6"}, or
+ * {@code - expression:} with the language and its expression on the lines
under it. Empty when not found.
+ */
+ static String expressionLabel(List<String> lines, int itemLine) {
+ int base = lineIndent(lines.get(itemLine));
+ String language = null;
+ for (int j = itemLine; j < lines.size(); j++) {
+ String next = lines.get(j);
+ if (next.isBlank() || next.trim().startsWith("#")) {
+ continue;
+ }
+ if (j > itemLine && lineIndent(next) <= base) {
+ break;
+ }
+ String content = next.trim();
+ if (content.startsWith("- ")) {
+ content = content.substring(2).trim();
+ }
+ int colon = content.indexOf(':');
+ if (colon <= 0) {
+ continue;
+ }
+ String key = content.substring(0, colon).trim();
+ String value = unquote(content.substring(colon + 1).trim());
+ if ("steps".equals(key)) {
+ break;
+ }
+ if (BOILERPLATE_KEYS.contains(key)) {
+ continue;
+ }
+ if ("expression".equals(key)) {
+ if (!value.isEmpty()) {
+ return language != null ? language + "{" + value + "}" :
value;
+ }
+ continue;
+ }
+ language = key;
+ if (!value.isEmpty()) {
+ return language + "{" + value + "}";
+ }
+ }
+ return "";
+ }
+
private static String extractYamlValue(String trimmed, String key) {
String prefix = key + ":";
if (!trimmed.startsWith(prefix)) {
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/source.md
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/source.md
index 18a054497b65..c563f8f24b66 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/source.md
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/source.md
@@ -59,7 +59,8 @@ a heat map of the route: where messages go, where they fail.
line, problem); press Enter to send it, or change it first
- **Shift+F9** — apply the fix of the problem on the cursor line, when the
problem
says it (an option typo, an enum value a letter off, `to` that should be
- `toD`, `${key}` where `{{key}}` is meant); the Error panel shows the fix
+ `toD`, `${key}` where `{{key}}` is meant, a Simple function the error names
+ the right one of, such as `${bdy}` → `${body}`); the Error panel shows the
fix
- Java and XML DSL routes are checked as you type, like YAML routes: endpoint
options, simple expressions, and a `to` with `${...}` that should be a `toD`.
The problems are marked on their lines; an XML file with problems is not
saved
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ModelRouteNodeScannerTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ModelRouteNodeScannerTest.java
new file mode 100644
index 000000000000..9b16528aabdf
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ModelRouteNodeScannerTest.java
@@ -0,0 +1,105 @@
+/*
+ * 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 java.util.Map;
+
+import org.apache.camel.catalog.DefaultCamelCatalog;
+import
org.apache.camel.dsl.jbang.core.commands.tui.YamlRouteNodeScanner.EntryKind;
+import
org.apache.camel.dsl.jbang.core.commands.tui.YamlRouteNodeScanner.NodeEntry;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Go to Node (Ctrl+G) lists the routes of Java and XML files too, read into
the Camel model.
+ */
+class ModelRouteNodeScannerTest {
+
+ /** Each node as depth, type[label] @ line (from 1). */
+ private static List<String> describe(List<NodeEntry> nodes) {
+ return nodes.stream()
+ .map(n -> n.indent() + " " + n.type() + "[" + n.label() + "]
@" + (n.lineIndex() + 1))
+ .toList();
+ }
+
+ @Test
+ void theNodesOfAJavaRoute() {
+ String java = """
+ import org.apache.camel.builder.RouteBuilder;
+
+ public class OrderRoute extends RouteBuilder {
+ @Override
+ public void configure() throws Exception {
+ from("timer:orders?period=500")
+ .routeId("orders")
+ .filter(simple("${body} > 3"))
+ .to("log:big")
+ .end()
+ .choice()
+ .when(simple("${body} == 9"))
+ .throwException(new
IllegalStateException("Out of stock"))
+ .otherwise()
+ .to("seda:shipping");
+ }
+ }
+ """;
+ List<NodeEntry> nodes = ModelRouteNodeScanner.scan(
+ "/p/OrderRoute.java", "OrderRoute.java", java, Map.of(), new
DefaultCamelCatalog());
+
+ assertThat(nodes.get(0).kind()).isEqualTo(EntryKind.ROUTE);
+ assertThat(nodes.get(0).routeId()).isEqualTo("orders");
+ assertThat(nodes.get(0).fromUri()).isEqualTo("timer:orders");
+ assertThat(nodes.get(0).lineIndex()).isEqualTo(5);
+ assertThat(nodes.get(0).filePath()).isEqualTo("/p/OrderRoute.java");
+ assertThat(describe(nodes.subList(1, nodes.size()))).containsExactly(
+ "1 filter[simple{${body} > 3}] @8",
+ "2 to[log:big] @9",
+ "1 choice[] @11",
+ "2 when[simple{${body} == 9}] @12",
+ "3 throwException[java.lang.IllegalStateException] @13",
+ "2 otherwise[] @14",
+ "3 to[seda:shipping] @15");
+ }
+
+ @Test
+ void theNodesOfAnXmlRoute() {
+ String xml = """
+ <routes xmlns="http://camel.apache.org/schema/xml-io">
+ <route id="shipping">
+ <from uri="seda:shipping"/>
+ <log message="Shipping ${body}"/>
+ <to uri="log:shipped"/>
+ </route>
+ </routes>
+ """;
+ List<NodeEntry> nodes = ModelRouteNodeScanner.scan(
+ "/p/routes.xml", "routes.xml", xml, Map.of(), new
DefaultCamelCatalog());
+
+ assertThat(nodes.get(0).routeId()).isEqualTo("shipping");
+ assertThat(describe(nodes.subList(1, nodes.size()))).containsExactly(
+ "1 log[Shipping ${body}] @4",
+ "1 to[log:shipped] @5");
+ }
+
+ @Test
+ void aFileThatIsNotARouteHasNoNodes() {
+ assertThat(ModelRouteNodeScanner.scan(
+ "/p/Util.java", "Util.java", "public class Util {}", Map.of(),
new DefaultCamelCatalog())).isEmpty();
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewerReloadTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewerReloadTest.java
new file mode 100644
index 000000000000..574dc4a158cf
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewerReloadTest.java
@@ -0,0 +1,82 @@
+/*
+ * 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;
+
+/**
+ * A file an AI tool wrote is shown again in the viewer, unless it is being
edited.
+ */
+class SourceViewerReloadTest {
+
+ @TempDir
+ Path tempDir;
+
+ @Test
+ void theViewerShowsWhatTheAiWrote() throws IOException {
+ Path file = tempDir.resolve("route.camel.yaml");
+ Files.writeString(file, "line0\nline1 ${bdy}\nline2\n");
+ SourceViewer viewer = new SourceViewer();
+ viewer.loadFile(file);
+ viewer.goToLine(1);
+
+ Files.writeString(file, "line0\nline1 ${body}\nline2\n");
+ viewer.reloadIfShowing(file);
+
+ viewer.enterEditMode();
+
assertThat(viewer.editState().text()).contains("${body}").doesNotContain("${bdy}");
+ assertThat(viewer.getSelectedLine()).isEqualTo(1);
+ }
+
+ @Test
+ void anEditInProgressIsLeftAlone() throws IOException {
+ Path file = tempDir.resolve("route.camel.yaml");
+ Files.writeString(file, "mine\n");
+ SourceViewer viewer = new SourceViewer();
+ viewer.loadFile(file);
+ viewer.enterEditMode();
+
+ Files.writeString(file, "theirs\n");
+ viewer.reloadIfShowing(file);
+
+ assertThat(viewer.isEditMode()).isTrue();
+ assertThat(viewer.editState().text()).contains("mine");
+ }
+
+ @Test
+ void anotherFileIsNotReloaded() throws IOException {
+ Path file = tempDir.resolve("a.camel.yaml");
+ Path other = tempDir.resolve("b.camel.yaml");
+ Files.writeString(file, "a\n");
+ Files.writeString(other, "b\n");
+ SourceViewer viewer = new SourceViewer();
+ viewer.loadFile(file);
+
+ Files.writeString(file, "changed\n");
+ viewer.reloadIfShowing(other);
+
+ viewer.enterEditMode();
+
assertThat(viewer.editState().text()).contains("a").doesNotContain("changed");
+ }
+}
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
index 0a1349aba336..e43c4a1f8364 100644
---
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
@@ -211,6 +211,91 @@ class YamlRouteNodeScannerTest {
.hasSize(2);
}
+ /** Each processor as indent type[label] @line (from 1). */
+ private static List<String> describe(List<YamlRouteNodeScanner.NodeEntry>
entries) {
+ return entries.stream()
+ .filter(e -> e.kind() ==
YamlRouteNodeScanner.EntryKind.PROCESSOR)
+ .map(e -> e.indent() + " " + e.type() + "[" + e.label() + "]
@" + (e.lineIndex() + 1))
+ .toList();
+ }
+
+ @Test
+ void theWhenAndOtherwiseOfAChoiceAreNodes() {
+ String yaml = String.join("\n",
+ "- route:",
+ " id: shipping",
+ " from:",
+ " uri: seda:shipping",
+ " steps:",
+ " - choice:",
+ " when:",
+ " - expression:",
+ " simple:",
+ " expression: \"${body} > 6\"",
+ " steps:",
+ " - setHeader:",
+ " name: carrier",
+ " - simple: \"${body} > 3\"",
+ " steps:",
+ " - log:",
+ " message: medium",
+ " otherwise:",
+ " steps:",
+ " - setHeader:",
+ " name: carrier",
+ " - log:",
+ " message: shipped",
+ "");
+
+ List<YamlRouteNodeScanner.NodeEntry> entries =
YamlRouteNodeScanner.scanLines(List.of(yaml.split("\n")), "f");
+
+ // when and otherwise are siblings under the choice, with their steps
under them
+ assertThat(describe(entries)).containsExactly(
+ "2 choice[] @6",
+ "4 when[simple{${body} > 6}] @8",
+ "7 setHeader[carrier] @12",
+ "4 when[simple{${body} > 3}] @14",
+ "7 log[medium] @16",
+ "4 otherwise[] @18",
+ "6 setHeader[carrier] @20",
+ "2 log[shipped] @22");
+ }
+
+ @Test
+ void aChoiceInAWhenKeepsTheOuterWhens() {
+ String yaml = String.join("\n",
+ "- route:",
+ " from:",
+ " uri: direct:start",
+ " steps:",
+ " - choice:",
+ " when:",
+ " - simple: \"${body} > 6\"",
+ " steps:",
+ " - choice:",
+ " when:",
+ " - simple: \"${body} > 8\"",
+ " steps:",
+ " - to:",
+ " uri: direct:big",
+ " - simple: \"${body} > 3\"",
+ " steps:",
+ " - to:",
+ " uri: direct:medium",
+ "");
+
+ List<YamlRouteNodeScanner.NodeEntry> entries =
YamlRouteNodeScanner.scanLines(List.of(yaml.split("\n")), "f");
+
+ assertThat(describe(entries)).containsExactly(
+ "2 choice[] @5",
+ "4 when[simple{${body} > 6}] @7",
+ "7 choice[] @9",
+ "9 when[simple{${body} > 8}] @11",
+ "12 to[direct:big] @14",
+ "4 when[simple{${body} > 3}] @15",
+ "7 to[direct:medium] @18");
+ }
+
@Test
void scanEmptyFileReturnsEmptyList() throws IOException {
Path file = tempDir.resolve("empty.camel.yaml");