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 <[email protected]>
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) <[email protected]>
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) <[email protected]>
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) <[email protected]>
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) <[email protected]>
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) <[email protected]>
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) <[email protected]>
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) <[email protected]>
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) <[email protected]>
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) <[email protected]>
Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
---------
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
---
.../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);