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

commit e276b7f450715d8c2e7d7df62c99d268496e8432
Author: Claus Ibsen <[email protected]>
AuthorDate: Tue Sep 1 22:12:52 2026 +0200

    camel-jbang-plugin-tui: fix Tab-completion and indent for expression 
languages in Source editor
    
    Editing an expression field (e.g. setVariable, setBody, split) in the Source
    editor's YAML tree completion resolved the blank cursor line's parent key to
    the enclosing EIP instead of the immediate structural key, so Tab re-offered
    already-set EIP fields (name/expression) instead of the language list
    (simple, groovy, constant, ...). Once a language was picked, the same
    scope-based lookup also mis-indented it as a sibling of expression: instead
    of nesting it underneath.
    
    camel-jbang-plugin-tui: add Shift+Tab smart-dedent in Source editor
    
    Shift+Tab now moves the cursor left, within the current line's leading
    whitespace, to the nearest enclosing structural indent level instead of
    doing nothing (it previously behaved identically to plain Tab and just
    reopened autocomplete). It only acts while the cursor sits in leading
    whitespace with nothing typed yet on the line, so it never mutates the
    buffer. tamboui already decodes the ESC[Z backtab sequence into
    KeyCode.TAB + KeyModifiers.SHIFT across all backends, so no framework
    change was needed.
    
    camel-jbang-plugin-tui: fix breadcrumb and list-item indent after Shift+Tab 
dedent
    
    Two related bugs surfaced once Shift+Tab (added previously) could reposition
    the cursor within a blank line's existing whitespace without trimming it:
    
    - buildBreadcrumb treated any blank cursor line as infinitely deep, so it
      always walked to the deepest ancestor near the cursor instead of the one
      matching where the cursor was actually dedented to.
    - insertYamlCompletion computed indentation from the blank line's full,
      stale whitespace length rather than the cursor's column, so inserting an
      EIP as a "steps:" list item after dedenting landed at the wrong (too
      deep) indent with a misplaced "- " prefix, corrupting the YAML.
    
    Both now consistently derive the "effective indent" of a blank line from
    the live cursor column via a shared effectiveBlankIndent() helper (falling
    back to deriving from the preceding line only when resolving a row other
    than the live cursor, e.g. in tests). findParentYamlKey, findEnclosingEip,
    and collectExistingSiblingKeys were already using ad hoc variants of this;
    they now share the same helper.
    
    Verified live: dedenting from a nested language block back to the "steps:"
    list level now correctly updates the breadcrumb and offers the full EIP
    list via Tab, and picking one inserts a correctly indented "- <eip>:" list
    item.
    
    camel-jbang-plugin-tui: fix auto-inserted parameters: block misaligned 
under uri:
    
    Pressing Tab below a uri: line with no parameters: block yet auto-inserts
    one, but the indent came from deriveInsertionIndent's EIP-scope heuristic:
    for a producer/consumer EIP (to:, from:, ...) that heuristic resolves the
    scope to the uri: line itself and then adds a nesting level on top,
    placing parameters: one level too deep instead of as uri:'s sibling.
    
    That misalignment then broke findEnclosingComponent's uri-sibling lookup
    for everything nested under the malformed parameters: block — both adding
    a second parameter via Tab and value-side completion (e.g. true/false for
    a boolean option) stopped working, because the endpoint context could no
    longer be resolved.
    
    Fixed by using the cursor's real column (effectiveBlankIndent) instead,
    matching uri:'s indent via the existing Enter-key auto-indent.
    
    camel-jbang-plugin-tui: fix findEnclosingComponent ignoring dedented cursor
    
    findEnclosingComponent's blank-line handling inspected only the literal
    prefix of the immediately preceding line to decide context, completely
    ignoring the live cursor's column. After Shift+Tab dedents the cursor back
    out of an endpoint's parameters: block (without trimming the line's
    leftover whitespace), the preceding line still looked like an ordinary
    parameter, so it kept recursing as if still inside the endpoint's options
    — offering the wrong component's parameters (or even the same "steps:"
    list's blank line pretending to be inside an unrelated endpoint).
    
    Rewritten to compute cursorIndent via the existing effectiveBlankIndent
    helper and do an indent-based sibling scan for a uri: line, letting the
    existing indent-aware main loop (already correct) decide the rest.
    
    camel-jbang-plugin-tui: don't offer to recreate an existing parameters: 
