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 6422a66f0615 camel-jbang - TUI tab completion for EIP options in YAML
editor
6422a66f0615 is described below
commit 6422a66f06150216081b7a1ff5af0553a4e97193
Author: Claus Ibsen <[email protected]>
AuthorDate: Tue Aug 4 15:01:32 2026 +0200
camel-jbang - TUI tab completion for EIP options in YAML editor
camel-jbang - TUI fix EIP tab completion on empty lines
camel-jbang - TUI fix EIP completion indent on empty lines
camel-jbang - TUI scope line highlight in YAML editor
camel-jbang - TUI fix scope highlight to use bold and fix blank-line indent
camel-jbang - TUI auto-indent on Enter in YAML editor
camel-jbang - TUI filter incompatible placeholders from enum/boolean value
completions and add e shortcut in Files tab
Co-Authored-By: Claude Opus 4.6 <[email protected]>
Signed-off-by: Claus Ibsen <[email protected]>
---
.../dsl/jbang/core/commands/tui/SourceTab.java | 138 +++++-
.../dsl/jbang/core/commands/tui/SourceViewer.java | 399 ++++++++++++++++--
.../core/commands/tui/YamlCompletionTest.java | 469 +++++++++++++++++++++
3 files changed, 966 insertions(+), 40 deletions(-)
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 584a97808d38..f0e9872cdb65 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
@@ -63,6 +63,7 @@ import org.apache.camel.dsl.jbang.core.common.CatalogLoader;
import org.apache.camel.tooling.model.BaseOptionModel;
import org.apache.camel.tooling.model.ComponentModel;
import org.apache.camel.tooling.model.DataFormatModel;
+import org.apache.camel.tooling.model.EipModel;
import org.apache.camel.tooling.model.LanguageModel;
import org.apache.camel.tooling.model.MainModel;
import org.apache.camel.util.json.JsonArray;
@@ -323,6 +324,7 @@ class SourceTab extends AbstractTab {
## File List (left panel)
- **Up/Down** — navigate files
- **Enter** — open file or directory
+ - **e** — open file directly in edit mode
- **Backspace** — go to parent directory
## Source Viewer (right panel)
@@ -355,6 +357,9 @@ class SourceTab extends AbstractTab {
- Inside `parameters:` blocks, key completion shows endpoint
options from the
Camel catalog, filtered by consumer/producer role. Required
options appear
first (marked with `*`). Already-specified options are
excluded.
+ - Inside EIP blocks (e.g. `split:`, `aggregate:`, `filter:`),
Tab shows
+ the EIP's configurable options (attribute-type only,
excluding structural
+ elements like `steps:` and `expression:`).
- Value completion shows enum choices, boolean values, and
`{{placeholder}}`
suggestions from your `.properties` files
@@ -517,6 +522,13 @@ class SourceTab extends AbstractTab {
openSelectedEntry();
return true;
}
+ if (ke.isChar('e')) {
+ openSelectedEntry();
+ if (sourceViewer.isVisible() && sourceViewer.isEditable()) {
+ sourceViewer.enterEditMode();
+ }
+ return true;
+ }
return false;
}
@@ -933,6 +945,11 @@ class SourceTab extends AbstractTab {
return provideComponentNameCompletions(context.substring(9));
}
+ // EIP option completion
+ if (context.startsWith("yaml-eip:")) {
+ return provideEipKeyCompletions(context.substring(9));
+ }
+
if (!context.startsWith("yaml:")) {
return List.of();
}
@@ -1042,6 +1059,51 @@ class SourceTab extends AbstractTab {
return items;
}
+ private static final Set<String> EIP_BOILERPLATE = Set.of("id", "note",
"description", "disabled");
+
+ private List<AutocompletePopup.CompletionItem>
provideEipKeyCompletions(String contextAfterPrefix) {
+ CamelCatalog catalog = getCatalog();
+ if (catalog == null) {
+ return List.of();
+ }
+
+ // context format: "eipName" or "eipName:existingKey1,existingKey2,..."
+ String[] parts = contextAfterPrefix.split(":", 2);
+ String eipName = parts[0];
+
+ Set<String> existingKeys = Set.of();
+ if (parts.length > 1 && !parts[1].isEmpty()) {
+ existingKeys = new HashSet<>(Arrays.asList(parts[1].split(",")));
+ }
+
+ EipModel model = catalog.eipModel(eipName);
+ if (model == null) {
+ return List.of();
+ }
+
+ List<AutocompletePopup.CompletionItem> items = new ArrayList<>();
+ for (EipModel.EipOptionModel opt : model.getOptions()) {
+ if (!"attribute".equals(opt.getKind())) {
+ continue;
+ }
+ if (EIP_BOILERPLATE.contains(opt.getName())) {
+ continue;
+ }
+ if (existingKeys.contains(opt.getName()) && !opt.isMultiValue()) {
+ 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 static boolean
includeEndpointOption(ComponentModel.EndpointOptionModel opt, boolean
isConsumer) {
String label = opt.getLabel();
if (label == null || label.isEmpty()) {
@@ -1058,7 +1120,16 @@ class SourceTab extends AbstractTab {
}
private List<AutocompletePopup.CompletionItem>
provideYamlValueCompletions(String context) {
- if (context == null || !context.startsWith("yaml:")) {
+ if (context == null) {
+ return List.of();
+ }
+
+ // EIP value completion
+ if (context.startsWith("yaml-eip-value:")) {
+ return provideEipValueCompletions(context.substring(15));
+ }
+
+ if (!context.startsWith("yaml:")) {
return List.of();
}
CamelCatalog catalog = getCatalog();
@@ -1126,6 +1197,71 @@ class SourceTab extends AbstractTab {
return items;
}
+ private List<AutocompletePopup.CompletionItem>
provideEipValueCompletions(String contextAfterPrefix) {
+ CamelCatalog catalog = getCatalog();
+ if (catalog == null) {
+ return List.of();
+ }
+
+ // context format: "eipName:optionName"
+ String[] parts = contextAfterPrefix.split(":", 2);
+ if (parts.length < 2) {
+ return List.of();
+ }
+ String eipName = parts[0];
+ String optionName = parts[1];
+
+ EipModel model = catalog.eipModel(eipName);
+ if (model == null) {
+ return loadPropertyPlaceholders();
+ }
+
+ EipModel.EipOptionModel opt = null;
+ for (EipModel.EipOptionModel o : model.getOptions()) {
+ if (o.getName().equals(optionName)) {
+ opt = o;
+ break;
+ }
+ }
+
+ List<AutocompletePopup.CompletionItem> items = new ArrayList<>();
+ java.util.function.Predicate<String> valueFilter = null;
+ if (opt != null) {
+ List<String> enums = opt.getEnums();
+ if (enums != null && !enums.isEmpty()) {
+ java.util.Set<String> validValues = new java.util.HashSet<>();
+ for (String value : enums) {
+ validValues.add(value.toLowerCase());
+ boolean isDefault =
value.equals(String.valueOf(opt.getDefaultValue()));
+ items.add(new AutocompletePopup.CompletionItem(
+ value, opt.getDescription(), opt.getType(),
+ isDefault ? value : opt.getDefaultValue(),
+ false, null, opt.getGroup()));
+ }
+ valueFilter = v -> validValues.contains(v.toLowerCase());
+ } else if ("boolean".equalsIgnoreCase(opt.getType())
+ || "java.lang.Boolean".equals(opt.getJavaType())) {
+ valueFilter = v -> "true".equalsIgnoreCase(v) ||
"false".equalsIgnoreCase(v);
+ items.add(new AutocompletePopup.CompletionItem(
+ "true", opt.getDescription(), "boolean",
opt.getDefaultValue(),
+ false, null, opt.getGroup()));
+ items.add(new AutocompletePopup.CompletionItem(
+ "false", opt.getDescription(), "boolean",
opt.getDefaultValue(),
+ false, null, opt.getGroup()));
+ } else if (isNumericType(opt.getType(), opt.getJavaType())) {
+ valueFilter = SourceTab::isNumericValue;
+ }
+ }
+
+ // only include placeholders whose actual value is compatible with the
option type
+ for (AutocompletePopup.CompletionItem ph : loadPropertyPlaceholders())
{
+ if (valueFilter == null || (ph.description() != null &&
valueFilter.test(ph.description()))) {
+ items.add(ph);
+ }
+ }
+ return items;
+ }
+
// ---- Property placeholder loading ----
private List<AutocompletePopup.CompletionItem> placeholderCache;
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 0a39599c2851..d9ce3d7d766e 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
@@ -432,7 +432,13 @@ class SourceViewer {
return true;
}
if (ke.isConfirm()) {
+ int prevRow = editState.cursorRow();
+ String prevLine = editState.getLine(prevRow);
+ int indent = countLeadingSpaces(prevLine);
editState.insert('\n');
+ if (indent > 0) {
+ editState.insert(" ".repeat(indent));
+ }
dirty = true;
return true;
}
@@ -762,6 +768,258 @@ class SourceViewer {
return null;
}
+ record YamlEipContext(String eipName) {
+ }
+
+ private static final java.util.Set<String> STRUCTURAL_KEYS
+ = java.util.Set.of("steps", "uri", "parameters", "from", "route",
"routeConfiguration",
+ "routeTemplate", "templatedRoute", "rest", "beans");
+
+ YamlEipContext findEnclosingEip(int fromRow) {
+ String cursorLine = editState.getLine(fromRow);
+ int cursorIndent = countLeadingSpaces(cursorLine);
+
+ // blank lines: derive indent from context
+ if (cursorLine.isBlank()) {
+ if (cursorIndent == 0) {
+ // truly empty line — look at successor first, then predecessor
+ int succIndent = -1;
+ int lineCount = editState.lineCount();
+ for (int i = fromRow + 1; i < lineCount; i++) {
+ String next = editState.getLine(i);
+ if (!next.isBlank()) {
+ succIndent = countLeadingSpaces(next);
+ break;
+ }
+ }
+ if (succIndent > 0) {
+ cursorIndent = succIndent;
+ } else {
+ for (int i = fromRow - 1; i >= 0; i--) {
+ String prev = editState.getLine(i);
+ if (!prev.isBlank()) {
+ // treat cursor as sibling of predecessor
+ cursorIndent = countLeadingSpaces(prev);
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ // if cursor is inside a parameters: block, defer to component
completion
+ for (int i = fromRow; i >= 0; i--) {
+ String line = editState.getLine(i);
+ if (line.isBlank()) {
+ continue;
+ }
+ int indent = countLeadingSpaces(line);
+ String trimmed = line.trim();
+ if (trimmed.startsWith("parameters:") && indent < cursorIndent) {
+ return null;
+ }
+ if (i < fromRow && indent < cursorIndent) {
+ break;
+ }
+ }
+
+ // walk up to find the parent EIP
+ for (int i = fromRow; i >= 0; i--) {
+ String line = editState.getLine(i);
+ if (line.isBlank()) {
+ continue;
+ }
+ int indent = countLeadingSpaces(line);
+ if (indent < cursorIndent) {
+ String eipName = extractEipName(line.trim());
+ if (eipName != null && !STRUCTURAL_KEYS.contains(eipName)) {
+ String camelName = dashToCamelCase(eipName);
+ return new YamlEipContext(camelName);
+ }
+ // keep walking up if we hit a structural key
+ cursorIndent = indent;
+ }
+ }
+ return null;
+ }
+
+ java.util.Set<String> collectExistingSiblingKeys(int fromRow) {
+ java.util.Set<String> keys = new java.util.LinkedHashSet<>();
+ String cursorLine = editState.getLine(fromRow);
+ int cursorIndent = countLeadingSpaces(cursorLine);
+
+ // for blank lines, derive indent from nearest non-blank sibling
+ if (cursorLine.isBlank()) {
+ for (int i = fromRow - 1; i >= 0; i--) {
+ String prev = editState.getLine(i);
+ if (!prev.isBlank()) {
+ cursorIndent = countLeadingSpaces(prev);
+ break;
+ }
+ }
+ }
+
+ // scan upward for siblings at same indent
+ for (int i = fromRow - 1; i >= 0; i--) {
+ String line = editState.getLine(i);
+ if (line.isBlank()) {
+ continue;
+ }
+ int indent = countLeadingSpaces(line);
+ if (indent < cursorIndent) {
+ break;
+ }
+ if (indent == cursorIndent) {
+ String trimmed = line.trim();
+ int colonIdx = trimmed.indexOf(':');
+ if (colonIdx > 0) {
+ keys.add(trimmed.substring(0, colonIdx).trim());
+ }
+ }
+ }
+ // scan downward for siblings at same indent
+ int lineCount = editState.lineCount();
+ for (int i = fromRow + 1; i < lineCount; i++) {
+ String line = editState.getLine(i);
+ if (line.isBlank()) {
+ continue;
+ }
+ int indent = countLeadingSpaces(line);
+ if (indent < cursorIndent) {
+ break;
+ }
+ if (indent == cursorIndent) {
+ String trimmed = line.trim();
+ int colonIdx = trimmed.indexOf(':');
+ if (colonIdx > 0) {
+ keys.add(trimmed.substring(0, colonIdx).trim());
+ }
+ }
+ }
+ return keys;
+ }
+
+ static String dashToCamelCase(String text) {
+ if (text == null || !text.contains("-")) {
+ return text;
+ }
+ StringBuilder sb = new StringBuilder(text.length());
+ boolean upper = false;
+ for (int i = 0; i < text.length(); i++) {
+ char c = text.charAt(i);
+ if (c == '-') {
+ upper = true;
+ } else {
+ sb.append(upper ? Character.toUpperCase(c) : c);
+ upper = false;
+ }
+ }
+ return sb.toString();
+ }
+
+ int findScopeLineRow(int cursorRow) {
+ if (cursorRow < 0 || cursorRow >= editState.lineCount()) {
+ return -1;
+ }
+ String cursorLine = editState.getLine(cursorRow);
+ String trimmed = cursorLine.trim();
+ if (trimmed.startsWith("- ")) {
+ trimmed = trimmed.substring(2).trim();
+ }
+
+ // if cursor is on a uri: line, scope is this row
+ if (trimmed.startsWith("uri:")) {
+ return cursorRow;
+ }
+ int colonIdx = trimmed.indexOf(':');
+ if (colonIdx > 0) {
+ String key = trimmed.substring(0, colonIdx).trim();
+ // inline producer/consumer EIP (to:, enrich:) — exclude
structural keys like from:
+ if (!STRUCTURAL_KEYS.contains(key)
+ && (CONSUMER_EIPS.contains(key) ||
PRODUCER_EIPS.contains(key))) {
+ return cursorRow;
+ }
+ // EIP definition line in a list (e.g., "- split:", "- log:")
+ if (!STRUCTURAL_KEYS.contains(key) &&
cursorLine.trim().startsWith("- ")) {
+ return cursorRow;
+ }
+ }
+
+ int cursorIndent = countLeadingSpaces(cursorLine);
+
+ // for blank lines, derive indent from context
+ if (cursorLine.isBlank()) {
+ if (cursorIndent == 0) {
+ int lineCount = editState.lineCount();
+ for (int i = cursorRow + 1; i < lineCount; i++) {
+ String next = editState.getLine(i);
+ if (!next.isBlank()) {
+ cursorIndent = countLeadingSpaces(next);
+ break;
+ }
+ }
+ if (cursorIndent == 0) {
+ for (int i = cursorRow - 1; i >= 0; i--) {
+ String prev = editState.getLine(i);
+ if (!prev.isBlank()) {
+ cursorIndent = countLeadingSpaces(prev);
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ // walk up looking for the scope line
+ int parametersRow = -1;
+ int parametersIndent = -1;
+ for (int i = cursorRow; i >= 0; i--) {
+ String line = editState.getLine(i);
+ if (line.isBlank()) {
+ continue;
+ }
+ int indent = countLeadingSpaces(line);
+ String t = line.trim();
+
+ if (t.startsWith("parameters:") && indent < cursorIndent) {
+ parametersRow = i;
+ parametersIndent = indent;
+ break;
+ }
+ if (i < cursorRow && indent < cursorIndent) {
+ String eipName = extractEipName(t);
+ if (eipName != null && !STRUCTURAL_KEYS.contains(eipName)) {
+ return i;
+ }
+ cursorIndent = indent;
+ }
+ }
+
+ // inside parameters: block — find the uri: line at the same indent
+ if (parametersRow >= 0) {
+ for (int i = parametersRow - 1; i >= 0; i--) {
+ String line = editState.getLine(i);
+ if (line.isBlank()) {
+ continue;
+ }
+ int indent = countLeadingSpaces(line);
+ String t = line.trim();
+ if (indent == parametersIndent && (t.startsWith("uri:") ||
t.startsWith("- uri:"))) {
+ return i;
+ }
+ if (indent < parametersIndent) {
+ // check for inline uri on the EIP line itself
+ String eipName = extractEipName(t);
+ if (eipName != null && (CONSUMER_EIPS.contains(eipName) ||
PRODUCER_EIPS.contains(eipName))) {
+ return i;
+ }
+ break;
+ }
+ }
+ }
+ return -1;
+ }
+
private static int countLeadingSpaces(String line) {
int count = 0;
for (int i = 0; i < line.length(); i++) {
@@ -906,41 +1164,77 @@ class SourceViewer {
}
YamlEndpointContext ctx = findEnclosingComponent(row);
- if (ctx == null) {
+ if (ctx != null) {
+ int colonIdx = trimmed.indexOf(':');
+ if (colonIdx > 0) {
+ // value completion — cursor is on a line with key: or key:
value
+ String optionName = trimmed.substring(0, colonIdx).trim();
+ String valueText = trimmed.substring(colonIdx + 1).trim();
+ if (valueText.startsWith("\"") || valueText.startsWith("'")) {
+ valueText = valueText.substring(1);
+ }
+ if (valueText.endsWith("\"") || valueText.endsWith("'")) {
+ valueText = valueText.substring(0, valueText.length() - 1);
+ }
+ if (autocompleteValueProvider != null) {
+ String context = "yaml:" + ctx.component() + ":" +
optionName;
+ List<AutocompletePopup.CompletionItem> values =
autocompleteValueProvider.provide(context);
+ if (values != null && !values.isEmpty()) {
+ autocompletePopup = new AutocompletePopup(values, "",
valueText, true);
+ }
+ }
+ } else {
+ // 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");
+ }
+ }
return;
}
- int colonIdx = trimmed.indexOf(':');
- if (colonIdx > 0) {
- // value completion — cursor is on a line with key: or key: value
- String optionName = trimmed.substring(0, colonIdx).trim();
- String valueText = trimmed.substring(colonIdx + 1).trim();
- if (valueText.startsWith("\"") || valueText.startsWith("'")) {
- valueText = valueText.substring(1);
- }
- if (valueText.endsWith("\"") || valueText.endsWith("'")) {
- valueText = valueText.substring(0, valueText.length() - 1);
- }
- if (autocompleteValueProvider != null) {
- String context = "yaml:" + ctx.component() + ":" + optionName;
- List<AutocompletePopup.CompletionItem> values =
autocompleteValueProvider.provide(context);
- if (values != null && !values.isEmpty()) {
- autocompletePopup = new AutocompletePopup(values, "",
valueText, true);
+ // EIP option completion — cursor is inside an EIP block (not in
parameters:)
+ YamlEipContext eipCtx = findEnclosingEip(row);
+ if (eipCtx != null && autocompleteProvider != null) {
+ int colonIdx = trimmed.indexOf(':');
+ if (colonIdx > 0) {
+ // value completion for EIP option
+ String optionName = trimmed.substring(0, colonIdx).trim();
+ String valueText = trimmed.substring(colonIdx + 1).trim();
+ if (valueText.startsWith("\"") || valueText.startsWith("'")) {
+ valueText = valueText.substring(1);
+ }
+ if (valueText.endsWith("\"") || valueText.endsWith("'")) {
+ valueText = valueText.substring(0, valueText.length() - 1);
+ }
+ if (autocompleteValueProvider != null) {
+ String context = "yaml-eip-value:" + eipCtx.eipName() +
":" + optionName;
+ List<AutocompletePopup.CompletionItem> values =
autocompleteValueProvider.provide(context);
+ if (values != null && !values.isEmpty()) {
+ autocompletePopup = new AutocompletePopup(values, "",
valueText, true);
+ }
+ }
+ } else {
+ // key completion for EIP options
+ String filter = trimmed;
+ java.util.Set<String> existing =
collectExistingSiblingKeys(row);
+ String context = "yaml-eip:" + eipCtx.eipName();
+ 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(eipCtx.eipName() + "
options");
}
- }
- } else {
- // 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");
}
}
}
@@ -982,19 +1276,33 @@ class SourceViewer {
private void insertYamlCompletion(AutocompletePopup.CompletionItem item,
boolean valueMode, String currentLine) {
int indent = countLeadingSpaces(currentLine);
- // blank lines: derive indent from the nearest preceding non-blank line
+ // blank lines: derive indent from context
if (currentLine.isBlank() && indent == 0) {
int row = editState.cursorRow();
- for (int i = row - 1; i >= 0; i--) {
- String prev = editState.getLine(i);
- if (!prev.isBlank()) {
- indent = countLeadingSpaces(prev);
- if (prev.trim().startsWith("parameters:")) {
- indent += 2;
- }
+ // look at next non-blank line first (sibling indent)
+ int lineCount = editState.lineCount();
+ for (int i = row + 1; i < lineCount; i++) {
+ String next = editState.getLine(i);
+ if (!next.isBlank()) {
+ indent = countLeadingSpaces(next);
break;
}
}
+ if (indent == 0) {
+ // no successor — derive from predecessor
+ for (int i = row - 1; i >= 0; i--) {
+ String prev = editState.getLine(i);
+ if (!prev.isBlank()) {
+ indent = countLeadingSpaces(prev);
+ String trimmed = prev.trim();
+ if (trimmed.endsWith(":")) {
+ // cursor is inside this block — indent deeper
+ indent += trimmed.startsWith("- ") ? 4 : 2;
+ }
+ break;
+ }
+ }
+ }
}
String indentStr = " ".repeat(indent);
@@ -1348,6 +1656,19 @@ class SourceViewer {
.build();
textArea.renderWithCursor(inner, frame.buffer(), editState, frame);
+ // scope line highlight — shows which EIP or uri: line the cursor
belongs to
+ if (isCamelYamlFile()) {
+ int scopeRow = findScopeLineRow(editState.cursorRow());
+ if (scopeRow >= 0 && scopeRow != editState.cursorRow()) {
+ int relativeRow = scopeRow - editState.scrollRow();
+ if (relativeRow >= 0 && relativeRow < inner.height()) {
+ int screenY = inner.top() + relativeRow;
+ Rect lineRect = new Rect(inner.left(), screenY,
inner.width(), 1);
+ frame.buffer().setStyle(lineRect, Style.EMPTY.bold());
+ }
+ }
+ }
+
if (autocompletePopup != null) {
int cursorRow = editState.cursorRow() - editState.scrollRow();
int cursorCol = editState.cursorCol() - editState.scrollCol();
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 426700c51514..4b4822cdc3ef 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
@@ -21,12 +21,14 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Comparator;
+import java.util.HashSet;
import java.util.List;
import java.util.Set;
import org.apache.camel.catalog.CamelCatalog;
import org.apache.camel.catalog.DefaultCamelCatalog;
import org.apache.camel.tooling.model.ComponentModel;
+import org.apache.camel.tooling.model.EipModel;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
@@ -663,6 +665,389 @@ class YamlCompletionTest {
assertThat(items).isNotEmpty();
}
+ // --- EIP context detection ---
+
+ @Test
+ void findEnclosingEipDetectsSplit() throws IOException {
+ String yaml = String.join("\n",
+ "- from:",
+ " uri: timer:tick",
+ " steps:",
+ " - split:",
+ " expression:",
+ " simple: \"${body}\"",
+ " ",
+ "");
+
+ Path file = tempDir.resolve("route.camel.yaml");
+ Files.writeString(file, yaml);
+
+ SourceViewer viewer = new SourceViewer();
+ viewer.loadFile(file);
+ viewer.enterEditMode();
+
+ SourceViewer.YamlEipContext ctx = viewer.findEnclosingEip(6);
+ assertThat(ctx).isNotNull();
+ assertThat(ctx.eipName()).isEqualTo("split");
+ }
+
+ @Test
+ void findEnclosingEipReturnsNullInsideParameters() 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();
+
+ SourceViewer.YamlEipContext ctx = viewer.findEnclosingEip(4);
+ assertThat(ctx).isNull();
+ }
+
+ @Test
+ void findEnclosingEipConvertsKebabCase() throws IOException {
+ String yaml = String.join("\n",
+ "- from:",
+ " uri: timer:tick",
+ " steps:",
+ " - circuit-breaker:",
+ " ",
+ "");
+
+ Path file = tempDir.resolve("route.camel.yaml");
+ Files.writeString(file, yaml);
+
+ SourceViewer viewer = new SourceViewer();
+ viewer.loadFile(file);
+ viewer.enterEditMode();
+
+ SourceViewer.YamlEipContext ctx = viewer.findEnclosingEip(4);
+ assertThat(ctx).isNotNull();
+ assertThat(ctx.eipName()).isEqualTo("circuitBreaker");
+ }
+
+ @Test
+ void findEnclosingEipSkipsStructuralKeys() throws IOException {
+ String yaml = String.join("\n",
+ "- from:",
+ " uri: timer:tick",
+ " steps:",
+ " - split:",
+ " expression:",
+ " simple: \"${body}\"",
+ " steps:",
+ " - log:",
+ " ",
+ "");
+
+ Path file = tempDir.resolve("route.camel.yaml");
+ Files.writeString(file, yaml);
+
+ SourceViewer viewer = new SourceViewer();
+ viewer.loadFile(file);
+ viewer.enterEditMode();
+
+ // cursor inside log: block (nested in split's steps)
+ SourceViewer.YamlEipContext ctx = viewer.findEnclosingEip(8);
+ assertThat(ctx).isNotNull();
+ assertThat(ctx.eipName()).isEqualTo("log");
+ }
+
+ @Test
+ void findEnclosingEipOnEmptyLineBetweenEipAndOptions() throws IOException {
+ String yaml = String.join("\n",
+ "- from:",
+ " uri: timer:tick",
+ " steps:",
+ " - log:",
+ "",
+ " message: \"${body}\"",
+ "");
+
+ Path file = tempDir.resolve("route.camel.yaml");
+ Files.writeString(file, yaml);
+
+ SourceViewer viewer = new SourceViewer();
+ viewer.loadFile(file);
+ viewer.enterEditMode();
+
+ // empty line between log: and message: should detect log as enclosing
EIP
+ SourceViewer.YamlEipContext ctx = viewer.findEnclosingEip(4);
+ assertThat(ctx).isNotNull();
+ assertThat(ctx.eipName()).isEqualTo("log");
+ }
+
+ // --- EIP option completion ---
+
+ @Test
+ void eipCompletionIncludesOnlyAttributes() {
+ List<AutocompletePopup.CompletionItem> items =
provideEipKeyCompletions("split");
+
+ // streaming is an attribute — should be included
+ assertThat(items).anyMatch(i -> i.key().equals("streaming"));
+ assertThat(items).anyMatch(i -> i.key().equals("parallelProcessing"));
+ }
+
+ @Test
+ void eipCompletionExcludesBoilerplate() {
+ List<AutocompletePopup.CompletionItem> items =
provideEipKeyCompletions("split");
+
+ assertThat(items).noneMatch(i -> i.key().equals("id"));
+ assertThat(items).noneMatch(i -> i.key().equals("note"));
+ assertThat(items).noneMatch(i -> i.key().equals("description"));
+ assertThat(items).noneMatch(i -> i.key().equals("disabled"));
+ }
+
+ @Test
+ void eipCompletionExcludesExpressionAndElement() {
+ List<AutocompletePopup.CompletionItem> items =
provideEipKeyCompletions("split");
+
+ // expression and outputs are not attribute kind
+ assertThat(items).noneMatch(i -> i.key().equals("expression"));
+ assertThat(items).noneMatch(i -> i.key().equals("outputs"));
+ }
+
+ @Test
+ void eipCompletionExcludesExistingOptions() {
+ Set<String> existing = Set.of("streaming", "delimiter");
+ List<AutocompletePopup.CompletionItem> items =
provideEipKeyCompletions("split", existing);
+
+ assertThat(items).noneMatch(i -> i.key().equals("streaming"));
+ assertThat(items).noneMatch(i -> i.key().equals("delimiter"));
+ assertThat(items).isNotEmpty();
+ }
+
+ @Test
+ void eipCompletionForLogEip() {
+ List<AutocompletePopup.CompletionItem> items =
provideEipKeyCompletions("log");
+
+ assertThat(items).anyMatch(i -> i.key().equals("message"));
+ assertThat(items).anyMatch(i -> i.key().equals("loggingLevel"));
+ assertThat(items).anyMatch(i -> i.key().equals("logName"));
+ }
+
+ @Test
+ void eipValueCompletionForEnum() {
+ List<AutocompletePopup.CompletionItem> items =
provideEipValueCompletions("log", "loggingLevel");
+
+ assertThat(items).anyMatch(i -> i.key().equals("INFO"));
+ assertThat(items).anyMatch(i -> i.key().equals("ERROR"));
+ assertThat(items).anyMatch(i -> i.key().equals("DEBUG"));
+ }
+
+ @Test
+ void eipValueCompletionForBoolean() {
+ List<AutocompletePopup.CompletionItem> items =
provideEipValueCompletions("split", "streaming");
+
+ assertThat(items).anyMatch(i -> i.key().equals("true"));
+ assertThat(items).anyMatch(i -> i.key().equals("false"));
+ }
+
+ @Test
+ void eipValueCompletionFiltersIncompatiblePlaceholders() {
+ List<AutocompletePopup.CompletionItem> placeholders = List.of(
+ new AutocompletePopup.CompletionItem(
+ "{{greeting.message}}", "Hello World", "placeholder",
+ null, false, null, "application.properties"),
+ new AutocompletePopup.CompletionItem(
+ "{{log.level}}", "WARN", "placeholder",
+ null, false, null, "application.properties"));
+
+ // enum option: only placeholders whose value matches a valid enum
choice should be included
+ List<AutocompletePopup.CompletionItem> items =
provideEipValueCompletions("log", "loggingLevel", placeholders);
+
+ assertThat(items).anyMatch(i -> i.key().equals("INFO"));
+ assertThat(items).anyMatch(i -> i.key().equals("ERROR"));
+ // {{log.level}} has value "WARN" which IS a valid enum value
+ assertThat(items).anyMatch(i -> i.key().equals("{{log.level}}"));
+ // {{greeting.message}} has value "Hello World" which is NOT a valid
enum value
+ assertThat(items).noneMatch(i ->
i.key().equals("{{greeting.message}}"));
+ }
+
+ @Test
+ void eipValueCompletionAllowsPlaceholdersForStringOptions() {
+ List<AutocompletePopup.CompletionItem> placeholders = List.of(
+ new AutocompletePopup.CompletionItem(
+ "{{greeting.message}}", "Hello World", "placeholder",
+ null, false, null, "application.properties"));
+
+ // string option (logName): no type filter, all placeholders should be
included
+ List<AutocompletePopup.CompletionItem> items =
provideEipValueCompletions("log", "logName", placeholders);
+
+ assertThat(items).anyMatch(i ->
i.key().equals("{{greeting.message}}"));
+ }
+
+ // --- collectExistingSiblingKeys ---
+
+ @Test
+ void collectExistingSiblingKeysFindsKeys() throws IOException {
+ String yaml = String.join("\n",
+ "- from:",
+ " uri: timer:tick",
+ " steps:",
+ " - split:",
+ " streaming: true",
+ " delimiter: \",\"",
+ " ",
+ "");
+
+ Path file = tempDir.resolve("route.camel.yaml");
+ Files.writeString(file, yaml);
+
+ SourceViewer viewer = new SourceViewer();
+ viewer.loadFile(file);
+ viewer.enterEditMode();
+
+ Set<String> keys = viewer.collectExistingSiblingKeys(6);
+ assertThat(keys).containsExactlyInAnyOrder("streaming", "delimiter");
+ }
+
+ // --- dashToCamelCase ---
+
+ @Test
+ void dashToCamelCaseConverts() {
+
assertThat(SourceViewer.dashToCamelCase("circuit-breaker")).isEqualTo("circuitBreaker");
+
assertThat(SourceViewer.dashToCamelCase("wire-tap")).isEqualTo("wireTap");
+ assertThat(SourceViewer.dashToCamelCase("split")).isEqualTo("split");
+ assertThat(SourceViewer.dashToCamelCase(null)).isNull();
+ }
+
+ // --- findScopeLineRow ---
+
+ @Test
+ void findScopeLineRowDetectsUriLine() throws IOException {
+ String yaml = String.join("\n",
+ "- from:",
+ " uri: timer:tick",
+ " steps:",
+ " - to:",
+ " uri: kafka",
+ "");
+
+ Path file = tempDir.resolve("route.camel.yaml");
+ Files.writeString(file, yaml);
+
+ SourceViewer viewer = new SourceViewer();
+ viewer.loadFile(file);
+ viewer.enterEditMode();
+
+ // cursor on uri: line → scope is that row
+ assertThat(viewer.findScopeLineRow(1)).isEqualTo(1);
+ assertThat(viewer.findScopeLineRow(4)).isEqualTo(4);
+ }
+
+ @Test
+ void findScopeLineRowInsideParameters() throws IOException {
+ String yaml = String.join("\n",
+ "- from:",
+ " uri: kafka",
+ " parameters:",
+ " brokers: localhost",
+ " groupId: test",
+ "");
+
+ Path file = tempDir.resolve("route.camel.yaml");
+ Files.writeString(file, yaml);
+
+ SourceViewer viewer = new SourceViewer();
+ viewer.loadFile(file);
+ viewer.enterEditMode();
+
+ // cursor inside parameters: block → scope is the uri: line
+ assertThat(viewer.findScopeLineRow(3)).isEqualTo(1);
+ assertThat(viewer.findScopeLineRow(4)).isEqualTo(1);
+ }
+
+ @Test
+ void findScopeLineRowInsideEip() throws IOException {
+ String yaml = String.join("\n",
+ "- from:",
+ " uri: timer:tick",
+ " steps:",
+ " - split:",
+ " expression:",
+ " simple: \"${body}\"",
+ " streaming: true",
+ "");
+
+ Path file = tempDir.resolve("route.camel.yaml");
+ Files.writeString(file, yaml);
+
+ SourceViewer viewer = new SourceViewer();
+ viewer.loadFile(file);
+ viewer.enterEditMode();
+
+ // cursor on streaming: → scope is split: line
+ assertThat(viewer.findScopeLineRow(6)).isEqualTo(3);
+ }
+
+ @Test
+ void findScopeLineRowOnScopeLineItself() throws IOException {
+ String yaml = String.join("\n",
+ "- from:",
+ " uri: timer:tick",
+ " steps:",
+ " - split:",
+ " streaming: true",
+ "");
+
+ Path file = tempDir.resolve("route.camel.yaml");
+ Files.writeString(file, yaml);
+
+ SourceViewer viewer = new SourceViewer();
+ viewer.loadFile(file);
+ viewer.enterEditMode();
+
+ // cursor on the split: line itself → returns that row
+ assertThat(viewer.findScopeLineRow(3)).isEqualTo(3);
+ }
+
+ @Test
+ void findScopeLineRowNoScope() 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();
+
+ // cursor on top-level from: → no parent scope
+ assertThat(viewer.findScopeLineRow(0)).isEqualTo(-1);
+ }
+
+ @Test
+ void findScopeLineRowInlineEip() throws IOException {
+ String yaml = String.join("\n",
+ "- from:",
+ " uri: timer:tick",
+ " steps:",
+ " - to: kafka:topic",
+ "");
+
+ Path file = tempDir.resolve("route.camel.yaml");
+ Files.writeString(file, yaml);
+
+ SourceViewer viewer = new SourceViewer();
+ viewer.loadFile(file);
+ viewer.enterEditMode();
+
+ // cursor on inline to: line → scope is that row
+ assertThat(viewer.findScopeLineRow(3)).isEqualTo(3);
+ }
+
// --- Helpers that replicate SourceTab logic for testing ---
private List<AutocompletePopup.CompletionItem>
provideKeyCompletions(String componentName, String role) {
@@ -773,6 +1158,90 @@ class YamlCompletionTest {
return items;
}
+ private static final Set<String> EIP_BOILERPLATE = Set.of("id", "note",
"description", "disabled");
+
+ private List<AutocompletePopup.CompletionItem>
provideEipKeyCompletions(String eipName) {
+ return provideEipKeyCompletions(eipName, Set.of());
+ }
+
+ private List<AutocompletePopup.CompletionItem>
provideEipKeyCompletions(String eipName, Set<String> existingKeys) {
+ EipModel model = catalog.eipModel(eipName);
+ if (model == null) {
+ return List.of();
+ }
+ List<AutocompletePopup.CompletionItem> items = new ArrayList<>();
+ for (EipModel.EipOptionModel opt : model.getOptions()) {
+ if (!"attribute".equals(opt.getKind())) {
+ continue;
+ }
+ if (EIP_BOILERPLATE.contains(opt.getName())) {
+ continue;
+ }
+ if (existingKeys.contains(opt.getName()) && !opt.isMultiValue()) {
+ 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(ci -> !ci.required())
+ .thenComparing(AutocompletePopup.CompletionItem::key,
String.CASE_INSENSITIVE_ORDER));
+ return items;
+ }
+
+ private List<AutocompletePopup.CompletionItem>
provideEipValueCompletions(String eipName, String optionName) {
+ return provideEipValueCompletions(eipName, optionName, List.of());
+ }
+
+ private List<AutocompletePopup.CompletionItem> provideEipValueCompletions(
+ String eipName, String optionName,
List<AutocompletePopup.CompletionItem> placeholders) {
+ EipModel model = catalog.eipModel(eipName);
+ if (model == null) {
+ return List.of();
+ }
+ EipModel.EipOptionModel opt = null;
+ for (EipModel.EipOptionModel o : model.getOptions()) {
+ if (o.getName().equals(optionName)) {
+ opt = o;
+ break;
+ }
+ }
+ List<AutocompletePopup.CompletionItem> items = new ArrayList<>();
+ java.util.function.Predicate<String> valueFilter = null;
+ if (opt != null) {
+ List<String> enums = opt.getEnums();
+ if (enums != null && !enums.isEmpty()) {
+ Set<String> validValues = new HashSet<>();
+ for (String value : enums) {
+ validValues.add(value.toLowerCase());
+ boolean isDefault =
value.equals(String.valueOf(opt.getDefaultValue()));
+ items.add(new AutocompletePopup.CompletionItem(
+ value, opt.getDescription(), opt.getType(),
+ isDefault ? value : opt.getDefaultValue(),
+ false, null, opt.getGroup()));
+ }
+ valueFilter = v -> validValues.contains(v.toLowerCase());
+ } else if ("boolean".equalsIgnoreCase(opt.getType())
+ || "java.lang.Boolean".equals(opt.getJavaType())) {
+ valueFilter = v -> "true".equalsIgnoreCase(v) ||
"false".equalsIgnoreCase(v);
+ items.add(new AutocompletePopup.CompletionItem(
+ "true", opt.getDescription(), "boolean",
opt.getDefaultValue(),
+ false, null, opt.getGroup()));
+ items.add(new AutocompletePopup.CompletionItem(
+ "false", opt.getDescription(), "boolean",
opt.getDefaultValue(),
+ false, null, opt.getGroup()));
+ }
+ }
+ for (AutocompletePopup.CompletionItem ph : placeholders) {
+ if (valueFilter == null || (ph.description() != null &&
valueFilter.test(ph.description()))) {
+ items.add(ph);
+ }
+ }
+ return items;
+ }
+
private List<AutocompletePopup.CompletionItem> loadPlaceholders(Path dir) {
List<AutocompletePopup.CompletionItem> items = new ArrayList<>();
try (var stream = Files.list(dir)) {