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

Reply via email to