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 8e9a17077836 camel-jbang - TUI tab completion for component names and
endpoint options
8e9a17077836 is described below
commit 8e9a170778365ba8e5e6452a8032f7db21971c59
Author: Claus Ibsen <[email protected]>
AuthorDate: Tue Aug 4 13:40:12 2026 +0200
camel-jbang - TUI tab completion for component names and endpoint options
- Tab on uri: lines shows component names filtered by context
(consumer-only excluded for to/wireTap/enrich, producer-only excluded
for from/pollEnrich/poll/interceptFrom)
- Selecting a component auto-inserts parameters: block
- Endpoint option completion now includes path options (e.g.
destinationName)
- Required options shown in bold with * prefix and sorted to top
- Detail panel shows Required: true for required options
- Already-specified options filtered from completion list (multi-valued
options still allowed)
- Typing non-matching characters no longer closes the autocomplete popup
- Updated CONSUMER_EIPS/PRODUCER_EIPS with poll, interceptFrom,
interceptSendToEndpoint
camel-jbang - TUI autocomplete: title prefix, test coverage, and
non-matching char fix
camel-jbang - TUI component picker matches on labels and shows first label
as type
Co-Authored-By: Claude Opus 4.6 <[email protected]>
Signed-off-by: Claus Ibsen <[email protected]>
---
.../jbang/core/commands/tui/AutocompletePopup.java | 52 +++-
.../dsl/jbang/core/commands/tui/SourceTab.java | 105 ++++++-
.../dsl/jbang/core/commands/tui/SourceViewer.java | 170 ++++++++++-
.../core/commands/tui/AutocompletePopupTest.java | 16 +-
.../core/commands/tui/YamlCompletionTest.java | 327 ++++++++++++++++++++-
5 files changed, 640 insertions(+), 30 deletions(-)
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 8ea1a047af69..4b57abc28d19 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
@@ -45,7 +45,12 @@ import dev.tamboui.widgets.scrollbar.ScrollbarState;
class AutocompletePopup {
record CompletionItem(String key, String description, String type, Object
defaultValue,
- boolean deprecated, String deprecationNote, String group) {
+ boolean deprecated, String deprecationNote, String group, boolean
required) {
+
+ CompletionItem(String key, String description, String type, Object
defaultValue,
+ boolean deprecated, String deprecationNote, String
group) {
+ this(key, description, type, defaultValue, deprecated,
deprecationNote, group, false);
+ }
}
@FunctionalInterface
@@ -76,6 +81,7 @@ class AutocompletePopup {
private List<CompletionItem> filteredItems;
private CompletionItem selectedItem;
private Rect popupRect;
+ private String titlePrefix;
AutocompletePopup(List<CompletionItem> items, String initialPrefix, String
lineKeyText) {
this(items, initialPrefix, lineKeyText, false);
@@ -176,7 +182,14 @@ class AutocompletePopup {
}
if (ke.code() == KeyCode.CHAR && !ke.hasCtrl() && !ke.hasAlt()) {
filter.appendChar(ke.string().charAt(0));
+ List<CompletionItem> prev = filteredItems;
rebuildList();
+ if (filteredItems.isEmpty()) {
+ // undo: keep current list instead of showing empty
+ filter.deleteChar();
+ filteredItems = prev;
+ listState.select(prev != null && !prev.isEmpty() ? 0 : null);
+ }
return Result.CONSUMED;
}
return Result.CONSUMED;
@@ -261,6 +274,7 @@ class AutocompletePopup {
items.add(ListItem.from(Line.from(Span.styled(sep,
Style.EMPTY.dim()))));
Style normalStyle = Style.EMPTY;
+ Style boldStyle = Style.EMPTY.bold();
Style dimStyle = Style.EMPTY.dim();
Style deprecatedStyle = Style.EMPTY.dim().crossedOut();
@@ -269,12 +283,14 @@ class AutocompletePopup {
if (ci.deprecated()) {
spans.add(Span.styled(" ✘ ", dimStyle));
+ } else if (ci.required()) {
+ spans.add(Span.styled(" * ", boldStyle));
} else {
spans.add(Span.raw(" "));
}
String key = ci.key();
- Style keyStyle = ci.deprecated() ? deprecatedStyle : normalStyle;
+ Style keyStyle = ci.deprecated() ? deprecatedStyle : ci.required()
? boldStyle : normalStyle;
String displayKey = key;
if (!key.startsWith("{{") && !key.endsWith(".")) {
@@ -309,9 +325,10 @@ class AutocompletePopup {
int total = allItems.size();
int shown = filteredItems.size();
+ String label = titlePrefix != null ? titlePrefix : "Completions";
String title = shown == total
- ? " Completions (" + total + ") "
- : " Completions (" + shown + "/" + total + ") ";
+ ? " " + label + " (" + total + ") "
+ : " " + label + " (" + shown + "/" + total + ") ";
ListWidget list = ListWidget.builder()
.items(items.toArray(ListItem[]::new))
@@ -365,6 +382,11 @@ class AutocompletePopup {
Span.styled("Group: ", normalStyle.bold()),
Span.styled(selected.group(), normalStyle)));
}
+ if (selected.required()) {
+ lines.add(Line.from(
+ Span.styled("Required: ", normalStyle.bold()),
+ Span.styled("true", Theme.info())));
+ }
if (selected.deprecated()) {
String depText = "Deprecated";
if (selected.deprecationNote() != null &&
!selected.deprecationNote().isEmpty()) {
@@ -410,6 +432,14 @@ class AutocompletePopup {
return filteredItems != null && !filteredItems.isEmpty();
}
+ boolean hasFilter() {
+ return filter.hasFilter();
+ }
+
+ void setTitlePrefix(String titlePrefix) {
+ this.titlePrefix = titlePrefix;
+ }
+
private void rebuildList() {
if (!filter.hasFilter()) {
filteredItems = new ArrayList<>(allItems);
@@ -417,11 +447,23 @@ class AutocompletePopup {
filteredItems = new ArrayList<>();
String f = filter.filter();
for (CompletionItem item : allItems) {
- if (item.key().toLowerCase().startsWith(f)) {
+ if (item.key().toLowerCase().startsWith(f) ||
matchesLabel(item.group(), f)) {
filteredItems.add(item);
}
}
}
listState.select(filteredItems.isEmpty() ? null : 0);
}
+
+ private static boolean matchesLabel(String group, String filter) {
+ if (group == null || group.isEmpty()) {
+ return false;
+ }
+ for (String label : group.split(",")) {
+ if (label.trim().toLowerCase().startsWith(filter)) {
+ return true;
+ }
+ }
+ return false;
+ }
}
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 84560c6782d7..5d9e28eaf2b1 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
@@ -23,6 +23,7 @@ import java.nio.file.attribute.BasicFileAttributes;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
@@ -96,6 +97,11 @@ class SourceTab extends AbstractTab {
private final Map<String, Map<String, BaseOptionModel>>
languageOptionsCache = new HashMap<>();
private final Map<String, Map<String, BaseOptionModel>>
dataformatOptionsCache = new HashMap<>();
+ // Component name completion cache (keyed by catalog version)
+ private String componentsCatalogVersion;
+ private List<AutocompletePopup.CompletionItem> consumerComponents;
+ private List<AutocompletePopup.CompletionItem> producerComponents;
+
// Spring Boot configuration metadata cache (lazy-loaded on-demand via IPC)
private Map<String, JsonObject> springBootMetadataCache;
private boolean springBootMetadataLoaded;
@@ -912,7 +918,16 @@ class SourceTab extends AbstractTab {
// ---- YAML DSL completion ----
private List<AutocompletePopup.CompletionItem>
provideYamlKeyCompletions(String context) {
- if (context == null || !context.startsWith("yaml:")) {
+ if (context == null) {
+ return List.of();
+ }
+
+ // component name completion on uri: lines
+ if (context.startsWith("yaml-uri:")) {
+ return provideComponentNameCompletions(context.substring(9));
+ }
+
+ if (!context.startsWith("yaml:")) {
return List.of();
}
CamelCatalog catalog = getCatalog();
@@ -920,8 +935,8 @@ class SourceTab extends AbstractTab {
return List.of();
}
- // context format: "yaml:componentName:consumer|producer"
- String[] parts = context.substring(5).split(":", 2);
+ // context format:
"yaml:componentName:consumer|producer[:existingKey1,existingKey2,...]"
+ String[] parts = context.substring(5).split(":", 3);
if (parts.length < 2) {
return List.of();
}
@@ -929,26 +944,98 @@ class SourceTab extends AbstractTab {
String role = parts[1];
boolean isConsumer = "consumer".equals(role);
+ Set<String> existingKeys = Set.of();
+ if (parts.length > 2 && !parts[2].isEmpty()) {
+ existingKeys = new HashSet<>(Arrays.asList(parts[2].split(",")));
+ }
+
ComponentModel model = catalog.componentModel(componentName);
if (model == null) {
return List.of();
}
+ // build a set of multi-valued option names so we can allow duplicates
+ Set<String> multiValuedOptions = new HashSet<>();
+ for (ComponentModel.EndpointOptionModel opt :
model.getEndpointOptions()) {
+ if (opt.isMultiValue()) {
+ multiValuedOptions.add(opt.getName());
+ }
+ }
+
List<AutocompletePopup.CompletionItem> items = new ArrayList<>();
- for (ComponentModel.EndpointOptionModel opt :
model.getEndpointParameterOptions()) {
- if (includeEndpointOption(opt, isConsumer)) {
- items.add(new AutocompletePopup.CompletionItem(
- opt.getName(), opt.getDescription(), opt.getType(),
- opt.getDefaultValue(), opt.isDeprecated(),
opt.getDeprecationNote(),
- opt.getGroup()));
+ for (ComponentModel.EndpointOptionModel opt :
model.getEndpointOptions()) {
+ if (!includeEndpointOption(opt, isConsumer)) {
+ continue;
+ }
+ if (existingKeys.contains(opt.getName()) &&
!multiValuedOptions.contains(opt.getName())) {
+ continue;
}
+ items.add(new AutocompletePopup.CompletionItem(
+ opt.getName(), opt.getDescription(), opt.getType(),
+ opt.getDefaultValue(), opt.isDeprecated(),
opt.getDeprecationNote(),
+ opt.getGroup(), opt.isRequired()));
}
items.sort(Comparator.comparing(AutocompletePopup.CompletionItem::deprecated)
+ .thenComparing((a, b) -> Boolean.compare(b.required(),
a.required()))
.thenComparing(AutocompletePopup.CompletionItem::key,
String.CASE_INSENSITIVE_ORDER));
return items;
}
+ private List<AutocompletePopup.CompletionItem>
provideComponentNameCompletions(String role) {
+ CamelCatalog catalog = getCatalog();
+ if (catalog == null) {
+ return List.of();
+ }
+
+ boolean isConsumer = "consumer".equals(role);
+ IntegrationInfo info = ctx.findSelectedIntegration();
+ String version = info != null ? info.camelVersion : null;
+
+ // rebuild cache if catalog version changed
+ if (version != null && !version.equals(componentsCatalogVersion)) {
+ componentsCatalogVersion = version;
+ consumerComponents = null;
+ producerComponents = null;
+ }
+
+ List<AutocompletePopup.CompletionItem> cached = isConsumer ?
consumerComponents : producerComponents;
+ if (cached != null) {
+ return cached;
+ }
+
+ List<AutocompletePopup.CompletionItem> items = new ArrayList<>();
+ for (String name : catalog.findComponentNames()) {
+ ComponentModel model = catalog.componentModel(name);
+ if (model == null) {
+ continue;
+ }
+ if (isConsumer && model.isProducerOnly()) {
+ continue;
+ }
+ if (!isConsumer && model.isConsumerOnly()) {
+ continue;
+ }
+ String labels = model.getLabel();
+ String firstLabel = labels != null && !labels.isEmpty()
+ ? labels.split(",")[0].trim()
+ : "component";
+ items.add(new AutocompletePopup.CompletionItem(
+ name, model.getTitle() + " - " + model.getDescription(),
+ firstLabel, null, model.isDeprecated(),
model.getDeprecationNote(),
+ labels));
+ }
+
items.sort(Comparator.comparing(AutocompletePopup.CompletionItem::deprecated)
+ .thenComparing(AutocompletePopup.CompletionItem::key,
String.CASE_INSENSITIVE_ORDER));
+
+ if (isConsumer) {
+ consumerComponents = items;
+ } else {
+ producerComponents = items;
+ }
+ return items;
+ }
+
private static boolean
includeEndpointOption(ComponentModel.EndpointOptionModel opt, boolean
isConsumer) {
String label = opt.getLabel();
if (label == null || label.isEmpty()) {
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 93ffd93fe139..0a39599c2851 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
@@ -542,9 +542,11 @@ class SourceViewer {
record YamlEndpointContext(String component, boolean consumer) {
}
- private static final java.util.Set<String> CONSUMER_EIPS =
java.util.Set.of("from", "pollEnrich", "poll-enrich");
+ private static final java.util.Set<String> CONSUMER_EIPS
+ = java.util.Set.of("from", "pollEnrich", "poll-enrich", "poll",
"interceptFrom", "intercept-from");
private static final java.util.Set<String> PRODUCER_EIPS
- = java.util.Set.of("to", "toD", "to-d", "wireTap", "wire-tap",
"enrich");
+ = java.util.Set.of("to", "toD", "to-d", "wireTap", "wire-tap",
"enrich",
+ "interceptSendToEndpoint", "intercept-send-to-endpoint");
YamlEndpointContext findEnclosingComponent(int fromRow) {
String cursorLine = editState.getLine(fromRow);
@@ -626,6 +628,140 @@ class SourceViewer {
return null;
}
+ java.util.Set<String> collectExistingParameters(int fromRow) {
+ java.util.Set<String> keys = new java.util.LinkedHashSet<>();
+ // find the parameters: row by walking up
+ int parametersRow = -1;
+ int parametersIndent = -1;
+ String cursorLine = editState.getLine(fromRow);
+ int cursorIndent = countLeadingSpaces(cursorLine);
+
+ // blank lines: derive indent from nearest preceding non-blank line
+ if (cursorLine.isBlank()) {
+ for (int i = fromRow - 1; i >= 0; i--) {
+ String prev = editState.getLine(i);
+ if (!prev.isBlank()) {
+ if (prev.trim().startsWith("parameters:")) {
+ parametersRow = i;
+ parametersIndent = countLeadingSpaces(prev);
+ } else {
+ cursorIndent = countLeadingSpaces(prev);
+ }
+ break;
+ }
+ }
+ }
+
+ if (parametersRow < 0) {
+ for (int i = fromRow; i >= 0; i--) {
+ String line = editState.getLine(i);
+ if (line.isBlank()) {
+ continue;
+ }
+ String trimmed = line.trim();
+ int indent = countLeadingSpaces(line);
+ if (trimmed.startsWith("parameters:") && indent <
cursorIndent) {
+ parametersRow = i;
+ parametersIndent = indent;
+ break;
+ }
+ if (i < fromRow && indent < cursorIndent &&
!trimmed.startsWith("#")) {
+ break;
+ }
+ }
+ }
+ if (parametersRow < 0) {
+ return keys;
+ }
+ int childIndent = parametersIndent + 2;
+ for (int i = parametersRow + 1; i < editState.lineCount(); i++) {
+ if (i == fromRow) {
+ continue;
+ }
+ String line = editState.getLine(i);
+ if (line.isBlank()) {
+ continue;
+ }
+ int indent = countLeadingSpaces(line);
+ if (indent < childIndent) {
+ break;
+ }
+ if (indent == childIndent) {
+ String trimmed = line.trim();
+ int colonIdx = trimmed.indexOf(':');
+ if (colonIdx > 0) {
+ keys.add(trimmed.substring(0, colonIdx).trim());
+ }
+ }
+ }
+ return keys;
+ }
+
+ record YamlUriContext(boolean consumer, String prefix) {
+ }
+
+ YamlUriContext findUriContext(int row) {
+ String lineText = editState.getLine(row);
+ String trimmed = lineText.trim();
+ if (trimmed.startsWith("- ")) {
+ trimmed = trimmed.substring(2).trim();
+ }
+
+ // Check if cursor is on a "uri:" line (possibly with partial value)
+ if (trimmed.startsWith("uri:")) {
+ String value = trimmed.substring(4).trim();
+ if (value.startsWith("\"") || value.startsWith("'")) {
+ value = value.substring(1);
+ }
+ if (value.endsWith("\"") || value.endsWith("'")) {
+ value = value.substring(0, value.length() - 1);
+ }
+ // if value already contains a colon, scheme is already typed
+ if (value.contains(":")) {
+ return null;
+ }
+ // walk up to find the parent EIP
+ int indent = countLeadingSpaces(lineText);
+ for (int i = row - 1; i >= 0; i--) {
+ String prev = editState.getLine(i);
+ if (prev.isBlank()) {
+ continue;
+ }
+ int prevIndent = countLeadingSpaces(prev);
+ if (prevIndent < indent) {
+ String eipName = extractEipName(prev.trim());
+ if (eipName != null) {
+ boolean consumer = CONSUMER_EIPS.contains(eipName);
+ return new YamlUriContext(consumer, value);
+ }
+ break;
+ }
+ }
+ return null;
+ }
+
+ // Check if cursor is on an inline EIP line: "to: " or "from: kafka"
(no colon in value)
+ int colonIdx = trimmed.indexOf(':');
+ if (colonIdx > 0) {
+ String eipName = trimmed.substring(0, colonIdx).trim();
+ if (CONSUMER_EIPS.contains(eipName) ||
PRODUCER_EIPS.contains(eipName)) {
+ String value = trimmed.substring(colonIdx + 1).trim();
+ if (value.startsWith("\"") || value.startsWith("'")) {
+ value = value.substring(1);
+ }
+ if (value.endsWith("\"") || value.endsWith("'")) {
+ value = value.substring(0, value.length() - 1);
+ }
+ if (value.contains(":")) {
+ return null;
+ }
+ boolean consumer = CONSUMER_EIPS.contains(eipName);
+ return new YamlUriContext(consumer, value);
+ }
+ }
+ return null;
+ }
+
private static int countLeadingSpaces(String line) {
int count = 0;
for (int i = 0; i < line.length(); i++) {
@@ -756,6 +892,19 @@ class SourceViewer {
trimmed = trimmed.substring(2).trim();
}
+ // try component name completion on uri: lines first
+ YamlUriContext uriCtx = findUriContext(row);
+ if (uriCtx != null && autocompleteProvider != null) {
+ String role = uriCtx.consumer() ? "consumer" : "producer";
+ String context = "yaml-uri:" + role;
+ List<AutocompletePopup.CompletionItem> items =
autocompleteProvider.provide(context);
+ if (items != null && !items.isEmpty()) {
+ autocompletePopup = new AutocompletePopup(items,
uriCtx.prefix(), uriCtx.prefix(), true);
+ autocompletePopup.setTitlePrefix("Components");
+ }
+ return;
+ }
+
YamlEndpointContext ctx = findEnclosingComponent(row);
if (ctx == null) {
return;
@@ -783,10 +932,15 @@ class SourceViewer {
// key completion — cursor is on an empty or partial key line
String filter = colonIdx > 0 ? trimmed.substring(0,
colonIdx).trim() : trimmed;
String role = ctx.consumer() ? "consumer" : "producer";
+ java.util.Set<String> existing = collectExistingParameters(row);
String context = "yaml:" + ctx.component() + ":" + role;
+ if (!existing.isEmpty()) {
+ context += ":" + String.join(",", existing);
+ }
List<AutocompletePopup.CompletionItem> items =
autocompleteProvider.provide(context);
if (items != null && !items.isEmpty()) {
autocompletePopup = new AutocompletePopup(items, filter,
filter);
+ autocompletePopup.setTitlePrefix(ctx.component() + " options");
}
}
}
@@ -861,6 +1015,18 @@ class SourceViewer {
} else {
editState.insert(indentStr + item.key());
}
+ // for component names, add parameters: block if not already
present
+ if ("component".equals(item.type())) {
+ int nextRow = editState.cursorRow() + 1;
+ boolean hasParameters = nextRow < editState.lineCount()
+ &&
editState.getLine(nextRow).trim().startsWith("parameters:");
+ if (!hasParameters) {
+ editState.insert('\n');
+ editState.insert(indentStr + "parameters:");
+ editState.insert('\n');
+ editState.insert(indentStr + " ");
+ }
+ }
} else {
editState.insert(indentStr + item.key() + ": ");
}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AutocompletePopupTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AutocompletePopupTest.java
index 6ecbd3484153..20e1324c5831 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AutocompletePopupTest.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AutocompletePopupTest.java
@@ -68,17 +68,15 @@ class AutocompletePopupTest {
}
@Test
- void typingNonMatchingClosesOnBackspace() {
+ void typingNonMatchingKeepsExistingItems() {
var items = List.of(
new AutocompletePopup.CompletionItem("alpha", "desc",
"string", null, false, null, null));
var popup = new AutocompletePopup(items, "", "");
+ // typing 'z' should be rejected — no items start with 'z'
popup.handleKeyEvent(KeyEvent.ofChar('z', KeyModifiers.NONE));
- assertThat(popup.hasItems()).isFalse();
-
- assertThat(popup.handleKeyEvent(KeyEvent.ofKey(KeyCode.BACKSPACE,
KeyModifiers.NONE)))
- .isEqualTo(AutocompletePopup.Result.CONSUMED);
assertThat(popup.hasItems()).isTrue();
+ assertThat(popup.hasFilter()).isFalse();
}
@Test
@@ -123,8 +121,14 @@ class AutocompletePopupTest {
var popup = new AutocompletePopup(items, "", "");
assertThat(popup.hasItems()).isTrue();
+ // typing 'z' is rejected since no items match — list stays populated
popup.handleKeyEvent(KeyEvent.ofChar('z', KeyModifiers.NONE));
- assertThat(popup.hasItems()).isFalse();
+ assertThat(popup.hasItems()).isTrue();
+
+ // typing 'o' matches "one" — filter is now active
+ popup.handleKeyEvent(KeyEvent.ofChar('o', KeyModifiers.NONE));
+ assertThat(popup.hasItems()).isTrue();
+ assertThat(popup.hasFilter()).isTrue();
}
@Test
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/YamlCompletionTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/YamlCompletionTest.java
index d68d508242e0..426700c51514 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/YamlCompletionTest.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/YamlCompletionTest.java
@@ -22,6 +22,7 @@ import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
+import java.util.Set;
import org.apache.camel.catalog.CamelCatalog;
import org.apache.camel.catalog.DefaultCamelCatalog;
@@ -254,11 +255,11 @@ class YamlCompletionTest {
}
@Test
- void keyCompletionExcludesPathOptions() {
+ void keyCompletionIncludesPathOptions() {
List<AutocompletePopup.CompletionItem> items =
provideKeyCompletions("kafka", "producer");
- // "topic" is a path option in kafka, should NOT appear in parameters
completion
- assertThat(items).noneMatch(i -> i.key().equals("topic"));
+ // "topic" is a path option in kafka, should appear in completion
+ assertThat(items).anyMatch(i -> i.key().equals("topic"));
}
@Test
@@ -387,28 +388,338 @@ class YamlCompletionTest {
assertThat(items).isEmpty();
}
+ // --- URI context detection (component name completion) ---
+
+ @Test
+ void findUriContextOnEmptyUriLine() throws IOException {
+ String yaml = String.join("\n",
+ "- from:",
+ " uri: ",
+ "");
+
+ Path file = tempDir.resolve("route.camel.yaml");
+ Files.writeString(file, yaml);
+
+ SourceViewer viewer = new SourceViewer();
+ viewer.loadFile(file);
+ viewer.enterEditMode();
+
+ SourceViewer.YamlUriContext ctx = viewer.findUriContext(1);
+ assertThat(ctx).isNotNull();
+ assertThat(ctx.consumer()).isTrue();
+ assertThat(ctx.prefix()).isEmpty();
+ }
+
+ @Test
+ void findUriContextOnToUri() throws IOException {
+ String yaml = String.join("\n",
+ "- from:",
+ " uri: timer:tick",
+ " steps:",
+ " - to:",
+ " uri: ",
+ "");
+
+ Path file = tempDir.resolve("route.camel.yaml");
+ Files.writeString(file, yaml);
+
+ SourceViewer viewer = new SourceViewer();
+ viewer.loadFile(file);
+ viewer.enterEditMode();
+
+ SourceViewer.YamlUriContext ctx = viewer.findUriContext(4);
+ assertThat(ctx).isNotNull();
+ assertThat(ctx.consumer()).isFalse();
+ assertThat(ctx.prefix()).isEmpty();
+ }
+
+ @Test
+ void findUriContextWithPartialPrefix() throws IOException {
+ String yaml = String.join("\n",
+ "- from:",
+ " uri: ka",
+ "");
+
+ Path file = tempDir.resolve("route.camel.yaml");
+ Files.writeString(file, yaml);
+
+ SourceViewer viewer = new SourceViewer();
+ viewer.loadFile(file);
+ viewer.enterEditMode();
+
+ SourceViewer.YamlUriContext ctx = viewer.findUriContext(1);
+ assertThat(ctx).isNotNull();
+ assertThat(ctx.consumer()).isTrue();
+ assertThat(ctx.prefix()).isEqualTo("ka");
+ }
+
+ @Test
+ void findUriContextReturnsNullWhenSchemeAlreadyComplete() throws
IOException {
+ String yaml = String.join("\n",
+ "- from:",
+ " uri: timer:tick",
+ "");
+
+ Path file = tempDir.resolve("route.camel.yaml");
+ Files.writeString(file, yaml);
+
+ SourceViewer viewer = new SourceViewer();
+ viewer.loadFile(file);
+ viewer.enterEditMode();
+
+ // scheme already has a colon — no component completion needed
+ SourceViewer.YamlUriContext ctx = viewer.findUriContext(1);
+ assertThat(ctx).isNull();
+ }
+
+ @Test
+ void findUriContextOnInlineEip() throws IOException {
+ String yaml = String.join("\n",
+ "- from:",
+ " uri: timer:tick",
+ " steps:",
+ " - to: ",
+ "");
+
+ Path file = tempDir.resolve("route.camel.yaml");
+ Files.writeString(file, yaml);
+
+ SourceViewer viewer = new SourceViewer();
+ viewer.loadFile(file);
+ viewer.enterEditMode();
+
+ SourceViewer.YamlUriContext ctx = viewer.findUriContext(3);
+ assertThat(ctx).isNotNull();
+ assertThat(ctx.consumer()).isFalse();
+ }
+
+ @Test
+ void findUriContextOnPollEnrich() throws IOException {
+ String yaml = String.join("\n",
+ "- from:",
+ " uri: timer:tick",
+ " steps:",
+ " - pollEnrich:",
+ " uri: ",
+ "");
+
+ Path file = tempDir.resolve("route.camel.yaml");
+ Files.writeString(file, yaml);
+
+ SourceViewer viewer = new SourceViewer();
+ viewer.loadFile(file);
+ viewer.enterEditMode();
+
+ SourceViewer.YamlUriContext ctx = viewer.findUriContext(4);
+ assertThat(ctx).isNotNull();
+ assertThat(ctx.consumer()).isTrue();
+ }
+
+ // --- Component name completion (from vs to filtering) ---
+
+ @Test
+ void componentCompletionForFromExcludesProducerOnly() {
+ List<AutocompletePopup.CompletionItem> items =
provideComponentCompletions("consumer");
+
+ // timer is consumer-only — should be in the list
+ assertThat(items).anyMatch(i -> i.key().equals("timer"));
+ // log is producer-only — should NOT be in the list
+ assertThat(items).noneMatch(i -> i.key().equals("log"));
+ }
+
+ @Test
+ void componentCompletionForToExcludesConsumerOnly() {
+ List<AutocompletePopup.CompletionItem> items =
provideComponentCompletions("producer");
+
+ // log is producer-only — should be in the list
+ assertThat(items).anyMatch(i -> i.key().equals("log"));
+ // timer is consumer-only — should NOT be in the list
+ assertThat(items).noneMatch(i -> i.key().equals("timer"));
+ }
+
+ @Test
+ void componentCompletionIncludesBothRoles() {
+ List<AutocompletePopup.CompletionItem> consumerItems =
provideComponentCompletions("consumer");
+ List<AutocompletePopup.CompletionItem> producerItems =
provideComponentCompletions("producer");
+
+ // kafka supports both — should be in both lists
+ assertThat(consumerItems).anyMatch(i -> i.key().equals("kafka"));
+ assertThat(producerItems).anyMatch(i -> i.key().equals("kafka"));
+ }
+
+ @Test
+ void componentCompletionHasDescriptions() {
+ List<AutocompletePopup.CompletionItem> items =
provideComponentCompletions("producer");
+
+ var kafka = items.stream().filter(i ->
i.key().equals("kafka")).findFirst();
+ assertThat(kafka).isPresent();
+ assertThat(kafka.get().description()).isNotNull().isNotEmpty();
+ // type shows first label (e.g. "messaging") instead of generic
"component"
+ assertThat(kafka.get().type()).isEqualTo("messaging");
+ }
+
+ @Test
+ void componentCompletionFilterMatchesLabels() {
+ List<AutocompletePopup.CompletionItem> items =
provideComponentCompletions("producer");
+
+ // simulate typing "cloud" — should match components labeled "cloud"
+ var popup = new AutocompletePopup(items, "", "");
+ for (char c : "cloud".toCharArray()) {
+ popup.handleKeyEvent(dev.tamboui.tui.event.KeyEvent.ofChar(c,
dev.tamboui.tui.event.KeyModifiers.NONE));
+ }
+ assertThat(popup.hasItems()).isTrue();
+ }
+
+ // --- Required options ---
+
+ @Test
+ void requiredOptionsAreSortedFirst() {
+ List<AutocompletePopup.CompletionItem> items =
provideKeyCompletions("jms", "producer");
+
+ // find first required and first non-required
+ int firstRequired = -1;
+ int lastRequired = -1;
+ int firstNonRequired = -1;
+ for (int i = 0; i < items.size(); i++) {
+ if (items.get(i).required()) {
+ if (firstRequired < 0) {
+ firstRequired = i;
+ }
+ lastRequired = i;
+ } else if (!items.get(i).deprecated()) {
+ if (firstNonRequired < 0) {
+ firstNonRequired = i;
+ }
+ }
+ }
+ if (firstRequired >= 0 && firstNonRequired >= 0) {
+ assertThat(lastRequired).isLessThan(firstNonRequired);
+ }
+ }
+
+ @Test
+ void jmsDestinationNameIncluded() {
+ List<AutocompletePopup.CompletionItem> items =
provideKeyCompletions("jms", "producer");
+
+ // destinationName is a path option — should be included
+ assertThat(items).anyMatch(i -> i.key().equals("destinationName"));
+ }
+
+ // --- Existing parameters filtering ---
+
+ @Test
+ void collectExistingParametersFindsKeys() throws IOException {
+ String yaml = String.join("\n",
+ "- from:",
+ " uri: kafka",
+ " parameters:",
+ " brokers: localhost",
+ " topic: orders",
+ " ",
+ "");
+
+ Path file = tempDir.resolve("route.camel.yaml");
+ Files.writeString(file, yaml);
+
+ SourceViewer viewer = new SourceViewer();
+ viewer.loadFile(file);
+ viewer.enterEditMode();
+
+ // cursor on blank line (line 5) inside parameters
+ Set<String> existing = viewer.collectExistingParameters(5);
+ assertThat(existing).containsExactlyInAnyOrder("brokers", "topic");
+ }
+
+ @Test
+ void collectExistingParametersOnBlankLineAfterParametersHeader() throws
IOException {
+ String yaml = String.join("\n",
+ "- from:",
+ " uri: kafka",
+ " parameters:",
+ " ",
+ " brokers: localhost",
+ "");
+
+ Path file = tempDir.resolve("route.camel.yaml");
+ Files.writeString(file, yaml);
+
+ SourceViewer viewer = new SourceViewer();
+ viewer.loadFile(file);
+ viewer.enterEditMode();
+
+ // cursor on blank line right after parameters: (line 3)
+ Set<String> existing = viewer.collectExistingParameters(3);
+ assertThat(existing).contains("brokers");
+ }
+
+ @Test
+ void existingParametersFilteredFromCompletions() {
+ Set<String> existing = Set.of("brokers", "topic");
+ List<AutocompletePopup.CompletionItem> items =
provideKeyCompletions("kafka", "producer", existing);
+
+ assertThat(items).noneMatch(i -> i.key().equals("brokers"));
+ assertThat(items).noneMatch(i -> i.key().equals("topic"));
+ // other options should still be present
+ assertThat(items).isNotEmpty();
+ }
+
// --- Helpers that replicate SourceTab logic for testing ---
private List<AutocompletePopup.CompletionItem>
provideKeyCompletions(String componentName, String role) {
+ return provideKeyCompletions(componentName, role, Set.of());
+ }
+
+ private List<AutocompletePopup.CompletionItem> provideKeyCompletions(
+ String componentName, String role, Set<String> existingKeys) {
ComponentModel model = catalog.componentModel(componentName);
if (model == null) {
return List.of();
}
boolean isConsumer = "consumer".equals(role);
List<AutocompletePopup.CompletionItem> items = new ArrayList<>();
- for (ComponentModel.EndpointOptionModel opt :
model.getEndpointParameterOptions()) {
+ for (ComponentModel.EndpointOptionModel opt :
model.getEndpointOptions()) {
if (includeEndpointOption(opt, isConsumer)) {
- items.add(new AutocompletePopup.CompletionItem(
- opt.getName(), opt.getDescription(), opt.getType(),
- opt.getDefaultValue(), opt.isDeprecated(),
opt.getDeprecationNote(),
- opt.getGroup()));
+ if (!existingKeys.contains(opt.getName()) ||
opt.isMultiValue()) {
+ items.add(new AutocompletePopup.CompletionItem(
+ opt.getName(), opt.getDescription(), opt.getType(),
+ opt.getDefaultValue(), opt.isDeprecated(),
opt.getDeprecationNote(),
+ opt.getGroup(), opt.isRequired()));
+ }
}
}
items.sort(Comparator.comparing(AutocompletePopup.CompletionItem::deprecated)
+ .thenComparing(ci -> !ci.required())
.thenComparing(AutocompletePopup.CompletionItem::key,
String.CASE_INSENSITIVE_ORDER));
return items;
}
+ private List<AutocompletePopup.CompletionItem>
provideComponentCompletions(String role) {
+ boolean isConsumer = "consumer".equals(role);
+ List<AutocompletePopup.CompletionItem> items = new ArrayList<>();
+ for (String name : catalog.findComponentNames()) {
+ ComponentModel model = catalog.componentModel(name);
+ if (model == null) {
+ continue;
+ }
+ if (isConsumer && model.isProducerOnly()) {
+ continue;
+ }
+ if (!isConsumer && model.isConsumerOnly()) {
+ continue;
+ }
+ String labels = model.getLabel();
+ String firstLabel = labels != null && !labels.isEmpty()
+ ? labels.split(",")[0].trim()
+ : "component";
+ items.add(new AutocompletePopup.CompletionItem(
+ model.getScheme(), model.getDescription(), firstLabel,
+ null, model.isDeprecated(), model.getDeprecationNote(),
+ labels));
+ }
+ items.sort(Comparator.comparing(AutocompletePopup.CompletionItem::key,
String.CASE_INSENSITIVE_ORDER));
+ return items;
+ }
+
private static boolean
includeEndpointOption(ComponentModel.EndpointOptionModel opt, boolean
isConsumer) {
String label = opt.getLabel();
if (label == null || label.isEmpty()) {