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) {
