You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@camel.apache.org by da...@apache.org on 2026/10/07 11:05:25 UTC
(camel) branch main updated: CAMEL-25411: camel-jbang - Source editor: Kamelet property completion, highlighting, Go to Route names and notes as warnings (#27487)
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 131ec37bcbf8 CAMEL-25411: camel-jbang - Source editor: Kamelet property completion, highlighting, Go to Route names and notes as warnings (#27487)
131ec37bcbf8 is described below
commit 131ec37bcbf8b810f9b1785e33fd7b04ba11e7b5
Author: Claus Ibsen <cl...@gmail.com>
AuthorDate: Wed Oct 7 13:05:18 2026 +0200
CAMEL-25411: camel-jbang - Source editor: Kamelet property completion, highlighting, Go to Route names and notes as warnings (#27487)
* CAMEL-25411: camel-jbang - Source editor: complete the properties of a Kamelet under its kamelet: endpoint
Tab under parameters: (or in the query) of a kamelet: endpoint listed only
the options of the kamelet component itself (routeId, timeout...). It now
lists the properties of the Kamelet first, from the project's own Kamelet
file or the catalog, required first, with their type, default and
description, then a dim divider and the options of the kamelet component,
which are for special uses. There is no divider when only one kind is
listed, as for a Kamelet without properties.
Co-Authored-By: Claude Opus 5.5 (1M context) <no...@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
* CAMEL-25411: camel-jbang - Source editor: name the options popup of a kamelet: endpoint after its Kamelet
Co-Authored-By: Claude Opus 5.5 (1M context) <no...@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
* CAMEL-25411: camel-jbang - Source editor: Kamelet files are highlighted, named in Go to Route, and their notes shown as warnings
- A name.kamelet.yaml file was shown as plain text: the language is now
picked by the last extension.
- The template of a Kamelet was listed in Go to Route as kamelet:source,
the same for every Kamelet: it is now named after metadata.name and
reached as kamelet:<name>, so a route's to: kamelet:<name> links to it.
- A note (an unused dependency, camel:core) was marked and counted as an
error: it is now a warning mark, counted apart as notes, and its panel
says Note.
Co-Authored-By: Claude Opus 5.5 (1M context) <no...@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
* CAMEL-25411: camel-jbang - An unused Kamelet dependency says to remove its line, and Shift+F9 removes it
Told only what a Kamelet lists, a local model replaced the unused
camel:timer with kamelet:source. The note now says to remove the line, and
the fix is certain: Shift+F9 in the source editor removes it, and
camel_validate_source returns it as a find/replace. When it was the last
dependency, the dependencies: key goes with it.
Co-Authored-By: Claude Opus 5.5 (1M context) <no...@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
* CAMEL-25411: camel-jbang - Review: drop the unreachable multi-dot extensions, match kamelet:source exactly
Co-Authored-By: Claude Opus 5.5 (1M context) <no...@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
* CAMEL-25411: camel-jbang - Source editor: a route sent to from several places links to a popup of its callers
The from: line of a route linked back to the first route that sends to it
only. A Kamelet, or a direct: route, used by more than one route now links
as "2 callers", and Enter opens a popup of the callers to choose one. With
one caller the link jumps to it as before.
Also the review: the scrollbar of the completion popup counts the divider.
Co-Authored-By: Claude Opus 5.5 (1M context) <no...@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
* CAMEL-25411: camel-jbang - Source editor: the usages popup does not list the line it is opened on
Co-Authored-By: Claude Opus 5.5 (1M context) <no...@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
* CAMEL-25411: docs - source editor: Kamelets, notes as warnings, the callers of a route
Co-Authored-By: Claude Opus 5.5 (1M context) <no...@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
* CAMEL-25411: docs - source editor: a screenshot of the completion of a Kamelet's properties
Co-Authored-By: Claude Opus 5.5 (1M context) <no...@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
---------
Co-authored-by: Claude Opus 5.5 (1M context) <no...@anthropic.com>
---
.../jbang/camel-tui-source-kamelet-completion.png | Bin 0 -> 310326 bytes
.../ROOT/pages/camel-jbang-tui-source-editor.adoc | 35 +++++-
.../dsl/jbang/core/commands/ai/AuthoringTools.java | 17 ++-
.../dsl/jbang/core/commands/ai/KameletChecks.java | 7 +-
.../dsl/jbang/core/commands/ai/QuickFixes.java | 53 ++++++++-
.../jbang/core/commands/ai/KameletChecksTest.java | 31 ++++++
.../jbang/core/commands/tui/AutocompletePopup.java | 63 +++++++++--
.../jbang/core/commands/tui/SourceEditAssist.java | 47 ++++++++
.../dsl/jbang/core/commands/tui/SourceTab.java | 116 ++++++++++++++++---
.../dsl/jbang/core/commands/tui/SourceViewer.java | 123 ++++++++++++++++++---
.../jbang/core/commands/tui/SyntaxHighlighter.java | 9 +-
.../commands/tui/SourceKameletValidationTest.java | 100 +++++++++++++++++
.../core/commands/tui/SourceTabJumpLinksTest.java | 60 +++++++++-
.../core/commands/tui/SyntaxHighlighterTest.java | 8 ++
14 files changed, 608 insertions(+), 61 deletions(-)
diff --git a/docs/user-manual/modules/ROOT/images/jbang/camel-tui-source-kamelet-completion.png b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-source-kamelet-completion.png
new file mode 100644
index 000000000000..157041b7c32d
Binary files /dev/null and b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-source-kamelet-completion.png differ
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 c4787850c838..aced09a79f96 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
@@ -22,6 +22,8 @@ routes.
with *Ctrl+G*, see where the cursor is in the route.
* <<_changes_and_diff,*Changes and diff*>> -- changed lines are marked, and *F7* shows the unsaved changes as a diff.
* <<_yaml_java_and_xml,*YAML, Java and XML*>> -- *Space* shows a route in any of the three DSLs.
+* <<_kamelets,*Kamelets*>> -- write your own Kamelets with the same checks, and *Tab* completes the properties of a
+ Kamelet where a route uses it.
== Browsing and Editing
@@ -64,7 +66,9 @@ the title. The panel at the bottom says the problem of the selected line, and *F
image::jbang/camel-tui-source-problems-on-open.png[The problem of a YAML route marked when the file opens]
In the editor, a line with a problem has a red line number, the title shows how many errors there are, and the Error
-panel at the bottom says what is wrong with the line the cursor is on. They are checked again as you type, and *F9*
+panel at the bottom says what is wrong with the line the cursor is on. A note, something that works but is not right
+(such as a dependency a Kamelet does not use), is marked as a warning instead: a yellow `⚠`, an amber line number, and
+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.
image::jbang/camel-tui-source-problems.png[A misspelled seda option marked while typing]
@@ -85,7 +89,7 @@ The checks can be turned off in the Settings (*F2* > Settings).
Many problems say how to fix them: an option typo (`siz` instead of `size`), an enum value a letter off, a `to` that
should be a `toD`, `+${key}+` where the property placeholder `+{{key}}+` is meant, or a Simple function the error names
-the right one of (`+${bdy}+` for `+${body}+`). For these, the Error panel shows the fix, and *Shift+F9* applies it to the
+the right one of (`+${bdy}+` for `+${body}+`), or a dependency a Kamelet does not use, whose line is removed. For these, the Error panel shows the fix, and *Shift+F9* applies it to the
line:
image::jbang/camel-tui-source-quick-fix.png[Shift+F9 fixed the option typo]
@@ -235,11 +239,11 @@ image::jbang/camel-tui-source-xml-quick-doc.png[The documentation of the logging
* *Jump links* -- a line that sends to another route, such as `to("seda:shipping")`, shows `↵ shipping`. Press *Enter*
on the line to go to that route, also when it is in another file or another DSL. A `from` shows which route calls
- it.
+ it; when more than one does, it shows `↵ 2 callers`, and *Enter* opens a list of them to choose from.
* *Go to route* -- *g* lists all the routes of the project; type to filter, and *Enter* opens the route.
* *Bean jumps* -- a line that refers to a bean shows `↵ name`, and *Enter* goes to where the project declares it.
* *Usages* -- *u* on a line with an endpoint lists where it is used: the routes that consume from it and the steps that
- send to it, across the project and its DSLs. *Enter* goes there.
+ send to it, across the project and its DSLs, without the line you are on. *Enter* goes there.
* *Go to node* -- *Ctrl+G* shows the routes of the YAML, Java and XML files and their processors as a tree (the `when`
and `otherwise` of a choice included). Type to filter by route ID, EIP or label, or type a line number, and *Enter*
jumps there.
@@ -281,6 +285,29 @@ something it cannot say (a lambda processor, a predicate built in Java code) is
image::jbang/camel-tui-source-convert.png[An XML route converted to YAML, with the note of what did not carry over]
+== Kamelets
+
+A Kamelet file (`name.kamelet.yaml`) in the project opens and edits like a route file, with syntax highlighting and the
+same checks, and more that are particular to Kamelets:
+
+* *The shape of the file* -- the properties go under `spec.definition` (with the required ones listed in
+ `spec.definition.required`), and the template of an action or a sink starts `from: kamelet:source`, never from the
+ Kamelet itself.
+* *Where a route uses it* -- a `kamelet:` endpoint is checked against the properties of its Kamelet: a misspelled one
+ (`tagg` for `tag`) and a required one left out are marked. The project's own Kamelet files are read, so this works for
+ the Kamelets you write as well as for the ones of the catalog.
+* *Dependencies* -- a `camel:` dependency the template does not use, and `camel:core`, which every Camel runtime has, are
+ marked as notes. *Shift+F9* removes the line (and the `dependencies:` key when it was the last one).
+
+*Tab* under the `parameters:` of a `kamelet:` endpoint lists the properties of the Kamelet first, the required ones on
+top, with their type, default and description. Below a divider come the options of the kamelet component itself
+(`routeId`, `timeout`...), which are for special uses only.
+
+image::jbang/camel-tui-source-kamelet-completion.png[Tab under the parameters of a kamelet: endpoint lists the properties of the Kamelet first]
+
+The template of a Kamelet is a route of the project: *g* lists it under the name of the Kamelet, a route's
+`to: kamelet:name` has a jump link to it, and the `from` of the template links back to the routes that use it.
+
== Keyboard Shortcuts
=== Viewing
diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java
index dd6fb1a460c0..6a51293e425e 100644
--- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java
+++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java
@@ -535,13 +535,26 @@ public final class AuthoringTools {
// the problems whose fix is certain, as edits an agent can apply (camel_edit_file find/replace)
JsonArray fixes = new JsonArray();
String[] lines = content.split("\n", -1);
- for (String error : errors) {
+ List<String> problems = new ArrayList<>(errors);
+ if (result.get("notes") instanceof JsonArray notes) {
+ for (Object note : notes) {
+ problems.add(String.valueOf(note));
+ }
+ }
+ for (String error : problems) {
int line = lineOf(error);
QuickFixes.Fix fix = line > 0 && line <= lines.length ? QuickFixes.fixFor(error, lines[line - 1]) : null;
if (fix != null) {
JsonObject jo = new JsonObject();
jo.put("line", line);
- jo.put("find", fix.oldText());
+ String find = fix.oldText();
+ if (fix.removesLine()) {
+ // the line goes with its line break, and the key above it when it was its only item
+ int[] range = QuickFixes.linesToRemove(List.of(lines), line - 1);
+ find = String.join("\n", List.of(lines).subList(range[0], range[1] + 1))
+ + (range[1] + 1 < lines.length ? "\n" : "");
+ }
+ jo.put("find", find);
jo.put("replace", fix.newText());
jo.put("fix", fix.label());
fixes.add(jo);
diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/KameletChecks.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/KameletChecks.java
index e0928e5de459..7ad2b6622839 100644
--- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/KameletChecks.java
+++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/KameletChecks.java
@@ -421,7 +421,7 @@ public final class KameletChecks {
if ("core".equals(name)) {
// every Camel runtime has camel-core; camel:kamelet is not implied, it runs the template
notes.add(EndpointChecks.linePrefix(line(d)) + "spec.dependencies: camel:core is implied, every Camel"
- + " runtime has it: leave it out");
+ + " runtime has it: remove this line");
continue;
}
if (!inspectable) {
@@ -433,9 +433,10 @@ public final class KameletChecks {
|| usedArtifacts.contains(model.getArtifactId()) || delegatedTo(used, name)) {
continue;
}
+ // what to do first: told only what to list, a model put another dependency on the line
notes.add(EndpointChecks.linePrefix(line(d)) + "spec.dependencies: " + dep + " is not used by the template"
- + " (it has no " + name + ": endpoint): list only the components, languages and data formats the"
- + " template uses");
+ + " (it has no " + name + ": endpoint): remove this line; a Kamelet lists only the components,"
+ + " languages and data formats its template uses");
}
return notes;
}
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 2c8d330cde10..9b3e0ab476f0 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
@@ -40,12 +40,22 @@ public final class QuickFixes {
* @param oldText the text on the line to replace (its first occurrence)
* @param newText what it is replaced with
*/
- public record Fix(String label, String oldText, String newText) {
+ public record Fix(String label, String oldText, String newText, boolean removesLine) {
- /** The line with the fix applied, or null when the line does not have the text to replace. */
+ public Fix(String label, String oldText, String newText) {
+ this(label, oldText, newText, false);
+ }
+
+ /**
+ * The line with the fix applied, or null when the line does not have the text to replace. A fix that removes
+ * the line gives an empty line: the caller removes it.
+ */
public String apply(String line) {
int i = line != null ? line.indexOf(oldText) : -1;
- return i < 0 ? null : line.substring(0, i) + newText + line.substring(i + oldText.length());
+ if (i < 0) {
+ return null;
+ }
+ return removesLine ? "" : line.substring(0, i) + newText + line.substring(i + oldText.length());
}
}
@@ -58,6 +68,9 @@ public final class QuickFixes {
private static final Pattern INVALID_BOOLEAN = Pattern.compile("Invalid boolean value '([^']*)' for option '([^']+)'");
private static final Pattern SIMPLE_AS_PLACEHOLDER = Pattern.compile(
"([\\w.-]+)=(\\$\\{[^}]*\\}) is a Simple expression, which an endpoint option is not evaluated as.*\\{\\{([^}]+)\\}\\}");
+ // the notes of a Kamelet file (CAMEL-25403): the dependency goes, with its line
+ private static final Pattern UNUSED_DEPENDENCY
+ = Pattern.compile("spec\\.dependencies: (camel:[\\w.-]+) (?:is not used by the template|is implied)");
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"
@@ -66,6 +79,34 @@ public final class QuickFixes {
private QuickFixes() {
}
+ /**
+ * The lines, from 0, a fix that removes the line of the row removes: the row, and the key above it when the row is
+ * its only item, so that the last dependency of a Kamelet does not leave an empty dependencies: behind.
+ *
+ * @return the first and the last line to remove
+ */
+ public static int[] linesToRemove(List<String> lines, int row) {
+ int first = row;
+ if (row > 0 && lines.get(row).trim().startsWith("- ") && lines.get(row - 1).trim().endsWith(":")
+ && !lines.get(row - 1).trim().startsWith("- ")) {
+ int itemIndent = indent(lines.get(row));
+ boolean more = row + 1 < lines.size() && !lines.get(row + 1).isBlank()
+ && indent(lines.get(row + 1)) >= itemIndent && lines.get(row + 1).trim().startsWith("- ");
+ if (!more) {
+ first = row - 1;
+ }
+ }
+ return new int[] { first, row };
+ }
+
+ private static int indent(String line) {
+ int i = 0;
+ while (i < line.length() && line.charAt(i) == ' ') {
+ i++;
+ }
+ return i;
+ }
+
/**
* The fix of a problem.
*
@@ -82,7 +123,11 @@ public final class QuickFixes {
}
private static Fix find(String message, String line) {
- Matcher m = UNKNOWN_OPTION.matcher(message);
+ Matcher m = UNUSED_DEPENDENCY.matcher(message);
+ if (m.find()) {
+ return line.contains(m.group(1)) ? new Fix("remove " + m.group(1), line, "", true) : null;
+ }
+ m = UNKNOWN_OPTION.matcher(message);
if (m.find()) {
return rename(line, m.group(1), m.group(2).trim());
}
diff --git a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/KameletChecksTest.java b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/KameletChecksTest.java
index c3b3f142073a..821a8627909b 100644
--- a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/KameletChecksTest.java
+++ b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/KameletChecksTest.java
@@ -320,6 +320,37 @@ class KameletChecksTest {
.contains("camel:core is implied");
}
+ @Test
+ void anUnusedDependencyIsFixedByRemovingItsLine() {
+ // told only what to list, a local model replaced camel:timer with kamelet:source: the note says to remove it,
+ // and the fix removes the line
+ String content = withDependencies(TAG_KAMELET.formatted("${body} [{{tag}}]"), "camel:kamelet", "camel:timer");
+ String note = KameletChecks.unusedDependencies(content).get(0);
+ assertThat(note).contains("camel:timer is not used by the template").contains("remove this line");
+ QuickFixes.Fix fix = QuickFixes.fixFor(note, " - \"camel:timer\"");
+ assertThat(fix).isNotNull();
+ assertThat(fix.removesLine()).isTrue();
+ assertThat(fix.label()).isEqualTo("remove camel:timer");
+ assertThat(QuickFixes.fixFor("spec.dependencies: camel:core is implied, every Camel runtime has it: remove this line",
+ " - camel:core").removesLine()).isTrue();
+ // the fix as camel_validate_source gives it: the line and its line break go
+ JsonObject result = AuthoringTools.validate(new ToolContext(), "tag-order-action.kamelet.yaml", content);
+ assertThat(result.toJson()).contains("\"find\":\" - \\\"camel:timer\\\"\\n\"").contains("\"replace\":\"\"");
+ }
+
+ @Test
+ void theLastDependencyGoesWithTheDependenciesKey() {
+ List<String> lines = List.of("spec:", " dependencies:", " - \"camel:timer\"", " template:");
+ assertThat(QuickFixes.linesToRemove(lines, 2)).containsExactly(1, 2);
+ List<String> two = List.of("spec:", " dependencies:", " - \"camel:timer\"", " - \"camel:kamelet\"");
+ assertThat(QuickFixes.linesToRemove(two, 2)).containsExactly(2, 2);
+ assertThat(QuickFixes.linesToRemove(two, 3)).containsExactly(3, 3);
+ // the model's Kamelet, whose only dependency was camel:timer
+ String content = withDependencies(TAG_KAMELET.formatted("${body} [{{tag}}]"), "camel:timer");
+ JsonObject result = AuthoringTools.validate(new ToolContext(), "tag-order-action.kamelet.yaml", content);
+ assertThat(result.toJson()).contains("\"find\":\" dependencies:\\n - \\\"camel:timer\\\"\\n\"");
+ }
+
@Test
void theDependenciesTheTemplateUsesAreNotNoted() {
String source = """
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AutocompletePopup.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AutocompletePopup.java
index 245bcb6fcde6..4552ee285820 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AutocompletePopup.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AutocompletePopup.java
@@ -98,6 +98,12 @@ class AutocompletePopup {
private Rect popupRect;
private String titlePrefix;
private boolean fullKeys;
+ // the row of the filtered list a divider is drawn above, -1 for none: the properties of a Kamelet above it, the
+ // options of the kamelet component itself, for special uses only, below it (CAMEL-25411)
+ private int dividerAt = -1;
+
+ static final String KAMELET_GROUP = "kamelet ";
+ static final String KAMELET_COMPONENT_OPTIONS = "kamelet component options";
AutocompletePopup(List<CompletionItem> items, String initialPrefix, String lineKeyText) {
this(items, initialPrefix, lineKeyText, false);
@@ -224,11 +230,16 @@ class AutocompletePopup {
if (me.isClick()) {
if (popupRect != null && popupRect.contains(me.x(), me.y())) {
int idx = TuiHelper.listItemAt(popupRect, 0,
- (filteredItems != null ? filteredItems.size() : 0) + 2,
+ (filteredItems != null ? listRows() : 0) + 2,
me.x(), me.y());
- if (idx >= 2 && filteredItems != null && idx - 2 < filteredItems.size()) {
- listState.select(idx - 2);
- selectedItem = filteredItems.get(idx - 2);
+ int row = idx - 2;
+ if (dividerAt >= 0 && row >= dividerAt) {
+ // the divider is not an item
+ row = row == dividerAt ? -1 : row - 1;
+ }
+ if (row >= 0 && filteredItems != null && row < filteredItems.size()) {
+ listState.select(row);
+ selectedItem = filteredItems.get(row);
return Result.CLOSED;
}
return Result.CONSUMED;
@@ -244,7 +255,7 @@ class AutocompletePopup {
}
int popupW = Math.max(70, area.width() - 4);
- int contentH = filteredItems.size() + 2;
+ int contentH = listRows() + 2;
int maxH = area.height() - 2;
int popupH = Math.min(contentH + 2, maxH);
popupH = Math.max(popupH, 12);
@@ -314,7 +325,13 @@ class AutocompletePopup {
Style dimStyle = Style.EMPTY.dim();
Style deprecatedStyle = Style.EMPTY.dim().crossedOut();
- for (CompletionItem ci : filteredItems) {
+ for (int row = 0; row < filteredItems.size(); row++) {
+ CompletionItem ci = filteredItems.get(row);
+ if (row == dividerAt) {
+ String text = "── " + KAMELET_COMPONENT_OPTIONS + " ";
+ items.add(ListItem.from(Line.from(Span.styled(
+ text + "─".repeat(Math.max(1, listRect.width() - 2 - text.length())), dimStyle))));
+ }
List<Span> spans = new ArrayList<>();
if (ci.deprecated()) {
@@ -362,7 +379,7 @@ class AutocompletePopup {
ListState renderState = new ListState();
Integer sel = listState.selected();
if (sel != null) {
- renderState.select(sel + 2);
+ renderState.select(sel + 2 + (dividerAt >= 0 && sel >= dividerAt ? 1 : 0));
}
int total = allItems.size();
@@ -385,11 +402,11 @@ class AutocompletePopup {
frame.renderStatefulWidget(list, listRect, renderState);
int visibleRows = Math.max(1, listRect.height() - 2);
- if (shown + 2 > visibleRows) {
+ if (listRows() + 2 > visibleRows) {
scrollbarState
- .contentLength(shown)
+ .contentLength(listRows())
.viewportContentLength(visibleRows)
- .position(sel != null ? sel : 0);
+ .position(sel != null ? sel + (dividerAt >= 0 && sel >= dividerAt ? 1 : 0) : 0);
frame.renderStatefulWidget(Scrollbar.builder().build(), listRect, scrollbarState);
}
}
@@ -565,9 +582,35 @@ class AutocompletePopup {
// what was typed exactly first, then what starts with it: seda before hazelcast-seda
filteredItems.sort(Comparator.comparingInt(item -> rank(item.key(), f)));
}
+ dividerAt = -1;
+ boolean kamelet = filteredItems.stream().anyMatch(AutocompletePopup::isKameletProperty);
+ if (kamelet && !filteredItems.stream().allMatch(AutocompletePopup::isKameletProperty)) {
+ // the properties of the Kamelet stay above the divider while filtering (the sort is stable)
+ filteredItems.sort(Comparator.comparing(item -> !isKameletProperty(item)));
+ for (int i = 0; i < filteredItems.size(); i++) {
+ if (!isKameletProperty(filteredItems.get(i))) {
+ dividerAt = i;
+ break;
+ }
+ }
+ }
listState.select(filteredItems.isEmpty() ? null : 0);
}
+ private static boolean isKameletProperty(CompletionItem item) {
+ return item.group() != null && item.group().startsWith(KAMELET_GROUP);
+ }
+
+ /** Package-private for tests: the row of the filtered list the divider is drawn above, -1 for none. */
+ int dividerAt() {
+ return dividerAt;
+ }
+
+ /** The rows of the list: the items and the divider. */
+ private int listRows() {
+ return filteredItems.size() + (dividerAt >= 0 ? 1 : 0);
+ }
+
private static int rank(String key, String filter) {
if (key.equalsIgnoreCase(filter)) {
return 0;
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditAssist.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditAssist.java
index 3a9c306f10d3..2c4faf30563b 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditAssist.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditAssist.java
@@ -40,6 +40,7 @@ import java.util.regex.Pattern;
import org.apache.camel.catalog.CamelCatalog;
import org.apache.camel.catalog.DefaultCamelCatalog;
import org.apache.camel.dsl.jbang.core.commands.ai.KameletChecks;
+import org.apache.camel.dsl.jbang.core.commands.ai.KameletDefinitions;
import org.apache.camel.dsl.jbang.core.commands.ai.RouteAssist;
import org.apache.camel.dsl.jbang.core.commands.ai.RouteNodes;
import org.apache.camel.dsl.jbang.core.commands.ai.SourceValidator;
@@ -928,6 +929,14 @@ final class SourceEditAssist {
}
List<AutocompletePopup.CompletionItem> provideYamlKeyCompletions(String context) {
+ return provideYamlKeyCompletions(context, null);
+ }
+
+ /**
+ * As {@link #provideYamlKeyCompletions(String)}, with the directory of the file: the options of a kamelet: endpoint
+ * start with the properties of its Kamelet, from the project's own Kamelet file or the catalog (CAMEL-25411).
+ */
+ List<AutocompletePopup.CompletionItem> provideYamlKeyCompletions(String context, Path directory) {
if (context == null) {
return List.of();
}
@@ -994,6 +1003,9 @@ final class SourceEditAssist {
}
List<AutocompletePopup.CompletionItem> items = new ArrayList<>();
+ if ("kamelet".equals(componentName)) {
+ items.addAll(kameletPropertyCompletions(uri, directory, existingKeys));
+ }
for (ComponentModel.EndpointOptionModel opt : model.getEndpointOptions()) {
if (!includeEndpointOption(opt, isConsumer)) {
continue;
@@ -1008,11 +1020,46 @@ final class SourceEditAssist {
}
items.sort(Comparator.comparing(AutocompletePopup.CompletionItem::deprecated)
+ // the properties of the Kamelet before the options of the kamelet component
+ .thenComparing(i -> i.group() == null || !i.group().startsWith(AutocompletePopup.KAMELET_GROUP))
.thenComparing((a, b) -> Boolean.compare(b.required(), a.required()))
.thenComparing(AutocompletePopup.CompletionItem::key, String.CASE_INSENSITIVE_ORDER));
return items;
}
+ /**
+ * The properties of the Kamelet a kamelet: uri names, the ones not set yet: what goes under parameters: beside the
+ * options of the kamelet component itself (routeId, timeout...), which are all a model or a user saw before.
+ */
+ static List<AutocompletePopup.CompletionItem> kameletPropertyCompletions(
+ String uri, Path directory, Set<String> existingKeys) {
+ if (uri == null || !uri.startsWith("kamelet:")) {
+ return List.of();
+ }
+ String name = uri.substring("kamelet:".length());
+ int end = name.length();
+ for (char c : new char[] { '?', '/' }) {
+ int i = name.indexOf(c);
+ if (i >= 0) {
+ end = Math.min(end, i);
+ }
+ }
+ KameletDefinitions.Definition def = KameletDefinitions.find(name.substring(0, end).trim(), directory);
+ if (def == null) {
+ return List.of();
+ }
+ List<AutocompletePopup.CompletionItem> items = new ArrayList<>();
+ for (KameletDefinitions.Property p : def.properties()) {
+ if (existingKeys.contains(p.name())) {
+ continue;
+ }
+ items.add(new AutocompletePopup.CompletionItem(
+ p.name(), p.description(), p.type() != null ? p.type() : "string", p.defaultValue(), false, null,
+ AutocompletePopup.KAMELET_GROUP + def.name(), p.required()));
+ }
+ return items;
+ }
+
/** The quick doc of the XML element or attribute at the cursor (CAMEL-25244). */
List<SourceViewer.DocEntry> provideXmlQuickDoc(List<String> lines, int row, int col) {
return XmlQuickDoc.at(validationCatalog(), lines, row, col);
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 966f04171377..5e9f1d98d82f 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
@@ -907,7 +907,9 @@ class SourceTab extends AbstractTab {
sourceViewer.setQuickDocProvider(assist::provideCamelQuickDocs);
sourceViewer.setDeprecatedLineScanner(null);
if (SourceEditAssist.isYamlFile(filePath)) {
- sourceViewer.setAutocompleteProvider(assist::provideYamlKeyCompletions);
+ // the options of a kamelet: endpoint start with the properties of its Kamelet (CAMEL-25411)
+ sourceViewer.setAutocompleteProvider(
+ c -> assist.provideYamlKeyCompletions(c, filePath.toAbsolutePath().getParent()));
sourceViewer.setAutocompleteValueProvider(assist::provideYamlValueCompletions);
sourceViewer.setEndpointValidator(assist::validateYamlEndpoints);
// in a Kamelet's template, a property written as ${header.tag} is said to be the placeholder {{tag}}
@@ -1388,6 +1390,8 @@ class SourceTab extends AbstractTab {
}
String filePath = file.toString();
+ // the template of a Kamelet is the route of kamelet:<its name>, named after it (CAMEL-25411)
+ String kamelet = SourceEditAssist.isKameletFile(file) ? kameletName(lines) : null;
String currentRouteId = null;
int routeIdIndent = -1;
int pendingFromLine = -1;
@@ -1425,7 +1429,8 @@ class SourceTab extends AbstractTab {
linkableBlockIndent = -1;
String inlineUri = extractInlineUri(trimmed, "from");
if (inlineUri != null) {
- emitRouteEntry(fromEntries, currentRouteId, inlineUri, filePath, i);
+ emitRouteEntry(fromEntries, kameletRouteId(kamelet, currentRouteId), kameletFromUri(kamelet, inlineUri),
+ filePath, i);
} else {
pendingFromLine = i;
}
@@ -1436,7 +1441,8 @@ class SourceTab extends AbstractTab {
if (pendingFromLine >= 0 && trimmed.startsWith("uri:")) {
String uri = extractYamlValue(trimmed, "uri");
if (uri != null) {
- emitRouteEntry(fromEntries, currentRouteId, uri, filePath, pendingFromLine);
+ emitRouteEntry(fromEntries, kameletRouteId(kamelet, currentRouteId), kameletFromUri(kamelet, uri),
+ filePath, pendingFromLine);
}
pendingFromLine = -1;
continue;
@@ -1489,6 +1495,33 @@ class SourceTab extends AbstractTab {
}
}
+ /** The metadata.name of a Kamelet file, or null. */
+ static String kameletName(List<String> lines) {
+ boolean metadata = false;
+ for (String line : lines) {
+ if (line.isBlank() || line.trim().startsWith("#")) {
+ continue;
+ }
+ if (lineIndent(line) == 0) {
+ metadata = line.trim().equals("metadata:");
+ } else if (metadata && line.trim().startsWith("name:")) {
+ String name = extractYamlValue(line.trim(), "name");
+ return name != null && !name.isEmpty() ? name : null;
+ }
+ }
+ return null;
+ }
+
+ private static String kameletRouteId(String kamelet, String routeId) {
+ return kamelet != null && (routeId == null || routeId.isEmpty()) ? kamelet : routeId;
+ }
+
+ /** An action or a sink is entered from kamelet:source: the routes reach it as kamelet:<its name>. */
+ private static String kameletFromUri(String kamelet, String uri) {
+ return kamelet != null && (uri.equals("kamelet:source") || uri.startsWith("kamelet:source?"))
+ ? "kamelet:" + kamelet : uri;
+ }
+
private void emitRouteEntry(List<RouteEntry> index, String routeId, String fromUri, String filePath, int fromLine) {
String baseUri = stripQueryParams(fromUri);
if (baseUri == null || baseUri.isEmpty()) {
@@ -1685,23 +1718,23 @@ class SourceTab extends AbstractTab {
return result;
}
- /** Reverse links: a from line to a route that sends to it (jumps to the caller's to line). */
+ /**
+ * Reverse links: a from line to the route that sends to it (jumps to the caller's to line), or when several do, to
+ * a popup to choose one of them: a Kamelet or a direct: route used by more than one route.
+ */
private void addReverseLinks(String currentFilePath, Map<Integer, SourceViewer.JumpLink> result) {
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;
- }
+ List<ToEntry> callers = callers(re);
+ if (callers.size() == 1) {
+ ToEntry te = callers.get(0);
+ String callerRouteId = te.routeId().isEmpty() ? "route" : te.routeId();
+ result.putIfAbsent(re.fromLine(), new SourceViewer.JumpLink(callerRouteId, te.filePath(), te.toLine()));
+ } else if (callers.size() > 1) {
+ result.putIfAbsent(re.fromLine(),
+ new SourceViewer.JumpLink(callers.size() + " callers", null, -1, re.fromUri()));
}
}
}
@@ -1741,7 +1774,27 @@ class SourceTab extends AbstractTab {
}
return;
}
- gotoRoutePopup.openItems(usagesOf(uri), "Usages of " + uri);
+ // the line the popup is opened on is not one of the places to go to
+ List<GotoRoutePopup.RouteItem> items = withoutLine(usagesOf(uri),
+ sourceViewer.getCurrentFilePath(), sourceViewer.getSelectedLine());
+ if (items.isEmpty()) {
+ if (ctx.notificationCallback != null) {
+ ctx.notificationCallback.accept(uri + " is used only here", false);
+ }
+ return;
+ }
+ gotoRoutePopup.openItems(items, "Usages of " + uri);
+ }
+
+ /** The places without the one at the line of the file. */
+ static List<GotoRoutePopup.RouteItem> withoutLine(List<GotoRoutePopup.RouteItem> items, String file, int line) {
+ List<GotoRoutePopup.RouteItem> answer = new ArrayList<>();
+ for (GotoRoutePopup.RouteItem item : items) {
+ if (!(item.filePath().equals(file) && item.fromLine() == line)) {
+ answer.add(item);
+ }
+ }
+ return answer;
}
/** The routes that consume from the endpoint and the steps that send to it. */
@@ -1795,9 +1848,40 @@ class SourceTab extends AbstractTab {
}
private void handleJumpLink(SourceViewer.JumpLink link) {
+ if (link.callersOf() != null) {
+ // sent to from more than one place: choose which
+ gotoRoutePopup.openItems(callersOf(link.callersOf()), "Callers of " + link.callersOf());
+ return;
+ }
openFileAt(link.filePath(), link.targetLine());
}
+ /** The steps of other routes that send to the endpoint a route consumes from. */
+ List<GotoRoutePopup.RouteItem> callersOf(String uri) {
+ List<GotoRoutePopup.RouteItem> items = new ArrayList<>();
+ for (RouteEntry re : routeIndex) {
+ if (uri.equals(re.fromUri())) {
+ for (ToEntry te : callers(re)) {
+ String routeId = te.routeId().isEmpty() ? "route" : te.routeId();
+ items.add(new GotoRoutePopup.RouteItem(routeId, "to " + uri, te.filePath(), te.toLine()));
+ }
+ break;
+ }
+ }
+ return items;
+ }
+
+ /** The steps of the other routes that send to the route. */
+ private List<ToEntry> callers(RouteEntry re) {
+ List<ToEntry> answer = new ArrayList<>();
+ for (ToEntry te : toIndex) {
+ if (!te.routeId().equals(re.routeId()) && re.fromUri().equals(te.toUri())) {
+ answer.add(te);
+ }
+ }
+ return answer;
+ }
+
/** A uri: line, also as the first key of a list item: - uri: direct:billing in the cases of a switch. */
private static boolean isUriLine(String trimmed) {
return trimmed.startsWith("uri:") || trimmed.startsWith("- uri:");
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 ffe17de3f105..403a12dd88af 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
@@ -23,6 +23,7 @@ import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.Collections;
+import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
@@ -125,7 +126,15 @@ class SourceViewer {
Set<Integer> scan(List<JsonObject> codeData);
}
- record JumpLink(String routeId, String filePath, int targetLine) {
+ /**
+ * A link on a line to where it leads; callersOf is the endpoint of a route sent to from more than one place, whose
+ * link opens a popup to choose one (CAMEL-25411), else null.
+ */
+ record JumpLink(String routeId, String filePath, int targetLine, String callersOf) {
+
+ JumpLink(String routeId, String filePath, int targetLine) {
+ this(routeId, filePath, targetLine, null);
+ }
}
/**
@@ -218,6 +227,8 @@ class SourceViewer {
// the Kamelet checks of a YAML file (CAMEL-25411): its problems, and the notes that do not block a save
private EndpointValidator kameletValidator;
private EndpointValidator kameletNotes;
+ // 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;
private LiveRunData liveRunData;
private LineFailures lineFailures;
@@ -545,6 +556,7 @@ class SourceViewer {
simpleValidator = null;
kameletValidator = null;
kameletNotes = null;
+ noteLines = Set.of();
routeValidator = null;
uriCompletion = null;
simpleCompletion = null;
@@ -592,6 +604,7 @@ class SourceViewer {
simpleValidator = null;
kameletValidator = null;
kameletNotes = null;
+ noteLines = Set.of();
routeValidator = null;
uriCompletion = null;
simpleCompletion = null;
@@ -1828,7 +1841,7 @@ class SourceViewer {
List<AutocompletePopup.CompletionItem> items = autocompleteProvider.provide(context);
if (items != null && !items.isEmpty()) {
autocompletePopup = new AutocompletePopup(items, filter, filter);
- autocompletePopup.setTitlePrefix(ctx.component() + " options");
+ autocompletePopup.setTitlePrefix(optionsTitle(ctx.component(), ctx.uri()));
}
}
return;
@@ -2075,6 +2088,7 @@ class SourceViewer {
}
}
addAll(msgs, kameletValidator, content);
+ noteLines = Set.of();
if (!msgs.isEmpty()) {
validationErrors = msgs;
validationErrorScroll = 0;
@@ -2087,6 +2101,7 @@ class SourceViewer {
// marked, and said when saved, but not blocking the save
routeProblems = notes;
inlineErrors = buildInlineErrors(notes, content);
+ noteLines = new HashSet<>(inlineErrors.keySet());
return;
}
} else if (validateOnSave && isPropertiesFile() && propertiesValidator != null) {
@@ -2228,6 +2243,19 @@ class SourceViewer {
if (fixed == null) {
return;
}
+ if (fix.removesLine()) {
+ // an unused dependency of a Kamelet: the line goes, as with Ctrl+K, and the dependencies: key above it
+ // when it was the last one
+ int[] range = QuickFixes.linesToRemove(editLines(), row);
+ List<String> kept = new ArrayList<>(editLines());
+ kept.subList(range[0], range[1] + 1).clear();
+ applyBlockEdit(new YamlBlockEditor.EditResult(kept, Math.min(range[0], Math.max(0, kept.size() - 1)), 0));
+ dirty = true;
+ lineStatuses = null;
+ lastBackgroundValidationTime = 0;
+ notifySave("Fixed: " + fix.label(), false);
+ return;
+ }
recordEditChange();
editState.moveCursorToLineStart();
for (int i = 0; i < line.length(); i++) {
@@ -2288,6 +2316,7 @@ class SourceViewer {
/** The problems the checks of the file type (Camel YAML, properties, Java or XML routes) find in the content. */
private List<String> validateContent(String content) {
List<String> msgs = new ArrayList<>();
+ noteLines = Set.of();
if (isCamelYamlFile()) {
if (endpointValidator != null) {
List<String> endpointErrors = endpointValidator.validate(content);
@@ -2302,7 +2331,17 @@ class SourceViewer {
}
}
addAll(msgs, kameletValidator, content);
- addAll(msgs, kameletNotes, content);
+ List<String> notes = new ArrayList<>();
+ addAll(notes, kameletNotes, content);
+ if (!notes.isEmpty()) {
+ // a line with an error is marked as an error
+ Set<Integer> lines = new HashSet<>(buildInlineErrors(notes, content).keySet());
+ if (!msgs.isEmpty()) {
+ lines.removeAll(buildInlineErrors(msgs, content).keySet());
+ }
+ noteLines = lines;
+ msgs.addAll(notes);
+ }
} else if (isPropertiesFile() && propertiesValidator != null) {
msgs.addAll(validateProperties(content));
} else if (routeValidator != null) {
@@ -2314,6 +2353,44 @@ class SourceViewer {
return msgs;
}
+ /**
+ * The title of the options popup of an endpoint: the component, or for a kamelet: endpoint the Kamelet it names,
+ * whose properties the list starts with (CAMEL-25411).
+ */
+ static String optionsTitle(String component, String uri) {
+ if ("kamelet".equals(component) && uri != null && uri.startsWith("kamelet:")) {
+ String name = uri.substring("kamelet:".length()).split("[?/]", 2)[0].trim();
+ if (!name.isEmpty()) {
+ return name + " options";
+ }
+ }
+ return component + " options";
+ }
+
+ /** The title spans counting the problems of the lines: the errors, and apart from them the notes. */
+ private List<Span> problemCounts(Map<Integer, String> problems, Style errorStyle) {
+ List<Span> spans = new ArrayList<>();
+ int notes = 0;
+ for (Integer line : problems.keySet()) {
+ if (noteLines.contains(line)) {
+ notes++;
+ }
+ }
+ int errors = problems.size() - notes;
+ if (errors > 0) {
+ spans.add(Span.styled(" errors: " + errors + " ", errorStyle));
+ }
+ if (notes > 0) {
+ spans.add(Span.styled(" notes: " + notes + " ", Theme.warning()));
+ }
+ return spans;
+ }
+
+ /** Package-private for tests: the lines, from 0, whose problem is a note. */
+ Set<Integer> noteLines() {
+ return noteLines;
+ }
+
private static void addAll(List<String> msgs, EndpointValidator validator, String content) {
if (validator != null) {
List<String> found = validator.validate(content);
@@ -2675,12 +2752,15 @@ class SourceViewer {
String problem = viewErrors.get(selectedLine);
if (problem != null) {
// the problem of the line goes before its documentation, as the Error panel of the editor shows it
- int remaining = Math.max(0, viewDocArea.width() - " Error ".length() - 3);
+ boolean note = noteLines.contains(selectedLine);
+ Style problemStyle = note ? Theme.warning() : Theme.error();
+ String kind = note ? " Note " : " Error ";
+ int remaining = Math.max(0, viewDocArea.width() - kind.length() - 3);
docLines.add(Line.from(
- Span.styled("───", Theme.error()),
- Span.styled(" Error ", Theme.error().bold()),
- Span.styled("─".repeat(remaining), Theme.error())));
- docLines.add(Line.from(Span.styled(problem, Theme.error())));
+ Span.styled("───", problemStyle),
+ Span.styled(kind, problemStyle.bold()),
+ Span.styled("─".repeat(remaining), problemStyle)));
+ docLines.add(Line.from(Span.styled(problem, problemStyle)));
docLines.add(Line.from(Span.styled("F4 edit F9 next problem", Style.EMPTY.dim())));
} else if (titleText != null) {
String prefix = "─── ";
@@ -2725,7 +2805,7 @@ 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));
+ titleSpans.addAll(problemCounts(visibleErrors, errorStyle));
}
Title posTitle;
if (diffOverlay) {
@@ -2879,8 +2959,11 @@ class SourceViewer {
for (int r = 0; r < editorArea.height(); r++) {
int lineIdx = editState.scrollRow() + r;
if (visibleErrors.containsKey(lineIdx)) {
+ // a note on dark amber, an error on dark red
Style errorBg = Style.EMPTY.fg(dev.tamboui.style.Color.WHITE)
- .bg(dev.tamboui.style.Color.rgb(0x8B, 0x00, 0x00));
+ .bg(noteLines.contains(lineIdx)
+ ? dev.tamboui.style.Color.rgb(0x80, 0x60, 0x00)
+ : dev.tamboui.style.Color.rgb(0x8B, 0x00, 0x00));
int screenY = editorArea.top() + r;
for (int x = textAreaRect.left(); x < textAreaRect.left() + gutterWidth; x++) {
dev.tamboui.buffer.Cell cell = frame.buffer().get(x, screenY);
@@ -2897,7 +2980,7 @@ class SourceViewer {
List<Line> docLines = new ArrayList<>();
String titleText = null;
if (cursorError != null) {
- titleText = "Error";
+ titleText = noteLines.contains(editState.cursorRow()) ? "Note" : "Error";
} else if (editDocEntries != null && !editDocEntries.isEmpty()) {
titleText = editDocEntries.get(0).title();
}
@@ -2905,7 +2988,9 @@ class SourceViewer {
String prefix = "─── ";
String suffix = " ";
int remaining = Math.max(0, docArea.width() - prefix.length() - titleText.length() - suffix.length());
- Style errorDim = Style.EMPTY.fg(dev.tamboui.style.Color.rgb(0xFF, 0x66, 0x66));
+ Style errorDim = noteLines.contains(editState.cursorRow())
+ ? Theme.warning()
+ : Style.EMPTY.fg(dev.tamboui.style.Color.rgb(0xFF, 0x66, 0x66));
docLines.add(Line.from(
Span.styled(prefix, errorDim),
Span.styled(titleText, errorDim.bold()),
@@ -3743,9 +3828,10 @@ class SourceViewer {
}
if (currentRouteId == null) {
if (!viewErrors.isEmpty()) {
- return Title.from(Line.from(
- Span.styled(" Source [" + info + "] ", ts),
- Span.styled(" errors: " + viewErrors.size() + " ", Theme.error())));
+ List<Span> spans = new ArrayList<>();
+ spans.add(Span.styled(" Source [" + info + "] ", ts));
+ spans.addAll(problemCounts(viewErrors, Theme.error()));
+ return Title.from(Line.from(spans));
}
return Title.from(Span.styled(" Source [" + info + "] ", ts));
}
@@ -3946,7 +4032,8 @@ class SourceViewer {
spans.add(Span.styled(">> ", focused ? Theme.label().bold() : Theme.label().dim()));
if (!prefix.isEmpty()) {
Style numberStyle = viewErrors.containsKey(lineIndex)
- ? Theme.error().bold() : focused ? Theme.label().bold() : Theme.label().dim();
+ ? (noteLines.contains(lineIndex) ? Theme.warning().bold() : Theme.error().bold())
+ : focused ? Theme.label().bold() : Theme.label().dim();
spans.add(Span.styled(prefix, numberStyle.patch(selBg)));
}
addLiveColumn(spans, lineIndex, selBg);
@@ -3955,7 +4042,9 @@ class SourceViewer {
}
} else {
if (viewErrors.containsKey(lineIndex)) {
- spans.add(Span.styled(" ✗ ", Theme.error().bold()));
+ spans.add(noteLines.contains(lineIndex)
+ ? Span.styled(" ⚠ ", Theme.warning().bold())
+ : Span.styled(" ✗ ", Theme.error().bold()));
} else {
spans.add(isDeprecated
? Span.styled(" ⚠ ", Theme.warning())
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SyntaxHighlighter.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SyntaxHighlighter.java
index ab8afd3c1911..ffff94e5d3f4 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SyntaxHighlighter.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SyntaxHighlighter.java
@@ -230,10 +230,15 @@ class SyntaxHighlighter {
return Language.PLAIN;
}
ext = ext.toLowerCase();
+ // the last extension: a Kamelet is name.kamelet.yaml, which onlyExt gives as kamelet.yaml (CAMEL-25411)
+ int dot = ext.lastIndexOf('.');
+ if (dot >= 0) {
+ ext = ext.substring(dot + 1);
+ }
return switch (ext) {
case "java" -> Language.JAVA;
- case "yaml", "yml", "camel.yaml", "camel.yml" -> Language.YAML;
- case "xml", "camel.xml" -> Language.XML;
+ case "yaml", "yml" -> Language.YAML;
+ case "xml" -> Language.XML;
case "properties" -> Language.PROPERTIES;
default -> Language.PLAIN;
};
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 67b4620f950f..a8e046633e76 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
@@ -117,6 +117,68 @@ class SourceKameletValidationTest {
ROUTE.replace("tagg:", "tag:"))).isEmpty();
}
+ @Test
+ void theParametersOfAKameletEndpointCompleteItsProperties() throws Exception {
+ Files.writeString(tempDir.resolve("tag-order-action.kamelet.yaml"), KAMELET, StandardCharsets.UTF_8);
+ SourceEditAssist assist = new SourceEditAssist(
+ new MonitorContext(
+ new AtomicReference<>(List.of()), new AtomicReference<>(List.of())));
+ List<AutocompletePopup.CompletionItem> items
+ = assist.provideYamlKeyCompletions("yaml:kamelet:producer|kamelet:tag-order-action", tempDir);
+ // the property of the Kamelet first, then the options of the kamelet component
+ assertThat(items.get(0).key()).isEqualTo("tag");
+ assertThat(items.get(0).required()).isTrue();
+ assertThat(items.get(0).group()).isEqualTo("kamelet tag-order-action");
+ assertThat(items).extracting(AutocompletePopup.CompletionItem::key).contains("routeId", "timeout");
+ // a property already given is not offered again
+ assertThat(assist.provideYamlKeyCompletions("yaml:kamelet:producer:tag|kamelet:tag-order-action", tempDir))
+ .extracting(AutocompletePopup.CompletionItem::key).doesNotContain("tag");
+ // a Kamelet of the catalog, in the query of the uri
+ assertThat(assist.provideYamlKeyCompletions("yaml:kamelet:consumer|kamelet:timer-source?period=1000", tempDir))
+ .extracting(AutocompletePopup.CompletionItem::key).startsWith("message");
+ }
+
+ @Test
+ void theOptionsPopupIsNamedAfterTheKamelet() {
+ assertThat(SourceViewer.optionsTitle("kamelet", "kamelet:tag-order-action")).isEqualTo("tag-order-action options");
+ assertThat(SourceViewer.optionsTitle("kamelet", "kamelet:timer-source?period=1000"))
+ .isEqualTo("timer-source options");
+ assertThat(SourceViewer.optionsTitle("kamelet", null)).isEqualTo("kamelet options");
+ assertThat(SourceViewer.optionsTitle("timer", "timer:tick")).isEqualTo("timer options");
+ }
+
+ @Test
+ void aDividerSetsTheKameletComponentOptionsApart() throws Exception {
+ Files.writeString(tempDir.resolve("tag-order-action.kamelet.yaml"), KAMELET, StandardCharsets.UTF_8);
+ SourceEditAssist assist = new SourceEditAssist(
+ new MonitorContext(
+ new AtomicReference<>(List.of()), new AtomicReference<>(List.of())));
+ List<AutocompletePopup.CompletionItem> items
+ = assist.provideYamlKeyCompletions("yaml:kamelet:producer|kamelet:tag-order-action", tempDir);
+ // tag above the divider, the options of the kamelet component below it
+ assertThat(new AutocompletePopup(items, "", "").dividerAt()).isEqualTo(1);
+ // filtering keeps the property of the Kamelet above: "t" matches tag and timeout
+ AutocompletePopup filtered = new AutocompletePopup(items, "t", "t");
+ assertThat(filtered.dividerAt()).isEqualTo(1);
+ // only the Kamelet's properties left, or none: no divider
+ assertThat(new AutocompletePopup(items, "tag", "tag").dividerAt()).isEqualTo(-1);
+ assertThat(new AutocompletePopup(items, "routeId", "routeId").dividerAt()).isEqualTo(-1);
+ // a Kamelet without properties: only the options of the component, no divider
+ String noProperties = KAMELET.replace("""
+ required:
+ - tag
+ properties:
+ tag:
+ title: Tag
+ type: string
+ """, "").replace("{{tag}}", "tagged");
+ Files.writeString(tempDir.resolve("tag-order-action.kamelet.yaml"), noProperties, StandardCharsets.UTF_8);
+ List<AutocompletePopup.CompletionItem> none
+ = assist.provideYamlKeyCompletions("yaml:kamelet:producer|kamelet:tag-order-action", tempDir);
+ assertThat(none).isNotEmpty();
+ assertThat(new AutocompletePopup(none, "", "").dividerAt()).isEqualTo(-1);
+ }
+
@Test
void aKameletFileWithAProblemIsMarkedOnLoadAndNotSaved() throws Exception {
Path file = tempDir.resolve("tag-order-action.kamelet.yaml");
@@ -143,6 +205,8 @@ class SourceKameletValidationTest {
viewer.loadFile(file);
int timerLine = lineOf(timer, "\"camel:timer\"");
assertThat(viewer.viewErrors()).containsOnlyKeys(timerLine);
+ // a note, marked as a warning and counted apart from the errors
+ assertThat(viewer.noteLines()).containsOnly(timerLine);
viewer.enterEditMode();
appendSpaceToLine(viewer, 1);
@@ -151,6 +215,42 @@ class SourceKameletValidationTest {
assertThat(lastNotification.get()).startsWith("Saved: tag-order-action.kamelet.yaml with 1 Camel problem: ")
.contains("camel:timer is not used by the template");
assertThat(viewer.inlineErrors()).containsOnlyKeys(timerLine);
+ assertThat(viewer.noteLines()).containsOnly(timerLine);
+ }
+
+ @Test
+ void shiftF9RemovesAnUnusedDependency() throws Exception {
+ Path file = tempDir.resolve("tag-order-action.kamelet.yaml");
+ // camel:timer the only dependency, as the model wrote it: the dependencies: key goes with it
+ String timer = KAMELET.replace(" - \"camel:kamelet\"\n", " - \"camel:timer\"\n");
+ Files.writeString(file, timer, StandardCharsets.UTF_8);
+ SourceViewer viewer = viewer(file);
+ viewer.loadFile(file);
+ viewer.enterEditMode();
+ for (int i = 0; i < lineOf(timer, "\"camel:timer\""); i++) {
+ viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.DOWN, KeyModifiers.NONE));
+ }
+ viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.F9, KeyModifiers.SHIFT));
+ assertThat(viewer.editText()).doesNotContain("camel:timer").doesNotContain("dependencies:")
+ .contains(" template:");
+ assertThat(lastNotification.get()).isEqualTo("Fixed: remove camel:timer");
+ }
+
+ @Test
+ void anErrorIsNotANote() throws Exception {
+ Path file = tempDir.resolve("tag-order-action.kamelet.yaml");
+ String self = KAMELET.replace("uri: kamelet:source", "uri: kamelet:tag-order-action");
+ Files.writeString(file, self, StandardCharsets.UTF_8);
+ SourceViewer viewer = viewer(file);
+ viewer.loadFile(file);
+ assertThat(viewer.viewErrors()).isNotEmpty();
+ assertThat(viewer.noteLines()).isEmpty();
+ }
+
+ @Test
+ void theTemplateOfAKameletIsNamedAfterIt() {
+ assertThat(SourceTab.kameletName(List.of(KAMELET.split("\n")))).isEqualTo("tag-order-action");
+ assertThat(SourceTab.kameletName(List.of(ROUTE.split("\n")))).isNull();
}
private SourceViewer viewer(Path file) {
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceTabJumpLinksTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceTabJumpLinksTest.java
index 8361a9df4a22..13be5dae4a48 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceTabJumpLinksTest.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceTabJumpLinksTest.java
@@ -88,6 +88,58 @@ class SourceTabJumpLinksTest {
assertThat(links.get(15).routeId()).isEqualTo("direct:review");
}
+ @Test
+ void aKameletUsedByTwoRoutesLinksToAPopupOfItsCallers() throws Exception {
+ // CAMEL-25411: the template of a Kamelet is the route of kamelet:<its name>
+ Path kamelet = Files.writeString(dir.resolve("tag-order-action.kamelet.yaml"), """
+ apiVersion: camel.apache.org/v1
+ kind: Kamelet
+ metadata:
+ name: tag-order-action
+ spec:
+ template:
+ from:
+ uri: kamelet:source
+ steps:
+ - setBody:
+ simple: "${body} [{{tag}}]"
+ """);
+ Path orders = Files.writeString(dir.resolve("orders.camel.yaml"), """
+ - route:
+ id: orders
+ from:
+ uri: timer:orders
+ steps:
+ - to:
+ uri: kamelet:tag-order-action
+ """);
+ SourceTab tab = newTab();
+ assertThat(tab.loadDirectory(dir)).isTrue();
+ // the route links to the Kamelet, and one caller is linked from the template
+ assertThat(tab.computeJumpLinks(orders).get(6).routeId()).isEqualTo("tag-order-action");
+ assertThat(tab.computeJumpLinks(kamelet).get(6).routeId()).isEqualTo("orders");
+
+ Files.writeString(dir.resolve("refunds.camel.yaml"), """
+ - route:
+ id: refunds
+ from:
+ uri: timer:refunds
+ steps:
+ - to:
+ uri: kamelet:tag-order-action
+ """);
+ assertThat(tab.loadDirectory(dir)).isTrue();
+ SourceViewer.JumpLink link = tab.computeJumpLinks(kamelet).get(6);
+ assertThat(link.routeId()).isEqualTo("2 callers");
+ assertThat(link.callersOf()).isEqualTo("kamelet:tag-order-action");
+ assertThat(tab.callersOf("kamelet:tag-order-action"))
+ .extracting(GotoRoutePopup.RouteItem::routeId).containsExactlyInAnyOrder("orders", "refunds");
+ // the usages popup opened on the from: line of the template does not list that line itself
+ List<GotoRoutePopup.RouteItem> usages = SourceTab.withoutLine(
+ tab.usagesOf("kamelet:tag-order-action"), kamelet.toString(), 6);
+ assertThat(usages).extracting(GotoRoutePopup.RouteItem::routeId).containsExactlyInAnyOrder("orders", "refunds");
+ }
+
@Test
void javaRoutesLinkToAndFromYamlRoutes() throws Exception {
Path orders = Files.writeString(dir.resolve("Orders.java"), """
@@ -122,10 +174,12 @@ class SourceTabJumpLinksTest {
assertThat(links.get(5).filePath()).isEqualTo(billing.toString());
assertThat(links.get(9).routeId()).isEqualTo("billing");
- // and back: the from: line of the YAML route links to a Java caller
+ // and back: the from: line of the YAML route has two Java callers, to choose from in a popup (CAMEL-25411)
SourceViewer.JumpLink back = tab.computeJumpLinks(billing).get(2);
- assertThat(back.filePath()).isEqualTo(orders.toString());
- assertThat(back.routeId()).isEqualTo("file:inbox");
+ assertThat(back.routeId()).isEqualTo("2 callers");
+ assertThat(back.callersOf()).isEqualTo("direct:billing");
+ assertThat(tab.callersOf("direct:billing")).extracting(GotoRoutePopup.RouteItem::filePath)
+ .containsOnly(orders.toString());
}
@Test
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SyntaxHighlighterTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SyntaxHighlighterTest.java
index 8d58ab215013..162c415b7ea7 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SyntaxHighlighterTest.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SyntaxHighlighterTest.java
@@ -44,6 +44,14 @@ class SyntaxHighlighterTest {
SyntaxHighlighter.detectLanguage("application.properties:12"));
}
+ @Test
+ void detectsTheLanguageOfAKameletFile() {
+ // onlyExt gives kamelet.yaml: the last extension decides (CAMEL-25411)
+ assertEquals(SyntaxHighlighter.Language.YAML, SyntaxHighlighter.detectLanguage("tag-order-action.kamelet.yaml"));
+ assertEquals(SyntaxHighlighter.Language.YAML, SyntaxHighlighter.detectLanguage("orders.camel.yaml"));
+ assertEquals(SyntaxHighlighter.Language.XML, SyntaxHighlighter.detectLanguage("orders.camel.xml"));
+ }
+
@Test
void colorsKeySeparatorAndValue() {
Line line = SyntaxHighlighter.highlightLine("camel.main.name=demo", SyntaxHighlighter.Language.PROPERTIES);