block
    
    findEnclosingComponent's uri: sibling scan kept looking past an existing
    parameters: sibling to find uri: for the needsParameters auto-insert path,
    even when parameters: already existed at the same indent. Pressing Tab
    with the cursor dedented to that sibling level (a common position after
    Shift+Tab, or simply typing a new blank line there) then inserted a
    second, duplicate "parameters:" key under the same endpoint, corrupting
    the YAML.
    
    Now returns null as soon as a parameters: sibling is found at the
    cursor's indent, before ever reaching uri:, since there's nothing to
    auto-create and the cursor isn't inside the existing block either.
    
    camel-jbang-plugin-tui: don't flag a field's error until it has a value
    
    Background validation immediately flagged a field the instant it was
    created via Tab-completion (e.g. "checksumFileAlgorithm:" with nothing
    typed yet), showing "Invalid enum value ''" before the user had any
    chance to fill it in. That's not a mistake, just an incomplete edit.
    
    Inline errors (the gutter highlight, the "errors: N" badge, and the
    bottom quick-doc panel) are now suppressed for a line whose value is
    still empty. This is value-based rather than cursor-based: a line with a
    genuinely wrong, non-empty value (e.g. a Simple language typo) still
    flags immediately, without requiring the cursor to move away first —
    important since typo feedback while actively typing a Simple expression
    is exactly when you want it.
    
    Save-time validation (Ctrl+S/F5) is unaffected and still blocks saving
    with the full error, since completeness only truly matters there.
    
    Co-Authored-By: Claude Sonnet 5 <[email protected]>
    Signed-off-by: Claus Ibsen <[email protected]>
---
 .../dsl/jbang/core/commands/tui/SourceViewer.java  | 199 +++++++++----
 .../core/commands/tui/YamlCompletionTest.java      | 319 +++++++++++++++++++++
 2 files changed, 467 insertions(+), 51 deletions(-)

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 d8ed9a6eca63..07a4fdf13369 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
@@ -808,6 +808,10 @@ class SourceViewer {
             editState.deleteForward();
             return true;
         }
