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 9e5c6ea7a73c camel-tui: Inline validation markers with red gutter and
doc panel
9e5c6ea7a73c is described below
commit 9e5c6ea7a73c52fb76117d708beaed87b25fa569
Author: Claus Ibsen <[email protected]>
AuthorDate: Fri Aug 14 10:46:45 2026 +0200
camel-tui: Inline validation markers with red gutter and doc panel
After save (Ctrl+S), validation errors are mapped to line numbers
and shown as red gutter markers on the affected lines. When the
cursor is on an error line, the doc panel shows the error message
in red (taking priority over quick doc). Errors clear on edit and
repopulate on next save. Supports unknown endpoint options and
invalid values with line-level mapping.
camel-tui: Background validation with line-aware errors
Validation now runs every 2 seconds while editing (when dirty),
showing inline error markers without needing to save first.
Refactored endpoint validators to include line numbers in error
messages (Line N: prefix) instead of using regex heuristics to
map option names back to source lines. All validators now return
structured line-aware errors consistently.
camel-tui: Error counter in title bar and F9 jump to next error
Show errors: N in red in the top-right corner of the editor border
when validation errors are present. F9 jumps the cursor to the next
error line, wrapping around to the first error at the end.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
Signed-off-by: Claus Ibsen <[email protected]>
---
.../dsl/jbang/core/commands/tui/SourceTab.java | 32 ++++--
.../dsl/jbang/core/commands/tui/SourceViewer.java | 128 ++++++++++++++++++++-
.../commands/tui/YamlEndpointValidationTest.java | 16 +--
3 files changed, 155 insertions(+), 21 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 40566877fff1..1f568084bbe5 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
@@ -461,6 +461,7 @@ class SourceTab extends AbstractTab {
- **Home** — smart home (content indent, then column 0)
- Quick documentation panel is shown at the bottom (shows doc
for current line)
- **F7** — show diff of unsaved changes
+ - **F9** — jump to next validation error
## Edit Mode (Tab Completion)
Press **F4** to enter edit mode, then **Tab** for
context-aware completion:
@@ -2290,6 +2291,7 @@ class SourceTab extends AbstractTab {
// look ahead for a parameters: block at the same indent level as
uri
StringBuilder uriBuilder = new StringBuilder(uri);
boolean hasParams = uri.contains("?");
+ Map<String, Integer> optionLineMap = new LinkedHashMap<>();
for (int j = i + 1; j < lines.length; j++) {
String next = lines[j];
if (next.isBlank()) {
@@ -2324,6 +2326,7 @@ class SourceTab extends AbstractTab {
char sep = hasParams ? '&' : '?';
uriBuilder.append(sep).append(key).append('=').append(val);
hasParams = true;
+ optionLineMap.put(key, k);
}
}
break;
@@ -2339,7 +2342,7 @@ class SourceTab extends AbstractTab {
= catalog.validateEndpointProperties(fullUri, false,
consumerOnly, producerOnly);
if (!result.isSuccess()) {
String scheme = fullUri.contains(":") ?
fullUri.substring(0, fullUri.indexOf(':')) : fullUri;
- collectEndpointErrors(errors, result, scheme);
+ collectEndpointErrors(errors, result, scheme, i,
optionLineMap);
}
} catch (Exception e) {
// ignore validation errors
@@ -2348,7 +2351,9 @@ class SourceTab extends AbstractTab {
return errors;
}
- private static void collectEndpointErrors(List<String> errors,
EndpointValidationResult result, String scheme) {
+ private static void collectEndpointErrors(
+ List<String> errors, EndpointValidationResult result, String
scheme,
+ int uriLineIdx, Map<String, Integer> optionLineMap) {
if (result.getUnknown() != null) {
for (String name : result.getUnknown()) {
StringBuilder sb = new StringBuilder(scheme).append(": Unknown
option '").append(name).append("'");
@@ -2358,22 +2363,25 @@ class SourceTab extends AbstractTab {
sb.append(". Did you mean:
").append(Arrays.asList(suggestions));
}
}
- errors.add(sb.toString());
+ errors.add(linePrefix(optionLineMap.getOrDefault(name,
uriLineIdx)) + sb);
}
}
if (result.getInvalidBoolean() != null) {
for (Map.Entry<String, String> entry :
result.getInvalidBoolean().entrySet()) {
- errors.add(scheme + ": Invalid boolean value '" +
entry.getValue() + "' for option '" + entry.getKey() + "'");
+
errors.add(linePrefix(optionLineMap.getOrDefault(entry.getKey(), uriLineIdx))
+ + scheme + ": Invalid boolean value '" +
entry.getValue() + "' for option '" + entry.getKey() + "'");
}
}
if (result.getInvalidInteger() != null) {
for (Map.Entry<String, String> entry :
result.getInvalidInteger().entrySet()) {
- errors.add(scheme + ": Invalid integer value '" +
entry.getValue() + "' for option '" + entry.getKey() + "'");
+
errors.add(linePrefix(optionLineMap.getOrDefault(entry.getKey(), uriLineIdx))
+ + scheme + ": Invalid integer value '" +
entry.getValue() + "' for option '" + entry.getKey() + "'");
}
}
if (result.getInvalidNumber() != null) {
for (Map.Entry<String, String> entry :
result.getInvalidNumber().entrySet()) {
- errors.add(scheme + ": Invalid number value '" +
entry.getValue() + "' for option '" + entry.getKey() + "'");
+
errors.add(linePrefix(optionLineMap.getOrDefault(entry.getKey(), uriLineIdx))
+ + scheme + ": Invalid number value '" +
entry.getValue() + "' for option '" + entry.getKey() + "'");
}
}
if (result.getInvalidEnum() != null) {
@@ -2387,21 +2395,27 @@ class SourceTab extends AbstractTab {
sb.append(". Possible values:
").append(Arrays.asList(choices));
}
}
- errors.add(sb.toString());
+
errors.add(linePrefix(optionLineMap.getOrDefault(entry.getKey(), uriLineIdx)) +
sb);
}
}
if (result.getNotConsumerOnly() != null) {
for (String name : result.getNotConsumerOnly()) {
- errors.add(scheme + ": Option '" + name + "' is not applicable
in consumer only mode");
+ errors.add(linePrefix(optionLineMap.getOrDefault(name,
uriLineIdx))
+ + scheme + ": Option '" + name + "' is not
applicable in consumer only mode");
}
}
if (result.getNotProducerOnly() != null) {
for (String name : result.getNotProducerOnly()) {
- errors.add(scheme + ": Option '" + name + "' is not applicable
in producer only mode");
+ errors.add(linePrefix(optionLineMap.getOrDefault(name,
uriLineIdx))
+ + scheme + ": Option '" + name + "' is not
applicable in producer only mode");
}
}
}
+ private static String linePrefix(int lineIdx) {
+ return "Line " + (lineIdx + 1) + ": ";
+ }
+
private static String extractEipFromLine(String trimmed) {
if (trimmed.startsWith("- ")) {
trimmed = trimmed.substring(2).trim();
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 91b21564f0e1..82665e2b917d 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
@@ -180,6 +180,10 @@ class SourceViewer {
private EndpointValidator simpleValidator;
private List<String> validationErrors;
private int validationErrorScroll;
+ private Map<Integer, String> inlineErrors = Collections.emptyMap();
+ private long lastBackgroundValidationTime;
+ private String lastBackgroundValidationContent;
+ private static final long BACKGROUND_VALIDATION_INTERVAL_MS = 2000;
private final SourceEditHistory editHistory = new SourceEditHistory();
private record CachedSource(
@@ -668,6 +672,10 @@ class SourceViewer {
}
return true;
}
+ if (ke.isKey(KeyCode.F9) && !inlineErrors.isEmpty()) {
+ jumpToNextError();
+ return true;
+ }
boolean yamlListBlocks = isCamelYamlFile();
if (ke.isKey(KeyCode.UP) && ke.hasAlt() && !ke.hasShift()) {
applyBlockEdit(YamlBlockEditor.moveBlockUp(editLines(),
editState.cursorRow(), yamlListBlocks));
@@ -842,6 +850,9 @@ class SourceViewer {
editHistory.clear();
autocompletePopup = null;
validationErrors = null;
+ inlineErrors = Collections.emptyMap();
+ lastBackgroundValidationTime = 0;
+ lastBackgroundValidationContent = null;
pendingDiscard = false;
originalEditText = null;
lineStatuses = null;
@@ -1976,6 +1987,7 @@ class SourceViewer {
if (!msgs.isEmpty()) {
validationErrors = msgs;
validationErrorScroll = 0;
+ inlineErrors = buildInlineErrors(msgs, content);
return;
}
} else if (validateOnSave && isPropertiesFile() && propertiesValidator
!= null) {
@@ -1983,9 +1995,76 @@ class SourceViewer {
if (!msgs.isEmpty()) {
validationErrors = msgs;
validationErrorScroll = 0;
+ inlineErrors = buildInlineErrors(msgs, content);
+ return;
+ }
+ }
+ inlineErrors = Collections.emptyMap();
+ }
+
+ private void jumpToNextError() {
+ List<Integer> errorLines = new ArrayList<>(inlineErrors.keySet());
+ Collections.sort(errorLines);
+ int cursorRow = editState.cursorRow();
+ // find the first error line after the cursor
+ for (int line : errorLines) {
+ if (line > cursorRow) {
+ goToLine(line);
return;
}
}
+ // wrap around to the first error
+ if (!errorLines.isEmpty()) {
+ goToLine(errorLines.get(0));
+ }
+ }
+
+ private void runBackgroundValidation() {
+ if (!dirty || validationErrors != null) {
+ return;
+ }
+ long now = System.currentTimeMillis();
+ if (now - lastBackgroundValidationTime <
BACKGROUND_VALIDATION_INTERVAL_MS) {
+ return;
+ }
+ String content = editState.text();
+ if (content.equals(lastBackgroundValidationContent)) {
+ return;
+ }
+ lastBackgroundValidationTime = now;
+ lastBackgroundValidationContent = content;
+
+ List<String> msgs = new ArrayList<>();
+ if (isCamelYamlFile()) {
+ if (endpointValidator != null) {
+ List<String> endpointErrors =
endpointValidator.validate(content);
+ if (endpointErrors != null) {
+ msgs.addAll(endpointErrors);
+ }
+ }
+ if (simpleValidator != null) {
+ List<String> simpleErrors = simpleValidator.validate(content);
+ if (simpleErrors != null) {
+ msgs.addAll(simpleErrors);
+ }
+ }
+ } else if (isPropertiesFile() && propertiesValidator != null) {
+ msgs.addAll(validateProperties(content));
+ }
+ inlineErrors = msgs.isEmpty() ? Collections.emptyMap() :
buildInlineErrors(msgs, content);
+ }
+
+ static Map<Integer, String> buildInlineErrors(List<String> errors, String
content) {
+ Map<Integer, String> result = new java.util.LinkedHashMap<>();
+ java.util.regex.Pattern linePattern =
java.util.regex.Pattern.compile("^Line (\\d+): (.*)");
+ for (String error : errors) {
+ java.util.regex.Matcher m = linePattern.matcher(error);
+ if (m.matches()) {
+ int lineNum = Integer.parseInt(m.group(1)) - 1;
+ result.putIfAbsent(lineNum, m.group(2));
+ }
+ }
+ return result;
}
private List<String> validateProperties(String content) {
@@ -2382,6 +2461,11 @@ class SourceViewer {
blockBuilder.borders(Borders.ALL)
.title(Title.from(Line.from(titleSpans)))
.titleBottom(posTitle);
+ if (!inlineErrors.isEmpty()) {
+ Style errorStyle =
Style.EMPTY.fg(dev.tamboui.style.Color.rgb(0xFF, 0x66, 0x66));
+ blockBuilder.title(Title.from(Line.from(
+ Span.styled(" errors: " + inlineErrors.size() + " ",
errorStyle))).right());
+ }
}
if (borderStyle != null) {
blockBuilder.borderStyle(borderStyle);
@@ -2397,6 +2481,8 @@ class SourceViewer {
return;
}
+ runBackgroundValidation();
+
// split inner area for quick doc panel at the bottom (fixed height to
avoid flicker)
List<DocEntry> editDocEntries = null;
Rect editorArea = inner;
@@ -2464,14 +2550,45 @@ class SourceViewer {
}
}
- // quick doc panel at the bottom of the editor (fixed height)
+ // error gutter markers — red line number for lines with validation
errors
+ if (!inlineErrors.isEmpty() && !plainMode) {
+ int gutterWidth = Math.max(2,
String.valueOf(editState.lineCount()).length()) + 2;
+ for (int r = 0; r < editorArea.height(); r++) {
+ int lineIdx = editState.scrollRow() + r;
+ if (inlineErrors.containsKey(lineIdx)) {
+ Style errorBg =
Style.EMPTY.fg(dev.tamboui.style.Color.WHITE)
+ .bg(dev.tamboui.style.Color.rgb(0x8B, 0x00, 0x00));
+ int screenY = editorArea.top() + r;
+ for (int x = editorArea.left(); x < editorArea.left() +
gutterWidth; x++) {
+ dev.tamboui.buffer.Cell cell = frame.buffer().get(x,
screenY);
+ frame.buffer().set(x, screenY,
+ new dev.tamboui.buffer.Cell(cell.symbol(),
errorBg));
+ }
+ }
+ }
+ }
+
+ // quick doc panel at the bottom — errors take priority over doc
if (docArea != null) {
+ String cursorError = inlineErrors.get(editState.cursorRow());
List<Line> docLines = new ArrayList<>();
String titleText = null;
- if (editDocEntries != null && !editDocEntries.isEmpty()) {
+ if (cursorError != null) {
+ titleText = "Error";
+ } else if (editDocEntries != null && !editDocEntries.isEmpty()) {
titleText = editDocEntries.get(0).title();
}
- if (titleText != null) {
+ if (cursorError != null) {
+ 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));
+ docLines.add(Line.from(
+ Span.styled(prefix, errorDim),
+ Span.styled(titleText, errorDim.bold()),
+ Span.styled(suffix + "─".repeat(remaining),
errorDim)));
+ docLines.add(Line.from(Span.styled(cursorError, errorDim)));
+ } else if (titleText != null) {
String prefix = "─── ";
String suffix = " ";
int remaining = Math.max(0, docArea.width() - prefix.length()
- titleText.length() - suffix.length());
@@ -2482,7 +2599,7 @@ class SourceViewer {
} else {
docLines.add(Line.from(Span.styled("─".repeat(Math.max(1,
docArea.width())), Style.EMPTY.dim())));
}
- if (editDocEntries != null && !editDocEntries.isEmpty()) {
+ if (cursorError == null && editDocEntries != null &&
!editDocEntries.isEmpty()) {
for (int d = 0; d < editDocEntries.size() && d <
docArea.height() - 1; d++) {
DocEntry entry = editDocEntries.get(d);
Style docStyle = entry.deprecated() ?
Style.EMPTY.dim().italic() : Style.EMPTY.dim();
@@ -2734,6 +2851,9 @@ class SourceViewer {
if (autocompleteProvider != null) {
TuiHelper.hint(spans, "Tab", "complete");
}
+ if (!inlineErrors.isEmpty()) {
+ TuiHelper.hint(spans, "F9", "next error");
+ }
TuiHelper.hint(spans, TuiIcons.HINT_SCROLL, "move");
return;
}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/YamlEndpointValidationTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/YamlEndpointValidationTest.java
index 978dc1eaa780..7f350a57c3e0 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/YamlEndpointValidationTest.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/YamlEndpointValidationTest.java
@@ -62,7 +62,7 @@ class YamlEndpointValidationTest {
""";
List<String> errors = SourceTab.doValidateYamlEndpoints(yaml, catalog);
assertThat(errors).isNotEmpty();
- assertThat(errors.get(0)).startsWith("timer:");
+ assertThat(errors.get(0)).contains("timer:");
assertThat(errors.get(0)).containsIgnoringCase("unknown");
}
@@ -78,7 +78,7 @@ class YamlEndpointValidationTest {
""";
List<String> errors = SourceTab.doValidateYamlEndpoints(yaml, catalog);
assertThat(errors).isNotEmpty();
- assertThat(errors.get(0)).startsWith("timer:");
+ assertThat(errors.get(0)).contains("timer:");
}
@Test
@@ -101,7 +101,7 @@ class YamlEndpointValidationTest {
""";
List<String> errors = SourceTab.doValidateYamlEndpoints(yaml, catalog);
assertThat(errors).isNotEmpty();
- assertThat(errors.get(0)).startsWith("timer:");
+ assertThat(errors.get(0)).contains("timer:");
assertThat(errors.get(0)).containsIgnoringCase("unknown");
}
@@ -143,7 +143,7 @@ class YamlEndpointValidationTest {
""";
List<String> errors = SourceTab.doValidateYamlEndpoints(yaml, catalog);
assertThat(errors).isNotEmpty();
- assertThat(errors.get(0)).startsWith("timer:");
+ assertThat(errors.get(0)).contains("timer:");
}
@Test
@@ -161,8 +161,8 @@ class YamlEndpointValidationTest {
""";
List<String> errors = SourceTab.doValidateYamlEndpoints(yaml, catalog);
assertThat(errors).isNotEmpty();
- assertThat(errors).allSatisfy(e -> assertThat(e).startsWith("log:"));
- assertThat(errors).noneSatisfy(e ->
assertThat(e).startsWith("timer:"));
+ assertThat(errors).allSatisfy(e -> assertThat(e).contains("log:"));
+ assertThat(errors).noneSatisfy(e -> assertThat(e).contains("timer:"));
}
@Test
@@ -214,7 +214,7 @@ class YamlEndpointValidationTest {
""";
List<String> errors = SourceTab.doValidateYamlEndpoints(yaml, catalog);
assertThat(errors).hasSize(1);
- assertThat(errors.get(0)).startsWith("seda:");
+ assertThat(errors.get(0)).contains("seda:");
}
@Test
@@ -252,7 +252,7 @@ class YamlEndpointValidationTest {
""";
List<String> errors = SourceTab.doValidateYamlEndpoints(yaml, catalog);
assertThat(errors).isNotEmpty();
- assertThat(errors).anyMatch(e -> e.startsWith("timer:") &&
e.contains("bridgeErrorHandler2"));
+ assertThat(errors).anyMatch(e -> e.contains("timer:") &&
e.contains("bridgeErrorHandler2"));
}
@Test