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 255772f0de44 chore: camel-jbang - source editor: a note on a line that 
sends to a direct: or seda: endpoint no route consumes (#28342)
255772f0de44 is described below

commit 255772f0de44e5db85f6f7bd04c8228db393de1c
Author: Claus Ibsen <[email protected]>
AuthorDate: Fri Oct 23 12:39:07 2026 +0200

    chore: camel-jbang - source editor: a note on a line that sends to a 
direct: or seda: endpoint no route consumes (#28342)
    
    A YAML route with to: direct:cheese and no from: direct:cheese anywhere in 
the
    project gets a note on that line (yellow warning, the save is not blocked), 
the
    check camel validate has had since CAMEL-24955. The endpoints the other 
route
    files consume (YAML, Java and XML) are read on a background thread and kept 
for
    10 seconds (ConsumedEndpoints); the file is marked again when a read finds
    something else. The setKameletNotes hook of the viewer is now setYamlNotes.
    
    Signed-off-by: Claus Ibsen <[email protected]>
    Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
---
 .../ROOT/pages/camel-jbang-tui-source-editor.adoc  |   7 +
 .../core/commands/ai/EndpointConsumerChecks.java   |  43 ++++-
 .../ai/SourceValidatorEndpointConsumersTest.java   |  46 +++++
 .../jbang/core/commands/tui/ConsumedEndpoints.java | 134 ++++++++++++++
 .../dsl/jbang/core/commands/tui/SourceTab.java     |  11 +-
 .../dsl/jbang/core/commands/tui/SourceViewer.java  |  20 +-
 .../core/commands/tui/SourceConsumerNotesTest.java | 202 +++++++++++++++++++++
 .../commands/tui/SourceKameletValidationTest.java  |   2 +-
 8 files changed, 452 insertions(+), 13 deletions(-)

diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-source-editor.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-source-editor.adoc
index 55a7117b77c2..0de33d4d0efc 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-source-editor.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-source-editor.adoc
@@ -90,6 +90,13 @@ panel at the bottom says what is wrong with the line the 
cursor is on. A note, s
 counted apart in the title as `notes`. A note does not keep the file from 
being saved. They are checked again as you type, and *F9*
 jumps to the next problem.
 
+A YAML route that sends to a `direct` or `seda` endpoint no route consumes 
gets a note on that line, such as
+`to: direct:cheese` while no route has `from: direct:cheese`. The routes of 
the other YAML, Java and XML files of the
+project count too. They are read in the background when the file opens, and 
read again every few seconds. It is a
+note rather than an error, as the route that consumes it is often the next one 
you write. The check says nothing when
+the other files cannot tell, such as a route whose input is a property 
placeholder or a route template, or a project
+with more files than the check reads.
+
 image::jbang/camel-tui-source-problems.png[A misspelled seda option marked 
while typing]
 
 === Saving
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/EndpointConsumerChecks.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/EndpointConsumerChecks.java
index b5688d7b4325..1d39f6fb9ae2 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/EndpointConsumerChecks.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/EndpointConsumerChecks.java
@@ -30,6 +30,7 @@ import java.util.List;
 import java.util.Locale;
 import java.util.Map;
 import java.util.Set;
+import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
 import org.apache.camel.catalog.CamelCatalog;
@@ -48,6 +49,8 @@ public final class EndpointConsumerChecks {
     private static final Pattern JAVA_FROM = 
Pattern.compile("(?<![.\\w])from\\s*\\(");
     /** The build files of a project whose routes are spread over 
src/main/java and src/main/resources. */
     private static final List<String> BUILD_FILES = List.of("pom.xml", 
"build.gradle", "build.gradle.kts");
+    /** The endpoint of a message of {@link EndpointConsumers#check(String, 
Set)}. */
+    private static final Pattern UNCONSUMED = Pattern.compile("sends to 
([^,\\s]+), and no route consumes it");
 
     private EndpointConsumerChecks() {
     }
@@ -70,13 +73,51 @@ public final class EndpointConsumerChecks {
         return EndpointConsumers.check(content, consumed(directory, 
excludeFile, catalog));
     }
 
+    /**
+     * As {@link #validateYamlConsumers(String, Path, String, CamelCatalog)}, 
with the endpoints the other route files
+     * consume already known, as an editor keeps them while the file is typed: 
each message is on the lines that send to
+     * the endpoint, as "Line N: message", and without a line prefix when none 
of the lines names it as written.
+     *
+     * @param  consumedElsewhere what {@link #consumed(Path, String, 
CamelCatalog)} returned; null says nothing
+     * @return                   the messages, one per line that sends to an 
endpoint no route consumes
+     */
+    public static List<String> validateYamlConsumers(String content, 
Set<String> consumedElsewhere) {
+        List<String> answer = new ArrayList<>();
+        String[] lines = content.split("\n", -1);
+        for (String msg : EndpointConsumers.check(content, consumedElsewhere)) 
{
+            Matcher m = UNCONSUMED.matcher(msg);
+            List<Integer> found = m.find() ? linesNaming(lines, m.group(1)) : 
List.of();
+            if (found.isEmpty()) {
+                answer.add(msg);
+            }
+            for (int line : found) {
+                answer.add("Line " + (line + 1) + ": " + msg);
+            }
+        }
+        return answer;
+    }
+
+    /** The lines, from 0, that name the endpoint (direct:cheese, also as 
direct://cheese), comments left out. */
+    private static List<Integer> linesNaming(String[] lines, String endpoint) {
+        int colon = endpoint.indexOf(':');
+        Pattern p = Pattern.compile("(?<![\\w.:/-])" + 
Pattern.quote(endpoint.substring(0, colon + 1)) + "(?://)?"
+                                    + Pattern.quote(endpoint.substring(colon + 
1)) + "(?![\\w.-])");
+        List<Integer> answer = new ArrayList<>();
+        for (int i = 0; i < lines.length; i++) {
+            if (!lines[i].trim().startsWith("#") && 
p.matcher(lines[i]).find()) {
+                answer.add(i);
+            }
+        }
+        return answer;
+    }
+
     /**
      * The {@code direct:} and {@code seda:} endpoints the route files under 
the directory consume, leaving out the file
      * being validated; null when they cannot be known: no directory, a 
directory that is only a part of a Maven or
      * Gradle project, more files than the scan looks at, or a route input the 
scan cannot read (a Java {@code from(}
      * with no literal, a placeholder, a route template), which could be any 
endpoint.
      */
-    static Set<String> consumed(Path directory, String excludeFile, 
CamelCatalog catalog) {
+    public static Set<String> consumed(Path directory, String excludeFile, 
CamelCatalog catalog) {
         if (directory == null || !Files.isDirectory(directory)) {
             return null;
         }
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorEndpointConsumersTest.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorEndpointConsumersTest.java
index 287cfb9dbb9e..640e42bb9790 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorEndpointConsumersTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/SourceValidatorEndpointConsumersTest.java
@@ -19,6 +19,7 @@ package org.apache.camel.dsl.jbang.core.commands.ai;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.List;
+import java.util.Set;
 
 import org.apache.camel.catalog.CamelCatalog;
 import org.apache.camel.catalog.DefaultCamelCatalog;
@@ -268,4 +269,49 @@ class SourceValidatorEndpointConsumersTest {
     void withoutADirectoryTheCheckSaysNothing() {
         assertThat(SourceValidator.validate("r.camel.yaml", ROUTE, CATALOG, 
null)).isEmpty();
     }
+
+    @Test
+    void theEditorGetsTheMessageOnEachLineThatSendsToTheEndpoint() throws 
Exception {
+        Files.writeString(dir.resolve("other.camel.yaml"), """
+                - route:
+                    from:
+                      uri: direct:other
+                      steps:
+                        - log: other
+                """);
+        Set<String> consumed = EndpointConsumerChecks.consumed(dir, 
"r.camel.yaml", CATALOG);
+        assertThat(consumed).containsOnly("direct:other");
+        String route = """
+                - route:
+                    id: tick
+                    from:
+                      uri: timer:tick
+                      steps:
+                        # to: direct:cheese, not yet
+                        - to: direct:cheese
+                        - to: direct:cheeseburger
+                        - to: direct://cheese?timeout=1000
+                        - to: direct:other
+                """;
+        List<String> msgs = 
EndpointConsumerChecks.validateYamlConsumers(route, consumed);
+        assertThat(msgs).hasSize(3);
+        assertThat(msgs.get(0)).startsWith("Line 7: route tick: sends to 
direct:cheese, and no route consumes it");
+        assertThat(msgs.get(1)).startsWith("Line 9: route tick: sends to 
direct:cheese, and no route consumes it");
+        assertThat(msgs.get(2)).startsWith("Line 8: route tick: sends to 
direct:cheeseburger");
+        // consumed in the file itself
+        assertThat(EndpointConsumerChecks.validateYamlConsumers(route + """
+                - route:
+                    from:
+                      uri: direct:cheese
+                      steps:
+                        - log: cheese
+                - route:
+                    from:
+                      uri: direct:cheeseburger
+                      steps:
+                        - log: burger
+                """, consumed)).isEmpty();
+        // the other files not known: nothing is said
+        assertThat(EndpointConsumerChecks.validateYamlConsumers(route, 
null)).isEmpty();
+    }
 }
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ConsumedEndpoints.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ConsumedEndpoints.java
new file mode 100644
index 000000000000..7cbb40507c22
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ConsumedEndpoints.java
@@ -0,0 +1,134 @@
+/*
+ * 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.nio.file.Path;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.Executor;
+import java.util.function.Consumer;
+
+import org.apache.camel.catalog.CamelCatalog;
+import org.apache.camel.dsl.jbang.core.commands.ai.EndpointConsumerChecks;
+
+/**
+ * The direct: and seda: endpoints the other route files of the project 
consume, for the editor to mark a line that
+ * sends to one no route consumes (CAMEL-24955). Reading them means reading 
every route file of the project, so they are
+ * read on a background thread and kept: the editor asks while typing, gets 
what was read last (null until the first
+ * read is done), and is told when a read finds something else. They are read 
again when older than a few seconds, as
+ * the other files may be edited meanwhile, in the TUI or outside it.
+ */
+final class ConsumedEndpoints {
+
+    static final long TTL_MS = 10_000;
+
+    private final Executor executor;
+    private final Consumer<Runnable> renderThread;
+
+    // the file asked about last: the endpoints are those of the route files 
under directory, excludeFile left out
+    private Path directory;
+    private String excludeFile;
+    private CamelCatalog catalog;
+    private Runnable onChange;
+    private Set<String> consumed;
+    private long readTime;
+    private boolean reading;
+
+    /**
+     * @param executor     where the files are read
+     * @param renderThread runs the callback of a read that found something 
else on the UI thread
+     */
+    ConsumedEndpoints(Executor executor, Consumer<Runnable> renderThread) {
+        this.executor = executor;
+        this.renderThread = renderThread;
+    }
+
+    /**
+     * The notes of a YAML route file: each line that sends to a direct: or 
seda: endpoint no route of the project
+     * consumes, as "Line N: message"; none while the other route files are 
being read the first time.
+     *
+     * @param rootDir  the project directory, whose route files are read; the 
file's own directory when it is not in it
+     * @param onChange called on the UI thread when a read finds other 
endpoints, to mark the file again
+     */
+    List<String> notes(Path file, Path rootDir, String content, CamelCatalog 
catalog, Runnable onChange) {
+        Path f = file.toAbsolutePath().normalize();
+        Path root = rootDir != null ? rootDir.toAbsolutePath().normalize() : 
null;
+        if (root == null || !f.startsWith(root)) {
+            root = f.getParent();
+        }
+        return EndpointConsumerChecks.validateYamlConsumers(content,
+                get(root, root.relativize(f).toString(), catalog, onChange));
+    }
+
+    /**
+     * The endpoints the route files under the directory consume, the excluded 
file left out: what was read last for
+     * them, starting a read when there is none yet or it is old.
+     *
+     * @param  onChange called on the UI thread when a read finds other 
endpoints than were returned
+     * @return          null while not known: not read yet, or the files 
cannot say (see
+     *                  {@link EndpointConsumerChecks#consumed})
+     */
+    synchronized Set<String> get(Path directory, String excludeFile, 
CamelCatalog catalog, Runnable onChange) {
+        this.catalog = catalog;
+        this.onChange = onChange;
+        if (!Objects.equals(directory, this.directory) || 
!Objects.equals(excludeFile, this.excludeFile)) {
+            this.directory = directory;
+            this.excludeFile = excludeFile;
+            consumed = null;
+            readTime = 0;
+        }
+        if (!reading && (readTime == 0 || MonotonicClock.millis() - readTime > 
TTL_MS)) {
+            startRead();
+        }
+        return consumed;
+    }
+
+    /** Reads the endpoints of the file asked about last; the caller holds the 
lock. */
+    private void startRead() {
+        reading = true;
+        Path dir = directory;
+        String exclude = excludeFile;
+        CamelCatalog cat = catalog;
+        executor.execute(() -> read(dir, exclude, cat));
+    }
+
+    private void read(Path dir, String exclude, CamelCatalog cat) {
+        Set<String> found = null;
+        try {
+            found = EndpointConsumerChecks.consumed(dir, exclude, cat);
+        } catch (RuntimeException e) {
+            // what the files consume is not known: the check says nothing
+        }
+        Runnable callback;
+        synchronized (this) {
+            if (!Objects.equals(dir, directory) || !Objects.equals(exclude, 
excludeFile)) {
+                // another file was opened meanwhile: read for that one instead
+                startRead();
+                return;
+            }
+            reading = false;
+            boolean changed = readTime == 0 ? found != null : 
!Objects.equals(found, consumed);
+            consumed = found;
+            readTime = MonotonicClock.millis();
+            callback = changed ? onChange : null;
+        }
+        if (callback != null) {
+            renderThread.accept(callback);
+        }
+    }
+}
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 8eb377e8f88f..0d4fa880c135 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
@@ -85,6 +85,11 @@ class SourceTab extends AbstractTab {
     private Rect rightArea;
 
     private final SourceEditAssist assist = new SourceEditAssist(ctx);
+    private final ConsumedEndpoints consumedEndpoints = new 
ConsumedEndpoints(ctx.backgroundExecutor, r -> {
+        if (ctx.runner != null) {
+            ctx.runner.runOnRenderThread(r);
+        }
+    });
 
     SourceEditAssist editAssist() {
         return assist;
@@ -958,7 +963,11 @@ class SourceTab extends AbstractTab {
                         name.toLowerCase(Locale.ROOT), content, 
assist.validateYamlSimple(content)));
                 // the shape of a Kamelet file and the kamelet: endpoints of a 
route (CAMEL-25411)
                 sourceViewer.setKameletValidator(content -> 
SourceEditAssist.validateKamelets(filePath, content));
-                sourceViewer.setKameletNotes(content -> 
SourceEditAssist.kameletNotes(filePath, content));
+                // the notes: a Kamelet's unused dependency, a direct: or 
seda: endpoint no route consumes (CAMEL-24955)
+                sourceViewer.setYamlNotes(content -> 
SourceEditAssist.isKameletFile(filePath)
+                        ? SourceEditAssist.kameletNotes(filePath, content)
+                        : consumedEndpoints.notes(filePath, rootDir, content, 
assist.validationCatalog(),
+                                () -> sourceViewer.reloadIfShowing(filePath)));
                 completion.setListItemNodeChecker(assist::isListChildrenNode);
                 
sourceViewer.setEditQuickDocProvider(withProjectDocs(assist::provideEditQuickDoc));
             } else {
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 284e56c1100c..42dd66f70836 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
@@ -258,9 +258,9 @@ class SourceViewer {
     private PropertiesValidator propertiesValidator;
     private EndpointValidator endpointValidator;
     private EndpointValidator simpleValidator;
-    // the Kamelet checks of a YAML file (CAMEL-25411): its problems, and the 
notes that do not block a save
+    // the Kamelet checks of a YAML file (CAMEL-25411), and the notes of a 
YAML file that do not block a save
     private EndpointValidator kameletValidator;
-    private EndpointValidator kameletNotes;
+    private EndpointValidator yamlNotes;
     // the lines whose problem is a note, not an error: marked as a warning 
and counted apart (CAMEL-25411)
     private Set<Integer> noteLines = Set.of();
     private EndpointValidator routeValidator;
@@ -349,11 +349,11 @@ class SourceViewer {
     }
 
     /**
-     * What a Kamelet file does that works but is not right (a camel: 
dependency its template does not use): marked and
-     * said when saved, but not blocking the save.
+     * What a YAML file does that works but is not right (a Kamelet's camel: 
dependency its template does not use, a
+     * direct: endpoint no route consumes): marked and said when saved, but 
not blocking the save.
      */
-    void setKameletNotes(EndpointValidator kameletNotes) {
-        this.kameletNotes = kameletNotes;
+    void setYamlNotes(EndpointValidator yamlNotes) {
+        this.yamlNotes = yamlNotes;
     }
 
     /**
@@ -388,7 +388,7 @@ class SourceViewer {
         endpointValidator = null;
         simpleValidator = null;
         kameletValidator = null;
-        kameletNotes = null;
+        yamlNotes = null;
         noteLines = Set.of();
         routeValidator = null;
         cursorQuickDocProvider = null;
@@ -429,7 +429,7 @@ class SourceViewer {
         endpointValidator = null;
         simpleValidator = null;
         kameletValidator = null;
-        kameletNotes = null;
+        yamlNotes = null;
         noteLines = Set.of();
         routeValidator = null;
         cursorQuickDocProvider = null;
@@ -1274,7 +1274,7 @@ class SourceViewer {
                 return;
             }
             List<String> notes = new ArrayList<>();
-            addAll(notes, kameletNotes, content);
+            addAll(notes, yamlNotes, content);
             if (!notes.isEmpty()) {
                 // marked, and said when saved, but not blocking the save
                 routeProblems = notes;
@@ -1517,7 +1517,7 @@ class SourceViewer {
             }
             addAll(msgs, kameletValidator, content);
             List<String> notes = new ArrayList<>();
-            addAll(notes, kameletNotes, content);
+            addAll(notes, yamlNotes, content);
             if (!notes.isEmpty()) {
                 // a line with an error is marked as an error
                 Set<Integer> lines = new HashSet<>(buildInlineErrors(notes, 
content).keySet());
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceConsumerNotesTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceConsumerNotesTest.java
new file mode 100644
index 000000000000..ff9a16cda9cd
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceConsumerNotesTest.java
@@ -0,0 +1,202 @@
+/*
+ * 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.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayDeque;
+import java.util.Deque;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+import dev.tamboui.tui.event.KeyCode;
+import dev.tamboui.tui.event.KeyEvent;
+import dev.tamboui.tui.event.KeyModifiers;
+import org.apache.camel.catalog.CamelCatalog;
+import org.apache.camel.catalog.DefaultCamelCatalog;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * A line of a YAML route that sends to a direct: or seda: endpoint no route 
of the project consumes is marked as a note
+ * in the Source tab (CAMEL-24955): a warning that does not block the save, as 
the consuming route may not be written
+ * yet. The other route files are read on a background thread, run here by 
hand.
+ */
+class SourceConsumerNotesTest {
+
+    private static final CamelCatalog CATALOG = new DefaultCamelCatalog();
+
+    private static final String ROUTE = """
+            - route:
+                id: tick
+                from:
+                  uri: timer:tick
+                  steps:
+                    - to: direct:cheese
+                    - to: direct:other
+            """;
+
+    private static final String OTHER = """
+            - route:
+                from:
+                  uri: direct:other
+                  steps:
+                    - log: other
+            """;
+
+    @TempDir
+    Path tempDir;
+
+    private final Deque<Runnable> background = new ArrayDeque<>();
+    private final AtomicReference<String> lastNotification = new 
AtomicReference<>();
+
+    @BeforeEach
+    void setUp() {
+        Theme.resetForTesting();
+    }
+
+    @Test
+    void aDirectEndpointNoRouteConsumesIsANoteOnItsLine() throws Exception {
+        Files.writeString(tempDir.resolve("other.camel.yaml"), OTHER, 
StandardCharsets.UTF_8);
+        Path file = tempDir.resolve("r.camel.yaml");
+        Files.writeString(file, ROUTE, StandardCharsets.UTF_8);
+        SourceViewer viewer = viewer(file);
+        viewer.loadFile(file);
+        // the other route files are being read: nothing is said yet
+        assertThat(viewer.viewErrors()).isEmpty();
+        runBackground();
+        int cheese = lineOf(ROUTE, "direct:cheese");
+        assertThat(viewer.viewErrors()).containsOnlyKeys(cheese);
+        assertThat(viewer.viewErrors().get(cheese))
+                .startsWith("route tick: sends to direct:cheese, and no route 
consumes it");
+        assertThat(viewer.noteLines()).containsOnly(cheese);
+
+        // a note: the file is saved
+        viewer.enterEditMode();
+        assertThat(viewer.inlineErrors()).containsOnlyKeys(cheese);
+        appendSpaceToLine(viewer, 1);
+        viewer.handleKeyEvent(KeyEvent.ofChar('s', KeyModifiers.CTRL));
+        assertThat(Files.readString(file, 
StandardCharsets.UTF_8)).contains("id: tick ");
+        assertThat(lastNotification.get()).startsWith("Saved: r.camel.yaml 
with 1 Camel problem: ")
+                .contains("sends to direct:cheese");
+    }
+
+    @Test
+    void theNoteGoesWhenARouteConsumesTheEndpoint() throws Exception {
+        Files.writeString(tempDir.resolve("other.camel.yaml"), OTHER, 
StandardCharsets.UTF_8);
+        Path file = tempDir.resolve("r.camel.yaml");
+        String withCheese = ROUTE + """
+                - route:
+                    from:
+                      uri: direct:cheese
+                      steps:
+                        - log: cheese
+                """;
+        Files.writeString(file, withCheese, StandardCharsets.UTF_8);
+        SourceViewer viewer = viewer(file);
+        viewer.loadFile(file);
+        runBackground();
+        assertThat(viewer.viewErrors()).isEmpty();
+        assertThat(viewer.noteLines()).isEmpty();
+    }
+
+    @Test
+    void theOtherFilesAreReadOnceAndAgainForAnotherFile() throws Exception {
+        Files.writeString(tempDir.resolve("other.camel.yaml"), OTHER, 
StandardCharsets.UTF_8);
+        ConsumedEndpoints consumed = new ConsumedEndpoints(background::add, 
Runnable::run);
+        AtomicInteger changes = new AtomicInteger();
+        Path file = tempDir.resolve("r.camel.yaml");
+        assertThat(consumed.notes(file, tempDir, ROUTE, CATALOG, 
changes::incrementAndGet)).isEmpty();
+        assertThat(background).hasSize(1);
+        // asked again while reading: no second read
+        consumed.notes(file, tempDir, ROUTE, CATALOG, 
changes::incrementAndGet);
+        assertThat(background).hasSize(1);
+        runBackground();
+        assertThat(changes).hasValue(1);
+        assertThat(consumed.notes(file, tempDir, ROUTE, CATALOG, 
changes::incrementAndGet))
+                .singleElement().asString().startsWith("Line 6: route tick: 
sends to direct:cheese");
+        // read a moment ago: kept
+        assertThat(background).isEmpty();
+
+        // the other file opened before the read of the first one is done: 
that read is followed by one for it
+        Path other = tempDir.resolve("other.camel.yaml");
+        consumed.notes(file, tempDir, ROUTE, CATALOG, 
changes::incrementAndGet);
+        Files.writeString(tempDir.resolve("cheese.camel.yaml"), 
OTHER.replace("direct:other", "direct:cheese"),
+                StandardCharsets.UTF_8);
+        consumed.get(tempDir, "nowhere.camel.yaml", CATALOG, 
changes::incrementAndGet);
+        assertThat(consumed.notes(other, tempDir, OTHER, CATALOG, 
changes::incrementAndGet)).isEmpty();
+        runBackground();
+        assertThat(consumed.get(tempDir, "other.camel.yaml", CATALOG, 
changes::incrementAndGet))
+                .containsOnly("direct:cheese");
+    }
+
+    @Test
+    void aFileOutsideTheProjectIsCheckedAgainstItsOwnDirectory() throws 
Exception {
+        Path project = Files.createDirectory(tempDir.resolve("project"));
+        Path elsewhere = Files.createDirectory(tempDir.resolve("elsewhere"));
+        Files.writeString(project.resolve("cheese.camel.yaml"), 
OTHER.replace("direct:other", "direct:cheese"),
+                StandardCharsets.UTF_8);
+        Files.writeString(elsewhere.resolve("other.camel.yaml"), OTHER, 
StandardCharsets.UTF_8);
+        ConsumedEndpoints consumed = new ConsumedEndpoints(background::add, 
Runnable::run);
+        Path file = elsewhere.resolve("r.camel.yaml");
+        consumed.notes(file, project, ROUTE, CATALOG, () -> {
+        });
+        runBackground();
+        assertThat(consumed.notes(file, project, ROUTE, CATALOG, () -> {
+        })).singleElement().asString().contains("sends to direct:cheese");
+    }
+
+    private SourceViewer viewer(Path file) {
+        SourceViewer viewer = new SourceViewer();
+        ConsumedEndpoints consumed = new ConsumedEndpoints(background::add, 
Runnable::run);
+        viewer.setNotificationCallback((msg, error) -> 
lastNotification.set(msg));
+        viewer.setYamlNotes(content -> consumed.notes(file, tempDir, content, 
CATALOG,
+                () -> viewer.reloadIfShowing(file)));
+        return viewer;
+    }
+
+    private void runBackground() {
+        while (!background.isEmpty()) {
+            background.poll().run();
+        }
+    }
+
+    /** The line, from 0, that has the text. */
+    private static int lineOf(String content, String text) {
+        List<String> lines = List.of(content.split("\n"));
+        for (int i = 0; i < lines.size(); i++) {
+            if (lines.get(i).contains(text)) {
+                return i;
+            }
+        }
+        return -1;
+    }
+
+    /** Moves to the line (from 0) and types a space at its end, a change that 
keeps the YAML as it was. */
+    private static void appendSpaceToLine(SourceViewer viewer, int line) {
+        for (int i = 0; i < line; i++) {
+            viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.DOWN, 
KeyModifiers.NONE));
+        }
+        viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.END, KeyModifiers.NONE));
+        viewer.handleKeyEvent(KeyEvent.ofChar(' ', KeyModifiers.NONE));
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceKameletValidationTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceKameletValidationTest.java
index 99fa7e49dc5e..d176687e28ed 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceKameletValidationTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceKameletValidationTest.java
@@ -265,7 +265,7 @@ class SourceKameletValidationTest {
         SourceViewer viewer = new SourceViewer();
         viewer.setNotificationCallback((msg, error) -> 
lastNotification.set(msg));
         viewer.setKameletValidator(content -> 
SourceEditAssist.validateKamelets(file, content));
-        viewer.setKameletNotes(content -> SourceEditAssist.kameletNotes(file, 
content));
+        viewer.setYamlNotes(content -> SourceEditAssist.kameletNotes(file, 
content));
         return viewer;
     }
 

Reply via email to