+        if (ke.isKey(KeyCode.TAB) && ke.hasShift()) {
+            moveCursorToPreviousIndentStop();
+            return true;
+        }
         if (ke.isKey(KeyCode.TAB) && autocompleteProvider != null) {
             openAutocomplete();
             return true;
@@ -894,33 +898,39 @@ class SourceViewer {
 
     YamlEndpointContext findEnclosingComponent(int fromRow) {
         String cursorLine = editState.getLine(fromRow);
-        int cursorIndent = countLeadingSpaces(cursorLine);
+        // a blank line's own leading whitespace can be stale after a 
Shift+Tab dedent (see
+        // effectiveBlankIndent) — use the cursor's real column so a cursor 
dedented back out of
+        // an endpoint's parameters: block isn't mistaken for still being 
inside it
+        int cursorIndent = cursorLine.isBlank() ? 
effectiveBlankIndent(fromRow) : countLeadingSpaces(cursorLine);
 
         // list items (- key:) are inside steps, not inside parameters
         if (!cursorLine.isBlank() && cursorLine.trim().startsWith("- ")) {
             return null;
         }
 
-        // blank lines: find the nearest preceding non-blank line for context
+        // blank line positioned as a sibling of a uri: line with no 
parameters: block yet —
+        // offer to create one. Scan siblings at exactly cursorIndent; a 
shallower line ends it.
         if (cursorLine.isBlank()) {
             for (int i = fromRow - 1; i >= 0; i--) {
-                String prev = editState.getLine(i);
-                if (!prev.isBlank()) {
-                    String pt = prev.trim();
-                    if (pt.startsWith("parameters:")) {
-                        // blank line right after parameters: — cursor is 
inside the block
-                        cursorIndent = countLeadingSpaces(prev) + 1;
-                        fromRow = i;
-                    } else if (pt.startsWith("- ") || pt.startsWith("steps:")) 
{
-                        // inside a steps block or list item — not inside 
parameters
+                String line = editState.getLine(i);
+                if (line.isBlank()) {
+                    continue;
+                }
+                int indent = countLeadingSpaces(line);
+                if (indent < cursorIndent) {
+                    break;
+                }
+                if (indent == cursorIndent) {
+                    String trimmed = line.trim();
+                    if (trimmed.startsWith("parameters:")) {
+                        // a parameters: block already exists as a sibling 
here — nothing to
+                        // auto-create, and the cursor isn't inside it either 
(it's a sibling,
+                        // not a child); fall through to generic EIP-field 
completion instead
                         return null;
-                    } else if (pt.startsWith("uri:") || pt.startsWith("id:")) {
-                        // below uri: or id: — look for uri: sibling to offer 
component options
+                    }
+                    if (trimmed.startsWith("uri:") || trimmed.startsWith("- 
uri:")) {
                         return findComponentFromUriSibling(i);
-                    } else {
-                        return findEnclosingComponent(i);
                     }
-                    break;
                 }
             }
         }
@@ -1240,22 +1250,57 @@ class SourceViewer {
         return 0;
     }
 
-    String findParentYamlKey(int fromRow) {
-        String cursorLine = editState.getLine(fromRow);
+    /**
+     * Shift+Tab: move the cursor left, within the current line's leading 
whitespace, to the nearest enclosing
+     * structural indent level — the same indent a completion inserted at this 
position would have used one level up.
+     * Only acts while the cursor sits inside leading whitespace (nothing 
typed yet on the line); otherwise it is a
+     * no-op.
+     */
+    void moveCursorToPreviousIndentStop() {
+        int row = editState.cursorRow();
+        String line = editState.getLine(row);
+        int col = Math.min(editState.cursorCol(), line.length());
+        if (col <= 0 || !line.substring(0, col).isBlank()) {
+            return;
+        }
 
-        // on a blank line, use the scope line (the highlighted EIP) as parent
-        if (cursorLine.isBlank()) {
-            int scopeRow = findScopeLineRow(fromRow);
-            if (scopeRow >= 0) {
-                String scopeLine = editState.getLine(scopeRow);
-                String scopeKey = extractEipName(scopeLine.trim());
-                if (scopeKey != null) {
-                    return dashToCamelCase(scopeKey);
-                }
+        int target = 0;
+        for (int i = row - 1; i >= 0; i--) {
+            String prev = editState.getLine(i);
+            if (prev.isBlank()) {
+                continue;
+            }
+            int indent = countLeadingSpaces(prev);
+            if (indent < col) {
+                target = indent;
+                break;
             }
         }
+        SourceEditorNavigation.positionCursor(editState, row, target);
+    }
 
-        int cursorIndent = countLeadingSpaces(cursorLine);
+    /**
+     * Effective indent for a possibly-blank line. On the live cursor row, the 
line's own leading whitespace can be
+     * stale — Shift+Tab (see {@link #moveCursorToPreviousIndentStop()}) 
repositions the cursor within existing
+     * whitespace without trimming it — so the cursor's column is the source 
of truth there. For any other row (e.g.
+     * tests resolving an arbitrary row without moving the live cursor there), 
trust the row's own real whitespace when
+     * it has any, and only derive from the preceding line when it is truly 
empty.
+     */
+    private int effectiveBlankIndent(int row) {
+        if (row == editState.cursorRow()) {
+            return editState.cursorCol();
+        }
+        int literal = countLeadingSpaces(editState.getLine(row));
+        return literal > 0 ? literal : deriveBlankLineIndent(row);
+    }
+
+    String findParentYamlKey(int fromRow) {
+        String cursorLine = editState.getLine(fromRow);
+
+        // a blank line has no real indentation yet — derive the intended 
nesting level so a
+        // cursor nested under e.g. "expression:" resolves to that key, not to 
the enclosing EIP
+        // (which would otherwise re-offer already-set fields like 
name/expression)
+        int cursorIndent = cursorLine.isBlank() ? 
effectiveBlankIndent(fromRow) : countLeadingSpaces(cursorLine);
 
         // walk up to find parent key at lower indent
         for (int i = fromRow; i >= 0; i--) {
@@ -1285,11 +1330,7 @@ class SourceViewer {
 
     YamlEipContext findEnclosingEip(int fromRow) {
         String cursorLine = editState.getLine(fromRow);
-        int cursorIndent = countLeadingSpaces(cursorLine);
-
-        if (cursorLine.isBlank() && cursorIndent == 0) {
-            cursorIndent = deriveBlankLineIndent(fromRow);
-        }
+        int cursorIndent = cursorLine.isBlank() ? 
effectiveBlankIndent(fromRow) : countLeadingSpaces(cursorLine);
 
         // if cursor is inside a parameters: block, defer to component 
completion
         for (int i = fromRow; i >= 0; i--) {
@@ -1335,11 +1376,7 @@ class SourceViewer {
     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);
-
-        if (cursorLine.isBlank() && cursorIndent == 0) {
-            cursorIndent = deriveBlankLineIndent(fromRow);
-        }
+        int cursorIndent = cursorLine.isBlank() ? 
effectiveBlankIndent(fromRow) : countLeadingSpaces(cursorLine);
 
         // scan upward for siblings at same indent
         for (int i = fromRow - 1; i >= 0; i--) {
@@ -1504,10 +1541,10 @@ class SourceViewer {
         }
         List<String> parts = new ArrayList<>();
         String cursorLine = editState.getLine(cursorRow);
-        int cursorIndent = countLeadingSpaces(cursorLine);
-        if (cursorLine.isBlank()) {
-            cursorIndent = Integer.MAX_VALUE;
-        }
+        // a blank line's own leading whitespace can be stale (see 
effectiveBlankIndent), so use
+        // the cursor's real column rather than assuming the deepest nesting 
implied by the line
+        // above — otherwise dedenting with Shift+Tab wouldn't be reflected in 
the breadcrumb
+        int cursorIndent = cursorLine.isBlank() ? 
effectiveBlankIndent(cursorRow) : countLeadingSpaces(cursorLine);
 
         int prevIndent = cursorIndent;
         for (int i = cursorRow - 1; i >= 0; i--) {
@@ -1782,7 +1819,12 @@ class SourceViewer {
             } else {
                 // auto-insert parameters: block if cursor is below uri: 
without one
                 if (ctx.needsParameters() && lineText.isBlank()) {
-                    int indent = deriveInsertionIndent(row);
+                    // parameters: must be a sibling of uri:, so use the 
cursor's real column
+                    // (matching uri:'s indent via Enter-key auto-indent) 
rather than
+                    // deriveInsertionIndent's EIP-scope heuristic, which 
resolves the scope to
+                    // the uri: line itself here and then adds a level, 
nesting parameters: one
+                    // level too deep and breaking findEnclosingComponent's 
uri-sibling lookup
+                    int indent = effectiveBlankIndent(row);
                     String indentStr = " ".repeat(indent);
                     editState.moveCursorToLineStart();
                     editState.insert(indentStr + "parameters:");
@@ -1889,14 +1931,31 @@ class SourceViewer {
     }
 
     void insertYamlCompletion(AutocompletePopup.CompletionItem item, boolean 
valueMode, String currentLine) {
-        insertYamlCompletion(item, valueMode, currentLine, false);
+        // no explicit cursor column given (e.g. direct test calls) — assume 
the cursor sits at
+        // the end of the given line, matching this method's original, 
column-agnostic behavior
+        insertYamlCompletion(item, valueMode, currentLine, false, 
currentLine.length());
     }
 
     private void insertYamlCompletion(
             AutocompletePopup.CompletionItem item, boolean valueMode, String 
currentLine, boolean listItem) {
-        int indent = countLeadingSpaces(currentLine);
-        if (currentLine.isBlank()) {
+        insertYamlCompletion(item, valueMode, currentLine, listItem, 
editState.cursorCol());
+    }
+
+    /** Package-private (rather than private) so tests can exercise an 
explicit cursor column. */
+    void insertYamlCompletion(
+            AutocompletePopup.CompletionItem item, boolean valueMode, String 
currentLine, boolean listItem,
+            int cursorCol) {
+        int indent;
+        if (currentLine.isEmpty()) {
+            // truly empty line (no auto-inserted whitespace yet) — derive 
from the enclosing EIP
             indent = deriveInsertionIndent(editState.cursorRow());
+        } else if (currentLine.isBlank()) {
+            // whitespace-only line: the cursor's column is the real, intended 
nesting depth —
+            // Shift+Tab (see moveCursorToPreviousIndentStop()) can dedent it 
within the existing
+            // whitespace without trimming the line, so the line's own length 
would be stale here
+            indent = Math.min(cursorCol, currentLine.length());
+        } else {
+            indent = countLeadingSpaces(currentLine);
         }
         String indentStr = " ".repeat(indent);
 
@@ -2098,6 +2157,42 @@ class SourceViewer {
         inlineErrors = msgs.isEmpty() ? Collections.emptyMap() : 
buildInlineErrors(msgs, content);
     }
 
+    /**
+     * Inline errors to actually display right now. A line with no value typed 
yet (e.g. a field just added via
+     * Tab-completion, "key:" with nothing after it) is still being filled in, 
so its error — which is really just "you
+     * haven't finished this" — is suppressed. This is value-based rather than 
cursor-based: a genuinely wrong,
+     * non-empty value (e.g. a Simple language typo) still flags immediately, 
without needing to move the cursor away
+     * first.
+     */
+    private Map<Integer, String> visibleInlineErrors() {
+        if (inlineErrors.isEmpty()) {
+            return inlineErrors;
+        }
+        Map<Integer, String> visible = null;
+        for (Integer line : inlineErrors.keySet()) {
+            if (line >= 0 && line < editState.lineCount() && 
isEmptyValueLine(editState.getLine(line))) {
+                if (visible == null) {
+                    visible = new java.util.LinkedHashMap<>(inlineErrors);
+                }
+                visible.remove(line);
+            }
+        }
+        return visible != null ? visible : inlineErrors;
+    }
+
+    /** Package-private (rather than private) so tests can exercise it 
directly. */
+    static boolean isEmptyValueLine(String line) {
+        String trimmed = line.trim();
+        if (trimmed.startsWith("- ")) {
+            trimmed = trimmed.substring(2).trim();
+        }
+        int colon = trimmed.indexOf(':');
+        if (colon < 0) {
+            return trimmed.isEmpty();
+        }
+        return trimmed.substring(colon + 1).trim().isEmpty();
+    }
+
     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+): (.*)");
@@ -2472,6 +2567,7 @@ class SourceViewer {
     }
 
     private void renderEditMode(Frame frame, Rect area) {
+        Map<Integer, String> visibleErrors = visibleInlineErrors();
         Style ts = titleStyle != null ? titleStyle : Style.EMPTY;
         List<Span> titleSpans = new ArrayList<>();
         String info = title != null ? title : "";
@@ -2505,10 +2601,10 @@ class SourceViewer {
             blockBuilder.borders(Borders.ALL)
                     .title(Title.from(Line.from(titleSpans)))
                     .titleBottom(posTitle);
-            if (!inlineErrors.isEmpty()) {
+            if (!visibleErrors.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());
+                        Span.styled(" errors: " + visibleErrors.size() + " ", 
errorStyle))).right());
             }
         }
         if (borderStyle != null) {
@@ -2620,11 +2716,11 @@ class SourceViewer {
         }
 
         // error gutter markers — red line number for lines with validation 
errors
-        if (!inlineErrors.isEmpty() && !plainMode) {
+        if (!visibleErrors.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)) {
+                if (visibleErrors.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;
@@ -2639,7 +2735,7 @@ class SourceViewer {
 
         // quick doc panel at the bottom — errors take priority over doc
         if (docArea != null) {
-            String cursorError = inlineErrors.get(editState.cursorRow());
+            String cursorError = visibleErrors.get(editState.cursorRow());
             List<Line> docLines = new ArrayList<>();
             String titleText = null;
             if (cursorError != null) {
@@ -2920,6 +3016,7 @@ class SourceViewer {
             if (autocompleteProvider != null) {
                 TuiHelper.hint(spans, "Tab", "complete");
             }
+            TuiHelper.hint(spans, "Shift+Tab", "dedent");
             if (!inlineErrors.isEmpty()) {
                 TuiHelper.hint(spans, "F9", "next error");
             }
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 222c9650c875..96ec71661671 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
@@ -239,6 +239,90 @@ class YamlCompletionTest {
         assertThat(ctx.consumer()).isFalse();
     }
 
+    @Test
+    void findEnclosingComponentReturnsNullAfterDedentPastParameters() throws 
IOException {
+        // reproduces the cursor being Shift+Tab-dedented from inside an 
endpoint's parameters:
+        // block back down to the steps list-item level — 
findEnclosingComponent must stop
+        // offering that endpoint's options once the cursor is no longer 
really inside them,
+        // even though the blank line's own leftover whitespace still looks 
"deep"
+        String yaml = String.join("\n",
+                "- from:",
+                "    uri: timer:tick",
+                "    steps:",
+                "      - to:",
+                "          uri: file:xxx",
+                "          parameters:",
+                "            autoCreate: true",
+                "");
+
+        Path file = tempDir.resolve("route.camel.yaml");
+        Files.writeString(file, yaml);
+
+        SourceViewer viewer = new SourceViewer();
+        viewer.loadFile(file);
+        viewer.enterEditMode();
+        viewer.editState().moveCursorToStart();
+        for (int i = 0; i < 6; i++) {
+            viewer.editState().moveCursorDown();
+        }
+        viewer.editState().moveCursorToLineEnd();
+
+        // sanity check: still inside the file endpoint's parameters here
+        SourceViewer.YamlEndpointContext before = 
viewer.findEnclosingComponent(viewer.editState().cursorRow());
+        assertThat(before).isNotNull();
+        assertThat(before.component()).isEqualTo("file");
+
+        viewer.handleKeyEvent(dev.tamboui.tui.event.KeyEvent.ofKey(
+                dev.tamboui.tui.event.KeyCode.ENTER, 
dev.tamboui.tui.event.KeyModifiers.NONE));
+        viewer.handleKeyEvent(dev.tamboui.tui.event.KeyEvent.ofKey(
+                dev.tamboui.tui.event.KeyCode.TAB, 
dev.tamboui.tui.event.KeyModifiers.SHIFT));
+        viewer.handleKeyEvent(dev.tamboui.tui.event.KeyEvent.ofKey(
+                dev.tamboui.tui.event.KeyCode.TAB, 
dev.tamboui.tui.event.KeyModifiers.SHIFT));
+
+        SourceViewer.YamlEndpointContext after = 
viewer.findEnclosingComponent(viewer.editState().cursorRow());
+        assertThat(after).isNull();
+    }
+
+    @Test
+    void findEnclosingComponentDoesNotOfferToRecreateExistingParameters() 
throws IOException {
+        // reproduces the cursor dedenting exactly one level — from inside 
parameters: down to
+        // being a sibling of both uri: and parameters: — which must not be 
treated as "needs a
+        // parameters: block" (parameters already exists there), or Tab ends 
up inserting a
+        // second, duplicate "parameters:" key
+        String yaml = String.join("\n",
+                "- from:",
+                "    uri: timer:tick",
+                "    steps:",
+                "      - to:",
+                "          uri: file:xxx",
+                "          parameters:",
+                "            autoCreate: true",
+                "");
+
+        Path file = tempDir.resolve("route.camel.yaml");
+        Files.writeString(file, yaml);
+
+        SourceViewer viewer = new SourceViewer();
+        viewer.loadFile(file);
+        viewer.enterEditMode();
+        viewer.editState().moveCursorToStart();
+        for (int i = 0; i < 6; i++) {
+            viewer.editState().moveCursorDown();
+        }
+        viewer.editState().moveCursorToLineEnd();
+
+        viewer.handleKeyEvent(dev.tamboui.tui.event.KeyEvent.ofKey(
+                dev.tamboui.tui.event.KeyCode.ENTER, 
dev.tamboui.tui.event.KeyModifiers.NONE));
+        viewer.handleKeyEvent(dev.tamboui.tui.event.KeyEvent.ofKey(
+                dev.tamboui.tui.event.KeyCode.TAB, 
dev.tamboui.tui.event.KeyModifiers.SHIFT));
+
+        // now a sibling of uri:/parameters: (indent 10), not inside 
parameters: children (12)
+        assertThat(viewer.editState().cursorCol()).isEqualTo(10);
+
+        SourceViewer.YamlEndpointContext ctx = 
viewer.findEnclosingComponent(viewer.editState().cursorRow());
+        assertThat(ctx).isNull();
+    }
+
     // --- Key completion from catalog ---
 
     @Test
@@ -818,6 +902,26 @@ class YamlCompletionTest {
         assertThat(SourceViewer.dashToCamelCase(null)).isNull();
     }
 
+    // --- isEmptyValueLine (drives suppressing inline validation errors while 
a value is
+    // still being typed, without hiding a genuinely wrong, non-empty value) 
---
+
+    @Test
+    void isEmptyValueLineDetectsMissingValue() {
+        assertThat(SourceViewer.isEmptyValueLine("            
checksumFileAlgorithm:")).isTrue();
+        assertThat(SourceViewer.isEmptyValueLine("            
checksumFileAlgorithm: ")).isTrue();
+        assertThat(SourceViewer.isEmptyValueLine("        - to:")).isTrue();
+        assertThat(SourceViewer.isEmptyValueLine("")).isTrue();
+    }
+
+    @Test
+    void isEmptyValueLineKeepsGenuinelyWrongValues() {
+        // a typo in a Simple expression (or any other non-empty, wrong value) 
must not be
+        // treated as "still being typed" — it should keep flagging immediately
+        assertThat(SourceViewer.isEmptyValueLine("              expression: 
${bdoy}")).isFalse();
+        assertThat(SourceViewer.isEmptyValueLine("            
checksumFileAlgorithm: NOT_REAL")).isFalse();
+        assertThat(SourceViewer.isEmptyValueLine("        - to: 
file:xxx")).isFalse();
+    }
+
     // --- findScopeLineRow ---
 
     @Test
@@ -1011,6 +1115,29 @@ class YamlCompletionTest {
         assertThat(viewer.findParentYamlKey(6)).isEqualTo("simple");
     }
 
+    @Test
+    void findParentYamlKeyOnEmptyLineUnderExpression() throws IOException {
+        // reproduces a truly empty cursor line (no pre-typed indentation), 
which relies on the
+        // preceding "expression:" line to derive the intended nesting level
+        String yaml = String.join("\n",
+                "- from:",
+                "    uri: timer:tick",
+                "    steps:",
+                "      - setVariable:",
+                "          name: cheese",
+                "          expression:",
+                "");
+
+        Path file = tempDir.resolve("route.camel.yaml");
+        Files.writeString(file, yaml);
+
+        SourceViewer viewer = new SourceViewer();
+        viewer.loadFile(file);
+        viewer.enterEditMode();
+
+        assertThat(viewer.findParentYamlKey(6)).isEqualTo("expression");
+    }
+
     @Test
     void findParentYamlKeyInsideFrom() throws IOException {
         String yaml = String.join("\n",
@@ -1296,6 +1423,198 @@ class YamlCompletionTest {
         assertThat(result).contains("            steps:\n              - ");
     }
 
+    @Test
+    void insertLanguageKeyUnderExpressionKeepsNestedIndent() throws 
IOException {
+        // reproduces choosing "expression:" (object) then a language (e.g. 
"constant") for its
+        // auto-inserted child line — the language must nest under expression, 
not become its sibling
+        String yaml = String.join("\n",
+                "- from:",
+                "    uri: timer:tick",
+                "    steps:",
+                "      - setVariable:",
+                "          name: cheese",
+                "          ",
+                "");
+
+        Path file = tempDir.resolve("route.camel.yaml");
+        Files.writeString(file, yaml);
+
+        SourceViewer viewer = new SourceViewer();
+        viewer.loadFile(file);
+        viewer.enterEditMode();
+        viewer.editState().moveCursorToStart();
+        for (int i = 0; i < 5; i++) {
+            viewer.editState().moveCursorDown();
+        }
+
+        AutocompletePopup.CompletionItem expressionItem = new 
AutocompletePopup.CompletionItem(
+                "expression", "The expression", "object", null, false, null, 
"common", true);
+        viewer.insertYamlCompletion(expressionItem, false, "          ");
+
+        // cursor now sits on the auto-inserted (whitespace-only, but real) 
child line
+        String childLine = 
viewer.editState().getLine(viewer.editState().cursorRow());
+        AutocompletePopup.CompletionItem constantItem = new 
AutocompletePopup.CompletionItem(
+                "constant", "A fixed value", "object", null, false, null, 
"language,core");
+        viewer.insertYamlCompletion(constantItem, false, childLine);
+
+        String result = viewer.editState().text();
+        assertThat(result).contains("          expression:\n            
constant:");
+    }
+
+    @Test
+    void shiftTabMovesCursorToPreviousIndentStop() throws IOException {
+        String yaml = String.join("\n",
+                "- from:",
+                "    uri: timer:tick",
+                "    steps:",
+                "      - setVariable:",
+                "          name: cheese",
+                "          expression:",
+                "            ",
+                "");
+
+        Path file = tempDir.resolve("route.camel.yaml");
+        Files.writeString(file, yaml);
+
+        SourceViewer viewer = new SourceViewer();
+        viewer.loadFile(file);
+        viewer.enterEditMode();
+        viewer.editState().moveCursorToStart();
+        for (int i = 0; i < 6; i++) {
+            viewer.editState().moveCursorDown();
+        }
+        viewer.editState().moveCursorToLineEnd();
+        assertThat(viewer.editState().cursorCol()).isEqualTo(12);
+        String before = viewer.editState().text();
+
+        viewer.handleKeyEvent(dev.tamboui.tui.event.KeyEvent.ofKey(
+                dev.tamboui.tui.event.KeyCode.TAB, 
dev.tamboui.tui.event.KeyModifiers.SHIFT));
+        assertThat(viewer.editState().cursorRow()).isEqualTo(6);
+        assertThat(viewer.editState().cursorCol()).isEqualTo(10);
+
+        // second Shift+Tab dedents past the "name"/"expression" siblings 
(indent 10) to the
+        // enclosing "- setVariable:" line's own indent
+        viewer.handleKeyEvent(dev.tamboui.tui.event.KeyEvent.ofKey(
+                dev.tamboui.tui.event.KeyCode.TAB, 
dev.tamboui.tui.event.KeyModifiers.SHIFT));
+        assertThat(viewer.editState().cursorCol()).isEqualTo(6);
+
+        // buffer content must be untouched — this is cursor movement only
+        assertThat(viewer.editState().text()).isEqualTo(before);
+    }
+
+    @Test
+    void insertListItemUsesDedentedCursorColumnNotStaleLineLength() throws 
IOException {
+        // reproduces inserting a "steps" EIP (e.g. "bean") as a list item 
after Shift+Tab
+        // dedented the cursor within a longer, pre-existing whitespace-only 
line: the insert
+        // must land at the cursor's column, not at the stale, deeper length 
of that line
+        String yaml = String.join("\n",
+                "- from:",
+                "    uri: timer:tick",
+                "    steps:",
+                "      - setVariable:",
+                "          name: cheese",
+                "          expression:",
+                "            ",
+                "");
+
+        Path file = tempDir.resolve("route.camel.yaml");
+        Files.writeString(file, yaml);
+
+        SourceViewer viewer = new SourceViewer();
+        viewer.loadFile(file);
+        viewer.enterEditMode();
+        viewer.setListItemNodeChecker(key -> "steps".equals(key) || 
"root".equals(key));
+        viewer.editState().moveCursorToStart();
+        for (int i = 0; i < 6; i++) {
+            viewer.editState().moveCursorDown();
+        }
+        viewer.editState().moveCursorToLineEnd();
+
+        // dedent to the "- setVariable:" list-item indent (6), without 
touching the buffer
+        viewer.handleKeyEvent(dev.tamboui.tui.event.KeyEvent.ofKey(
+                dev.tamboui.tui.event.KeyCode.TAB, 
dev.tamboui.tui.event.KeyModifiers.SHIFT));
+        viewer.handleKeyEvent(dev.tamboui.tui.event.KeyEvent.ofKey(
+                dev.tamboui.tui.event.KeyCode.TAB, 
dev.tamboui.tui.event.KeyModifiers.SHIFT));
+        assertThat(viewer.editState().cursorCol()).isEqualTo(6);
+        String currentLine = 
viewer.editState().getLine(viewer.editState().cursorRow());
+        assertThat(currentLine.length()).isEqualTo(12);
+
+        AutocompletePopup.CompletionItem beanItem = new 
AutocompletePopup.CompletionItem(
+                "bean", "Invokes a method on a bean", "object", null, false, 
null, "eip,endpoint");
+        viewer.insertYamlCompletion(beanItem, false, currentLine, true, 
viewer.editState().cursorCol());
+
+        String result = viewer.editState().text();
+        assertThat(result).contains("      - bean:");
+        assertThat(result).doesNotContain("            - bean:");
+    }
+
+    @Test
+    void autoInsertedParametersBlockAlignsWithUriIndent() throws IOException {
+        // reproduces the auto-inserted "parameters:" block landing one level 
too deep relative
+        // to "uri:" (it must be a sibling), which then broke 
findEnclosingComponent's
+        // uri-sibling lookup for every parameter added afterward
+        String yaml = String.join("\n",
+                "- route:",
+                "    from:",
+                "      uri: timer:tick",
+                "      steps:",
+                "        - to:",
+                "            uri: file:xxx",
+                "            ",
+                "");
+
+        Path file = tempDir.resolve("route.camel.yaml");
+        Files.writeString(file, yaml);
+
+        SourceViewer viewer = new SourceViewer();
+        viewer.loadFile(file);
+        viewer.enterEditMode();
+        viewer.setAutocompleteProvider(context -> List.of());
+        viewer.editState().moveCursorToStart();
+        for (int i = 0; i < 6; i++) {
+            viewer.editState().moveCursorDown();
+        }
+        viewer.editState().moveCursorToLineEnd();
+
+        viewer.handleKeyEvent(dev.tamboui.tui.event.KeyEvent.ofKey(
+                dev.tamboui.tui.event.KeyCode.TAB, 
dev.tamboui.tui.event.KeyModifiers.NONE));
+
+        String result = viewer.editState().text();
+        assertThat(result).contains("            uri: file:xxx\n            
parameters:");
+        assertThat(result).doesNotContain("              parameters:");
+    }
+
+    @Test
+    void shiftTabIsNoOpWhenLineHasTypedContent() throws IOException {
+        String yaml = String.join("\n",
+                "- from:",
+                "    uri: timer:tick",
+                "    steps:",
+                "      - setVariable:",
+                "          name: cheese",
+                "");
+
+        Path file = tempDir.resolve("route.camel.yaml");
+        Files.writeString(file, yaml);
+
+        SourceViewer viewer = new SourceViewer();
+        viewer.loadFile(file);
+        viewer.enterEditMode();
+        viewer.editState().moveCursorToStart();
+        for (int i = 0; i < 4; i++) {
+            viewer.editState().moveCursorDown();
+        }
+        viewer.editState().moveCursorToLineEnd();
+        int colBefore = viewer.editState().cursorCol();
+        String before = viewer.editState().text();
+
+        viewer.handleKeyEvent(dev.tamboui.tui.event.KeyEvent.ofKey(
+                dev.tamboui.tui.event.KeyCode.TAB, 
dev.tamboui.tui.event.KeyModifiers.SHIFT));
+
+        assertThat(viewer.editState().cursorCol()).isEqualTo(colBefore);
+        assertThat(viewer.editState().text()).isEqualTo(before);
+    }
+
     // --- Helpers that replicate SourceTab logic for testing ---
 
     private List<AutocompletePopup.CompletionItem> 
provideKeyCompletions(String componentName, String role) {

Reply via email to