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 353c25b5bcbcb9ca2f3660a22f78a8ac751649a4 Author: Claus Ibsen <[email protected]> AuthorDate: Wed Sep 2 11:27:11 2026 +0200 CAMEL-24393: Smart paste with auto-indent in TUI source editor Enable bracketed paste in the TUI so multi-line pastes arrive as a single PasteEvent, and auto-indent pasted YAML blocks to match the surrounding route structure in the source editor: - Enable bracketedPaste on the TuiRunner (TuiBackendHelper). - Wire paste through adjustPasteIndent/reindentBlock so pasted blocks are reindented to the target column while preserving relative indentation. - Normalize \r\n and bare \r line endings so pastes are not collapsed into a single line. - Align a paste inserted before an existing line with that line's own indent. - On a blank line (including one carrying ENTER auto-indent whitespace), infer the block indent from the surrounding context and align a pasted list item with the nearest sibling step; strip the auto-indent whitespace first so it is not stacked on top of the reindented block. Co-Authored-By: Claude Opus 4.8 <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../dsl/jbang/core/commands/tui/SourceViewer.java | 92 +++++++++++++++++----- .../jbang/core/commands/tui/TuiBackendHelper.java | 3 +- .../commands/tui/SourceViewerEditorOpsTest.java | 92 ++++++++++++++++++++++ .../commands/tui/SourceViewerPasteIndentTest.java | 15 ++++ 4 files changed, 183 insertions(+), 19 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 07a4fdf13369..bd40a71105bf 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 @@ -1585,29 +1585,64 @@ class SourceViewer { } private String adjustPasteIndent(String text, int cursorRow) { - int targetIndent = editState.cursorCol(); - // when cursor is at col 0, infer indent from the previous non-blank line - if (targetIndent == 0) { - for (int i = cursorRow - 1; i >= 0; i--) { - String l = editState.getLine(i); - if (!l.isBlank()) { - targetIndent = countLeadingSpaces(l); - String trimmed = l.trim(); - if (trimmed.startsWith("- ")) { - trimmed = trimmed.substring(2).trim(); - } - // if previous line is a parent key, indent children deeper - if (trimmed.endsWith(":")) { - targetIndent += 2; - } - break; + String current = editState.getLine(cursorRow); + int targetIndent; + if (current == null || current.isBlank()) { + // on a blank line (including one carrying ENTER auto-indent whitespace, which handlePaste + // strips before inserting): infer the block indent from the context above the cursor and + // apply it to every pasted line + int fallback = current == null ? 0 : countLeadingSpaces(current); + targetIndent = inferBlankLineIndent(text, cursorRow, fallback); + } else if (editState.cursorCol() == 0) { + // inserting before an existing line: match that line's own indent + targetIndent = countLeadingSpaces(current); + } else { + // pasting into the middle of existing content: keep the cursor column + targetIndent = editState.cursorCol(); + } + return reindentBlock(text, targetIndent); + } + + private int inferBlankLineIndent(String text, int cursorRow, int fallback) { + int prevIndent = -1; + boolean prevIsParentKey = false; + int listIndent = -1; + for (int i = cursorRow - 1; i >= 0; i--) { + String l = editState.getLine(i); + if (l.isBlank()) { + continue; + } + if (prevIndent < 0) { + // nearest non-blank line: its indent, and whether it opens a child block + prevIndent = countLeadingSpaces(l); + String t = l.trim(); + if (t.startsWith("- ")) { + t = t.substring(2).trim(); } + prevIsParentKey = t.endsWith(":"); + } + if (l.trim().startsWith("- ")) { + // nearest existing list item — the sibling level for a pasted list item + listIndent = countLeadingSpaces(l); + break; } } - return reindentBlock(text, targetIndent); + String firstTrimmed = firstNonBlankTrimmed(text); + boolean pasteIsListItem = firstTrimmed.startsWith("- ") || firstTrimmed.equals("-"); + if (pasteIsListItem && listIndent >= 0) { + // align a pasted step with the nearest existing sibling step + return listIndent; + } else if (prevIndent >= 0) { + // otherwise follow the previous line, indenting deeper under a parent key + return prevIndent + (prevIsParentKey ? 2 : 0); + } + return fallback; } static String reindentBlock(String text, int targetIndent) { + // normalize line endings: some terminals deliver pasted line breaks as \r\n or bare \r, + // which would otherwise collapse a multi-line paste into a single line + text = text.replace("\r\n", "\n").replace('\r', '\n'); text = text.replace("\t", " "); String[] pasteLines = text.split("\n", -1); int minIndent = Integer.MAX_VALUE; @@ -1641,6 +1676,15 @@ class SourceViewer { return sb.toString(); } + private static String firstNonBlankTrimmed(String text) { + for (String line : text.split("\r\n|\r|\n", -1)) { + if (!line.isBlank()) { + return line.trim(); + } + } + return ""; + } + private static int countLeadingSpaces(String line) { int count = 0; for (int i = 0; i < line.length(); i++) { @@ -2366,7 +2410,19 @@ class SourceViewer { } if (text != null && !text.isEmpty()) { recordEditChange(); - editState.insert(text); + int row = editState.cursorRow(); + String current = editState.getLine(row); + String adjusted = adjustPasteIndent(text, row); + if (current != null && current.isBlank() && !current.isEmpty()) { + // strip the blank line's leading whitespace (e.g. from ENTER auto-indent) so the + // reindented block's own indent is not stacked on top of it + editState.moveCursorToLineStart(); + int n = current.length(); + for (int i = 0; i < n; i++) { + editState.deleteForward(); + } + } + editState.insert(adjusted); } return; } diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiBackendHelper.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiBackendHelper.java index bbb0b052957f..2b5fa294e30f 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiBackendHelper.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiBackendHelper.java @@ -41,7 +41,8 @@ final class TuiBackendHelper { } static TuiRunner createTuiRunner(Backend backend) throws Exception { - return TuiRunner.create(TuiConfig.builder().backend(applyRecording(backend)).mouseCapture(true).build()); + return TuiRunner.create( + TuiConfig.builder().backend(applyRecording(backend)).mouseCapture(true).bracketedPaste(true).build()); } /** diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewerEditorOpsTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewerEditorOpsTest.java index cdbaec126b85..027c81702304 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewerEditorOpsTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewerEditorOpsTest.java @@ -151,6 +151,98 @@ class SourceViewerEditorOpsTest { assertThat(footer).contains("Ctrl+K"); } + @Test + void pasteAutoIndentsToMatchSurroundingBlock() { + // place cursor at column 0 of the "log:warn" line; its siblings are indented 8 spaces + String[] lines = viewer.editText().split("\n", -1); + int row = -1; + for (int i = 0; i < lines.length; i++) { + if (lines[i].contains("log:warn")) { + row = i; + break; + } + } + assertThat(row).isGreaterThanOrEqualTo(0); + SourceEditorNavigation.positionCursor(viewer.editState(), row, 0); + + // paste an unindented step; it should be reindented to align with the 8-space siblings + viewer.handlePaste("- to: log:error\n"); + + assertThat(viewer.editText()).contains(" - to: log:error\n"); + } + + @Test + void pasteAlignsWithCurrentLineNotDeeperPredecessor() throws Exception { + // a "- log:" step (indent 8) preceded by a far deeper line (indent 16); pasting before it + // must align with the step's own indent, not the deeper predecessor + Path nested = tempDir.resolve("nested.camel.yaml"); + Files.writeString(nested, """ + - route: + from: + uri: timer:tick + steps: + - setBody: + expression: + simple: + expression: "hi" + - log: + message: "x" + """, StandardCharsets.UTF_8); + viewer.loadFile(nested); + viewer.enterEditMode(); + + String[] lines = viewer.editText().split("\n", -1); + int row = -1; + for (int i = 0; i < lines.length; i++) { + if (lines[i].contains("- log:")) { + row = i; + break; + } + } + assertThat(row).isGreaterThanOrEqualTo(0); + SourceEditorNavigation.positionCursor(viewer.editState(), row, 0); + + viewer.handlePaste("- to:\n uri: mock:dead\n"); + + assertThat(viewer.editText()).contains(" - to:\n uri: mock:dead\n - log:"); + } + + @Test + void pasteListItemOnBlankLineAlignsWithNearestSiblingStep() throws Exception { + // last line is a deep leaf (indent 12); pasting a step on a blank line after it must align + // with the nearest sibling step (indent 8), not the deeper leaf above it + Path nested = tempDir.resolve("nested-append.camel.yaml"); + Files.writeString(nested, """ + - route: + from: + uri: timer:tick + steps: + - log: + message: "${body}" + """, StandardCharsets.UTF_8); + viewer.loadFile(nested); + viewer.enterEditMode(); + + // place the cursor at the end of the deep leaf line (message:, indent 12) and press ENTER — + // the editor auto-indents the new line to col 12; pasting a step must still align it with the + // nearest sibling step (indent 8), not the auto-indent column + int row = -1; + String[] lines = viewer.editText().split("\n", -1); + for (int i = 0; i < lines.length; i++) { + if (lines[i].contains("message:")) { + row = i; + break; + } + } + assertThat(row).isGreaterThanOrEqualTo(0); + SourceEditorNavigation.positionCursor(viewer.editState(), row, viewer.editState().getLine(row).length()); + viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER, KeyModifiers.NONE)); + + viewer.handlePaste("- to:\n uri: mock:dead\n"); + + assertThat(viewer.editText()).contains(" - to:\n uri: mock:dead"); + } + private void moveCursorToLineContaining(String needle) { String[] lines = viewer.editText().split("\n", -1); for (int row = 0; row < lines.length; row++) { diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewerPasteIndentTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewerPasteIndentTest.java index 8ba71db33bc1..41bef860d4d4 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewerPasteIndentTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewerPasteIndentTest.java @@ -88,6 +88,21 @@ class SourceViewerPasteIndentTest { assertThat(result).isEqualTo("- log:\n message: Hello"); } + @Test + void carriageReturnLineEndingsNormalized() { + // bare \r line endings (some terminals) must not collapse into a single line + String paste = "- to:\r uri: mock:dead"; + String result = SourceViewer.reindentBlock(paste, 8); + assertThat(result).isEqualTo(" - to:\n uri: mock:dead"); + } + + @Test + void crlfLineEndingsNormalized() { + String paste = "- to:\r\n uri: mock:dead"; + String result = SourceViewer.reindentBlock(paste, 8); + assertThat(result).isEqualTo(" - to:\n uri: mock:dead"); + } + @Test void trailingNewlinePreserved() { String paste = "- log:\n message: Hello\n";
