This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch feature/CAMEL-24372-tui-yaml-editor in repository https://gitbox.apache.org/repos/asf/camel.git
commit 8abdb6e0de5f4de8e03d86c00662a423c93f92e2 Author: Cursor Agent <[email protected]> AuthorDate: Sat Aug 8 03:23:33 2026 +0000 CAMEL-24372: TUI YAML editor undo/redo, block ops, word nav, find in edit Add SourceEditHistory, YamlBlockEditor, and SourceEditorNavigation helpers and wire them into SourceViewer edit mode for undo/redo (Ctrl+Z/Y), YAML block move/duplicate/delete (Alt+arrows, Ctrl+D/K), comment toggle (Ctrl+/), word navigation/delete, smart Home, and find while editing. Includes comprehensive unit and integration tests for the new editor ops. Co-authored-by: Omar Atie <[email protected]> --- .../jbang/core/commands/tui/SearchHighlighter.java | 35 +++ .../jbang/core/commands/tui/SourceEditHistory.java | 102 ++++++++ .../core/commands/tui/SourceEditorNavigation.java | 134 +++++++++++ .../dsl/jbang/core/commands/tui/SourceViewer.java | 135 ++++++++++- .../jbang/core/commands/tui/YamlBlockEditor.java | 265 +++++++++++++++++++++ .../core/commands/tui/SourceEditHistoryTest.java | 87 +++++++ .../commands/tui/SourceEditorNavigationTest.java | 95 ++++++++ .../commands/tui/SourceViewerEditorOpsTest.java | 192 +++++++++++++++ .../core/commands/tui/YamlBlockEditorTest.java | 160 +++++++++++++ 9 files changed, 1197 insertions(+), 8 deletions(-) diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SearchHighlighter.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SearchHighlighter.java index 9bdae7aa3b97..b23e95e273d1 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SearchHighlighter.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SearchHighlighter.java @@ -312,4 +312,39 @@ class SearchHighlighter { highlightTerm = null; highlightPattern = null; } + + /** Closes an active search input without clearing an existing find term. */ + void closeInputOnly() { + findInputActive = false; + highlightInputActive = false; + searchInputState = new TextInputState(""); + } + + void openFindInput() { + findInputActive = true; + highlightInputActive = false; + searchInputState = new TextInputState(findTerm != null ? findTerm : ""); + } + + /** + * Find navigation while editing plain text (Ctrl+F opens find, n/N step matches). + */ + boolean handleEditFindKeyEvent(KeyEvent ke) { + if (findInputActive || highlightInputActive) { + return handleSearchInput(ke); + } + if (ke.hasCtrl() && ke.isChar('f')) { + openFindInput(); + return true; + } + if (findTerm != null && ke.isChar('n') && !ke.hasCtrl() && !ke.hasAlt()) { + if (ke.hasShift()) { + navigateToPrevMatch(); + } else { + navigateToNextMatch(); + } + return true; + } + return false; + } } diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditHistory.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditHistory.java new file mode 100644 index 000000000000..255d8cc3e23e --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditHistory.java @@ -0,0 +1,102 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.dsl.jbang.core.commands.tui; + +import java.util.ArrayDeque; +import java.util.Deque; + +import dev.tamboui.widgets.input.TextAreaState; + +/** + * Undo/redo stack for {@link SourceViewer} plain-text edit mode. + */ +final class SourceEditHistory { + + private static final int MAX_DEPTH = 100; + + record Snapshot(String text, int row, int col) { + } + + private final Deque<Snapshot> undo = new ArrayDeque<>(); + private final Deque<Snapshot> redo = new ArrayDeque<>(); + + void clear() { + undo.clear(); + redo.clear(); + } + + void seedInitial(TextAreaState state) { + clear(); + undo.push(capture(state)); + } + + void beforeChange(TextAreaState state) { + Snapshot snap = capture(state); + undo.push(snap); + trim(undo); + redo.clear(); + } + + boolean undo(TextAreaState state) { + if (undo.size() <= 1) { + return false; + } + redo.push(capture(state)); + undo.pop(); + restore(state, undo.peek()); + return true; + } + + boolean redo(TextAreaState state) { + if (redo.isEmpty()) { + return false; + } + Snapshot next = redo.pop(); + undo.push(capture(state)); + trim(undo); + restore(state, next); + return true; + } + + static Snapshot capture(TextAreaState state) { + return new Snapshot(state.text(), state.cursorRow(), state.cursorCol()); + } + + private static void restore(TextAreaState state, Snapshot snapshot) { + state.setText(snapshot.text()); + positionCursor(state, snapshot.row(), snapshot.col()); + } + + static void positionCursor(TextAreaState state, int row, int col) { + state.moveCursorToStart(); + for (int i = 0; i < row && i < state.lineCount(); i++) { + state.moveCursorDown(); + } + state.moveCursorToLineStart(); + String line = state.getLine(state.cursorRow()); + int target = Math.min(col, line.length()); + for (int i = 0; i < target; i++) { + state.moveCursorRight(); + } + } + + private static void trim(Deque<Snapshot> stack) { + while (stack.size() > MAX_DEPTH) { + stack.removeLast(); + } + } +} diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditorNavigation.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditorNavigation.java new file mode 100644 index 000000000000..b7ec008e3d58 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditorNavigation.java @@ -0,0 +1,134 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.dsl.jbang.core.commands.tui; + +import dev.tamboui.widgets.input.TextAreaState; + +/** + * Word navigation and smart-home helpers for the source editor. + */ +final class SourceEditorNavigation { + + private SourceEditorNavigation() { + } + + static boolean isWordChar(char ch) { + return Character.isLetterOrDigit(ch) || ch == '_' || ch == '-' || ch == '.'; + } + + static int wordBoundaryLeft(String line, int col) { + if (line.isEmpty() || col <= 0) { + return 0; + } + int pos = Math.min(col, line.length()); + while (pos > 0 && !isWordChar(line.charAt(pos - 1))) { + pos--; + } + while (pos > 0 && isWordChar(line.charAt(pos - 1))) { + pos--; + } + return pos; + } + + static int wordBoundaryRight(String line, int col) { + if (line.isEmpty()) { + return 0; + } + int pos = Math.min(col, line.length()); + while (pos < line.length() && !isWordChar(line.charAt(pos))) { + pos++; + } + while (pos < line.length() && isWordChar(line.charAt(pos))) { + pos++; + } + return pos; + } + + static void moveWordLeft(TextAreaState state) { + String line = state.getLine(state.cursorRow()); + int col = state.cursorCol(); + int target = wordBoundaryLeft(line, col); + if (target == col && col > 0) { + target = wordBoundaryLeft(line, col - 1); + } + SourceEditHistory.positionCursor(state, state.cursorRow(), target); + } + + static void moveWordRight(TextAreaState state) { + String line = state.getLine(state.cursorRow()); + int col = state.cursorCol(); + int target = wordBoundaryRight(line, col); + if (target == col && col < line.length()) { + target = wordBoundaryRight(line, col + 1); + } + SourceEditHistory.positionCursor(state, state.cursorRow(), target); + } + + static void deleteWordBackward(TextAreaState state) { + String line = state.getLine(state.cursorRow()); + int col = state.cursorCol(); + if (col == 0) { + if (state.cursorRow() > 0) { + state.deleteBackward(); + } + return; + } + int start = wordBoundaryLeft(line, col); + String prefix = line.substring(0, start); + String suffix = line.substring(col); + replaceLine(state, prefix + suffix, start); + } + + static void deleteWordForward(TextAreaState state) { + String line = state.getLine(state.cursorRow()); + int col = state.cursorCol(); + if (col >= line.length()) { + state.deleteForward(); + return; + } + int end = wordBoundaryRight(line, col); + String prefix = line.substring(0, col); + String suffix = line.substring(end); + replaceLine(state, prefix + suffix, col); + } + + static void smartHome(TextAreaState state, boolean toAbsoluteStart) { + String line = state.getLine(state.cursorRow()); + int contentStart = 0; + while (contentStart < line.length() && line.charAt(contentStart) == ' ') { + contentStart++; + } + if (toAbsoluteStart || state.cursorCol() <= contentStart) { + SourceEditHistory.positionCursor(state, state.cursorRow(), 0); + } else { + SourceEditHistory.positionCursor(state, state.cursorRow(), contentStart); + } + } + + private static void replaceLine(TextAreaState state, String newLine, int cursorCol) { + int row = state.cursorRow(); + StringBuilder text = new StringBuilder(); + for (int i = 0; i < state.lineCount(); i++) { + if (i > 0) { + text.append('\n'); + } + text.append(i == row ? newLine : state.getLine(i)); + } + state.setText(text.toString()); + SourceEditHistory.positionCursor(state, row, Math.min(cursorCol, newLine.length())); + } +} 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 7735e9e97ef4..efbc6066c622 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 @@ -160,6 +160,7 @@ class SourceViewer { private EndpointValidator endpointValidator; private List<String> validationErrors; private int validationErrorScroll; + private final SourceEditHistory editHistory = new SourceEditHistory(); private record CachedSource( List<String> lines, List<JsonObject> codeData, @@ -519,6 +520,43 @@ class SourceViewer { return true; } + private void recordEditChange() { + editHistory.beforeChange(editState); + dirty = true; + } + + private List<String> editLines() { + List<String> answer = new ArrayList<>(editState.lineCount()); + for (int i = 0; i < editState.lineCount(); i++) { + answer.add(editState.getLine(i)); + } + return answer; + } + + private void applyBlockEdit(YamlBlockEditor.EditResult result) { + if (result == null) { + return; + } + recordEditChange(); + editState.setText(YamlBlockEditor.fromLines(result.lines())); + SourceEditHistory.positionCursor(editState, result.cursorRow(), result.cursorCol()); + } + + private void refreshEditFindMatches() { + search.buildFindMatches(editLines()); + } + + private void jumpEditToCurrentFindMatch() { + int line = search.currentMatchLine(); + if (line >= 0) { + SourceEditHistory.positionCursor(editState, line, 0); + } + } + + String editText() { + return editState.text(); + } + private boolean handleEditKeyEvent(KeyEvent ke) { if (validationErrors != null) { if (ke.isCancel() || ke.isKey(KeyCode.ENTER)) { @@ -547,6 +585,72 @@ class SourceViewer { } return true; } + if (search.handleEditFindKeyEvent(ke)) { + if (!search.isSearchInputActive()) { + refreshEditFindMatches(); + jumpEditToCurrentFindMatch(); + } + return true; + } + if (ke.hasCtrl() && ke.isChar('z') && !ke.hasShift()) { + if (editHistory.undo(editState)) { + dirty = true; + refreshEditFindMatches(); + } + return true; + } + if (ke.hasCtrl() && (ke.isChar('y') || (ke.isChar('z') && ke.hasShift()))) { + if (editHistory.redo(editState)) { + dirty = true; + refreshEditFindMatches(); + } + return true; + } + boolean yamlListBlocks = isCamelYamlFile(); + if (ke.hasAlt() && ke.isUp() && !ke.hasShift()) { + applyBlockEdit(YamlBlockEditor.moveBlockUp(editLines(), editState.cursorRow(), yamlListBlocks)); + return true; + } + if (ke.hasAlt() && ke.isDown() && !ke.hasShift()) { + applyBlockEdit(YamlBlockEditor.moveBlockDown(editLines(), editState.cursorRow(), yamlListBlocks)); + return true; + } + if (ke.hasCtrl() && ke.isChar('d') && !ke.hasShift()) { + applyBlockEdit(YamlBlockEditor.duplicateBlock(editLines(), editState.cursorRow(), yamlListBlocks)); + return true; + } + if (ke.hasCtrl() && ke.hasShift() && (ke.isChar('k') || ke.isChar('K'))) { + applyBlockEdit(YamlBlockEditor.deleteBlock(editLines(), editState.cursorRow(), yamlListBlocks)); + return true; + } + if (ke.hasCtrl() && ke.isChar('/')) { + YamlBlockEditor.BlockRange block + = YamlBlockEditor.findBlock(editLines(), editState.cursorRow(), yamlListBlocks); + recordEditChange(); + List<String> toggled = YamlBlockEditor.toggleComment(editLines(), block); + editState.setText(YamlBlockEditor.fromLines(toggled)); + SourceEditHistory.positionCursor(editState, block.startRow(), + YamlBlockEditor.leadingSpaces(toggled.get(block.startRow()))); + return true; + } + if (ke.hasCtrl() && ke.isLeft()) { + SourceEditorNavigation.moveWordLeft(editState); + return true; + } + if (ke.hasCtrl() && ke.isRight()) { + SourceEditorNavigation.moveWordRight(editState); + return true; + } + if (ke.hasCtrl() && ke.isDeleteBackward()) { + recordEditChange(); + SourceEditorNavigation.deleteWordBackward(editState); + return true; + } + if (ke.hasCtrl() && ke.isDeleteForward()) { + recordEditChange(); + SourceEditorNavigation.deleteWordForward(editState); + return true; + } if (pendingDiscard) { if (ke.isConfirm()) { pendingDiscard = false; @@ -573,6 +677,7 @@ class SourceViewer { return true; } if (ke.isConfirm()) { + recordEditChange(); int prevRow = editState.cursorRow(); String prevLine = editState.getLine(prevRow); int indent = countLeadingSpaces(prevLine); @@ -593,7 +698,6 @@ class SourceViewer { } else if (indent > 0) { editState.insert(" ".repeat(indent)); } - dirty = true; return true; } if (ke.isUp()) { @@ -613,7 +717,13 @@ class SourceViewer { return true; } if (ke.isHome() || ke.isKey(KeyCode.HOME)) { - editState.moveCursorToLineStart(); + String line = editState.getLine(editState.cursorRow()); + int contentStart = YamlBlockEditor.leadingSpaces(line); + if (editState.cursorCol() > contentStart) { + SourceEditHistory.positionCursor(editState, editState.cursorRow(), contentStart); + } else { + SourceEditHistory.positionCursor(editState, editState.cursorRow(), 0); + } return true; } if (ke.isEnd() || ke.isKey(KeyCode.END)) { @@ -635,13 +745,13 @@ class SourceViewer { return true; } if (ke.isDeleteBackward()) { + recordEditChange(); editState.deleteBackward(); - dirty = true; return true; } if (ke.isDeleteForward()) { + recordEditChange(); editState.deleteForward(); - dirty = true; return true; } if (ke.isKey(KeyCode.TAB) && autocompleteProvider != null) { @@ -649,8 +759,8 @@ class SourceViewer { return true; } if (ke.code() == KeyCode.CHAR && !ke.hasCtrl() && !ke.hasAlt()) { + recordEditChange(); editState.insert(ke.character()); - dirty = true; return true; } return true; @@ -670,16 +780,19 @@ class SourceViewer { markdownModeBeforeEdit = markdownMode; markdownMode = false; quickDocEnabled = false; - search.reset(); + search.closeInputOnly(); dirty = false; validationErrors = null; editMode = true; + editHistory.seedInitial(editState); + refreshEditFindMatches(); } private void exitEditMode() { boolean wasEditing = editMode; editMode = false; editState.clear(); + editHistory.clear(); autocompletePopup = null; validationErrors = null; pendingDiscard = false; @@ -1531,7 +1644,7 @@ class SourceViewer { } private void insertCompletion(AutocompletePopup.CompletionItem item, boolean valueMode, boolean listItem) { - dirty = true; + recordEditChange(); String currentLine = editState.getLine(editState.cursorRow()); if (isCamelYamlFile()) { insertYamlCompletion(item, valueMode, currentLine, listItem); @@ -1859,8 +1972,8 @@ class SourceViewer { void handlePaste(String text) { if (editMode) { if (text != null && !text.isEmpty()) { + recordEditChange(); editState.insert(text); - dirty = true; } return; } @@ -2214,10 +2327,16 @@ class SourceViewer { TuiHelper.hint(spans, "Esc", "cancel"); TuiHelper.hint(spans, "F5", "save & close"); TuiHelper.hint(spans, "Shift+F5", "save"); + TuiHelper.hint(spans, "Ctrl+Z", "undo"); + TuiHelper.hint(spans, "Alt+↑/↓", "move block"); + TuiHelper.hint(spans, "Ctrl+D", "duplicate"); + TuiHelper.hint(spans, "Ctrl+/", "comment"); + TuiHelper.hint(spans, "Ctrl+F", "find"); if (autocompleteProvider != null) { TuiHelper.hint(spans, "Tab", "complete"); } TuiHelper.hint(spans, TuiIcons.HINT_SCROLL, "move"); + search.renderFindStatus(spans); return; } if (markdownMode) { diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/YamlBlockEditor.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/YamlBlockEditor.java new file mode 100644 index 000000000000..425179c66d48 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/YamlBlockEditor.java @@ -0,0 +1,265 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.dsl.jbang.core.commands.tui; + +import java.util.ArrayList; +import java.util.List; + +/** + * YAML-structure-aware block operations for the TUI source editor. + */ +final class YamlBlockEditor { + + record BlockRange(int startRow, int endRow) { + boolean isEmpty() { + return startRow < 0 || endRow < startRow; + } + } + + record EditResult(List<String> lines, int cursorRow, int cursorCol) { + } + + private YamlBlockEditor() { + } + + static List<String> toLines(String text) { + if (text.isEmpty()) { + return new ArrayList<>(List.of("")); + } + return new ArrayList<>(List.of(text.split("\n", -1))); + } + + static String fromLines(List<String> lines) { + return String.join("\n", lines); + } + + static BlockRange findBlock(List<String> lines, int row, boolean yamlListBlocks) { + if (lines.isEmpty() || row < 0 || row >= lines.size()) { + return new BlockRange(row, row); + } + if (!yamlListBlocks) { + return new BlockRange(row, row); + } + int startRow = findBlockStart(lines, row); + int blockIndent = leadingSpaces(lines.get(startRow)); + int endRow = findBlockEnd(lines, startRow, blockIndent); + return new BlockRange(startRow, endRow); + } + + static EditResult deleteBlock(List<String> lines, int row, boolean yamlListBlocks) { + BlockRange block = findBlock(lines, row, yamlListBlocks); + if (block.isEmpty()) { + return new EditResult(lines, row, 0); + } + List<String> answer = new ArrayList<>(lines); + answer.subList(block.startRow(), block.endRow() + 1).clear(); + if (answer.isEmpty()) { + answer.add(""); + } + int cursorRow = Math.min(block.startRow(), answer.size() - 1); + return new EditResult(answer, cursorRow, leadingSpaces(answer.get(cursorRow))); + } + + static EditResult duplicateBlock(List<String> lines, int row, boolean yamlListBlocks) { + BlockRange block = findBlock(lines, row, yamlListBlocks); + if (block.isEmpty()) { + return new EditResult(lines, row, 0); + } + List<String> copy = new ArrayList<>(lines.subList(block.startRow(), block.endRow() + 1)); + List<String> answer = new ArrayList<>(lines); + answer.addAll(block.endRow() + 1, copy); + return new EditResult(answer, block.endRow() + 1, leadingSpaces(copy.get(0))); + } + + static EditResult moveBlockUp(List<String> lines, int row, boolean yamlListBlocks) { + BlockRange block = findBlock(lines, row, yamlListBlocks); + if (block.isEmpty() || block.startRow() == 0) { + return null; + } + BlockRange previous = findPreviousSibling(lines, block, yamlListBlocks); + if (previous == null || previous.isEmpty()) { + return null; + } + return swapBlocks(lines, previous, block); + } + + static EditResult moveBlockDown(List<String> lines, int row, boolean yamlListBlocks) { + BlockRange block = findBlock(lines, row, yamlListBlocks); + if (block.isEmpty() || block.endRow() >= lines.size() - 1) { + return null; + } + BlockRange next = findNextSibling(lines, block, yamlListBlocks); + if (next == null || next.isEmpty()) { + return null; + } + return swapBlocks(lines, block, next); + } + + static List<String> toggleComment(List<String> lines, BlockRange block) { + List<String> answer = new ArrayList<>(lines); + boolean uncomment = true; + for (int i = block.startRow(); i <= block.endRow() && i < answer.size(); i++) { + String line = answer.get(i); + if (line.isBlank()) { + continue; + } + if (!isCommented(line)) { + uncomment = false; + break; + } + } + for (int i = block.startRow(); i <= block.endRow() && i < answer.size(); i++) { + answer.set(i, uncomment ? uncommentLine(answer.get(i)) : commentLine(answer.get(i))); + } + return answer; + } + + private static EditResult swapBlocks(List<String> lines, BlockRange first, BlockRange second) { + List<String> firstLines = new ArrayList<>(lines.subList(first.startRow(), first.endRow() + 1)); + List<String> secondLines = new ArrayList<>(lines.subList(second.startRow(), second.endRow() + 1)); + List<String> answer = new ArrayList<>(); + answer.addAll(lines.subList(0, first.startRow())); + answer.addAll(secondLines); + answer.addAll(lines.subList(first.endRow() + 1, second.startRow())); + answer.addAll(firstLines); + answer.addAll(lines.subList(second.endRow() + 1, lines.size())); + return new EditResult(answer, second.startRow(), leadingSpaces(secondLines.get(0))); + } + + private static BlockRange findPreviousSibling(List<String> lines, BlockRange block, boolean yamlListBlocks) { + if (!yamlListBlocks) { + if (block.startRow() == 0) { + return null; + } + return new BlockRange(block.startRow() - 1, block.startRow() - 1); + } + int blockIndent = leadingSpaces(lines.get(block.startRow())); + for (int i = block.startRow() - 1; i >= 0; i--) { + String line = lines.get(i); + if (line.isBlank()) { + continue; + } + int indent = leadingSpaces(line); + if (indent == blockIndent && line.trim().startsWith("- ")) { + return findBlock(lines, i, true); + } + if (indent < blockIndent) { + break; + } + } + return null; + } + + private static BlockRange findNextSibling(List<String> lines, BlockRange block, boolean yamlListBlocks) { + if (!yamlListBlocks) { + if (block.endRow() >= lines.size() - 1) { + return null; + } + return new BlockRange(block.endRow() + 1, block.endRow() + 1); + } + int blockIndent = leadingSpaces(lines.get(block.startRow())); + for (int i = block.endRow() + 1; i < lines.size(); i++) { + String line = lines.get(i); + if (line.isBlank()) { + continue; + } + int indent = leadingSpaces(line); + if (indent == blockIndent && line.trim().startsWith("- ")) { + return findBlock(lines, i, true); + } + if (indent < blockIndent) { + break; + } + } + return null; + } + + private static int findBlockStart(List<String> lines, int row) { + int cursorIndent = leadingSpaces(lines.get(row)); + for (int i = row; i >= 0; i--) { + String line = lines.get(i); + if (line.isBlank()) { + continue; + } + int indent = leadingSpaces(line); + if (line.trim().startsWith("- ") && indent <= cursorIndent) { + return i; + } + if (indent < cursorIndent) { + return Math.max(i, 0); + } + } + return row; + } + + private static int findBlockEnd(List<String> lines, int startRow, int blockIndent) { + int endRow = startRow; + for (int i = startRow + 1; i < lines.size(); i++) { + String line = lines.get(i); + if (line.isBlank()) { + endRow = i; + continue; + } + int indent = leadingSpaces(line); + if (indent <= blockIndent && (line.trim().startsWith("- ") || indent < blockIndent)) { + break; + } + endRow = i; + } + return endRow; + } + + static int leadingSpaces(String line) { + int count = 0; + for (int i = 0; i < line.length(); i++) { + if (line.charAt(i) == ' ') { + count++; + } else { + break; + } + } + return count; + } + + private static boolean isCommented(String line) { + String trimmed = line.stripLeading(); + return trimmed.startsWith("#"); + } + + private static String commentLine(String line) { + if (line.isBlank() || isCommented(line)) { + return line; + } + int indent = leadingSpaces(line); + return line.substring(0, indent) + "# " + line.substring(indent); + } + + private static String uncommentLine(String line) { + if (line.isBlank()) { + return line; + } + int indent = leadingSpaces(line); + String rest = line.substring(indent); + if (rest.startsWith("# ")) { + return line.substring(0, indent) + rest.substring(2); + } + if (rest.startsWith("#")) { + return line.substring(0, indent) + rest.substring(1); + } + return line; + } +} diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditHistoryTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditHistoryTest.java new file mode 100644 index 000000000000..7ceae2050117 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditHistoryTest.java @@ -0,0 +1,87 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.dsl.jbang.core.commands.tui; + +import dev.tamboui.widgets.input.TextAreaState; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for edit undo/redo history (CAMEL-24372). + */ +class SourceEditHistoryTest { + + private SourceEditHistory history; + private TextAreaState state; + + @BeforeEach + void setUp() { + history = new SourceEditHistory(); + state = new TextAreaState("alpha\nbeta\n"); + history.seedInitial(state); + } + + @Test + void undoRestoresPreviousSnapshot() { + history.beforeChange(state); + state.setText("alpha\nchanged\n"); + + assertThat(history.undo(state)).isTrue(); + assertThat(state.text()).isEqualTo("alpha\nbeta\n"); + } + + @Test + void redoReappliesUndoneChange() { + history.beforeChange(state); + state.setText("alpha\nchanged\n"); + history.undo(state); + + assertThat(history.redo(state)).isTrue(); + assertThat(state.text()).isEqualTo("alpha\nchanged\n"); + } + + @Test + void undoAtInitialStateReturnsFalse() { + assertThat(history.undo(state)).isFalse(); + } + + @Test + void redoWhenEmptyReturnsFalse() { + assertThat(history.redo(state)).isFalse(); + } + + @Test + void positionCursorRestoresRowAndColumn() { + SourceEditHistory.positionCursor(state, 1, 2); + + assertThat(state.cursorRow()).isEqualTo(1); + assertThat(state.cursorCol()).isEqualTo(2); + assertThat(state.getLine(1)).startsWith("be"); + } + + @Test + void clearEmptiesStacks() { + history.beforeChange(state); + state.insert('X'); + history.clear(); + history.seedInitial(state); + + assertThat(history.undo(state)).isFalse(); + } +} diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditorNavigationTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditorNavigationTest.java new file mode 100644 index 000000000000..7cc57d4f3243 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceEditorNavigationTest.java @@ -0,0 +1,95 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.dsl.jbang.core.commands.tui; + +import dev.tamboui.widgets.input.TextAreaState; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for word navigation and smart home (CAMEL-24372). + */ +class SourceEditorNavigationTest { + + private TextAreaState state; + + @BeforeEach + void setUp() { + state = new TextAreaState(" from: timer:tick\n"); + SourceEditHistory.positionCursor(state, 0, 14); + } + + @Test + void wordBoundaryLeftSkipsToPreviousToken() { + assertThat(SourceEditorNavigation.wordBoundaryLeft("hello world", 11)).isEqualTo(6); + assertThat(SourceEditorNavigation.wordBoundaryLeft("hello world", 0)).isZero(); + } + + @Test + void wordBoundaryRightSkipsToNextToken() { + assertThat(SourceEditorNavigation.wordBoundaryRight("hello world", 0)).isEqualTo(5); + assertThat(SourceEditorNavigation.wordBoundaryRight("hello world", 6)).isEqualTo(11); + } + + @Test + void colonSeparatesWordsInComponentNames() { + assertThat(SourceEditorNavigation.wordBoundaryLeft("timer:tick", 10)).isEqualTo(6); + assertThat(SourceEditorNavigation.wordBoundaryRight("timer:tick", 0)).isEqualTo(5); + assertThat(SourceEditorNavigation.wordBoundaryRight("timer:tick", 6)).isEqualTo(10); + } + + @Test + void moveWordLeftAndRightUpdateCursor() { + SourceEditorNavigation.moveWordRight(state); + assertThat(state.cursorCol()).isEqualTo(18); + + SourceEditorNavigation.moveWordLeft(state); + assertThat(state.cursorCol()).isEqualTo(14); + } + + @Test + void deleteWordBackwardRemovesPreviousWord() { + SourceEditorNavigation.deleteWordBackward(state); + + assertThat(state.getLine(0)).isEqualTo(" from: tick"); + assertThat(state.cursorCol()).isEqualTo(8); + } + + @Test + void deleteWordForwardRemovesNextWordToken() { + SourceEditHistory.positionCursor(state, 0, 8); + + SourceEditorNavigation.deleteWordForward(state); + + assertThat(state.getLine(0)).isEqualTo(" from: :tick"); + assertThat(state.cursorCol()).isEqualTo(8); + } + + @Test + void smartHomeTogglesBetweenContentStartAndColumnZero() { + SourceEditorNavigation.smartHome(state, false); + assertThat(state.cursorCol()).isEqualTo(2); + + SourceEditorNavigation.smartHome(state, false); + assertThat(state.cursorCol()).isZero(); + + SourceEditorNavigation.smartHome(state, true); + assertThat(state.cursorCol()).isZero(); + } +} 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 new file mode 100644 index 000000000000..dbfb5a9b5a14 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewerEditorOpsTest.java @@ -0,0 +1,192 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.dsl.jbang.core.commands.tui; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import dev.tamboui.tui.event.KeyEvent; +import dev.tamboui.tui.event.KeyModifiers; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for SourceViewer YAML editor operations (CAMEL-24372). + */ +class SourceViewerEditorOpsTest { + + private static final KeyModifiers CTRL = KeyModifiers.of(true, false, false); + private static final KeyModifiers CTRL_SHIFT = KeyModifiers.of(true, false, true); + + @TempDir + Path tempDir; + + private SourceViewer viewer; + private Path yamlFile; + + @BeforeEach + void setUp() throws Exception { + Theme.resetForTesting(); + viewer = new SourceViewer(); + viewer.setValidateOnSave(false); + yamlFile = tempDir.resolve("route.camel.yaml"); + Files.writeString(yamlFile, """ + - route: + from: + uri: timer:tick + steps: + - to: log:info + - to: log:warn + """, StandardCharsets.UTF_8); + viewer.loadFile(yamlFile); + viewer.enterEditMode(); + } + + @Test + void ctrlZUndoesTypedChange() { + viewer.handleKeyEvent(KeyEvent.ofChar('X', KeyModifiers.NONE)); + assertThat(viewer.editText()).contains("X"); + + viewer.handleKeyEvent(KeyEvent.ofChar('z', CTRL)); + + assertThat(viewer.editText()).doesNotContain("X"); + } + + @Test + void ctrlYRedoesUndoneChange() { + viewer.handleKeyEvent(KeyEvent.ofChar('X', KeyModifiers.NONE)); + viewer.handleKeyEvent(KeyEvent.ofChar('z', CTRL)); + viewer.handleKeyEvent(KeyEvent.ofChar('y', CTRL)); + + assertThat(viewer.editText()).contains("X"); + } + + @Test + void ctrlSlashTogglesCommentOnCurrentBlock() { + moveCursorToLineContaining("log:info"); + + viewer.handleKeyEvent(KeyEvent.ofChar('/', CTRL)); + + assertThat(viewer.editText()).contains("# "); + assertThat(viewer.editText()).contains("log:info"); + + viewer.handleKeyEvent(KeyEvent.ofChar('/', CTRL)); + + assertThat(viewer.editText()).doesNotContain("# log:info"); + } + + @Test + void ctrlDDuplicatesYamlBlock() { + moveCursorToLineContaining("log:info"); + int before = countOccurrences(viewer.editText(), "log:info"); + + viewer.handleKeyEvent(KeyEvent.ofChar('d', CTRL)); + + assertThat(countOccurrences(viewer.editText(), "log:info")).isEqualTo(before + 1); + } + + @Test + void ctrlShiftKDeletesYamlBlock() { + moveCursorToLineContaining("log:warn"); + + viewer.handleKeyEvent(KeyEvent.ofChar('k', CTRL_SHIFT)); + + assertThat(viewer.editText()).doesNotContain("log:warn"); + assertThat(viewer.editText()).contains("log:info"); + } + + @Test + void smartHomeInEditModeUsesContentThenLineStart() { + moveCursorToLineContaining("uri:"); + String line = viewer.editState().getLine(viewer.editState().cursorRow()); + SourceEditHistory.positionCursor(viewer.editState(), viewer.editState().cursorRow(), line.length()); + int contentStart = YamlBlockEditor.leadingSpaces(line); + + SourceEditorNavigation.smartHome(viewer.editState(), false); + assertThat(viewer.editState().cursorCol()).isEqualTo(contentStart); + + SourceEditorNavigation.smartHome(viewer.editState(), false); + assertThat(viewer.editState().cursorCol()).isZero(); + } + + @Test + void ctrlLeftAndRightMoveByWord() { + moveCursorToLineContaining("uri:"); + SourceEditHistory.positionCursor(viewer.editState(), viewer.editState().cursorRow(), 0); + + SourceEditorNavigation.moveWordRight(viewer.editState()); + assertThat(viewer.editState().cursorCol()).isEqualTo(9); + + SourceEditorNavigation.moveWordLeft(viewer.editState()); + assertThat(viewer.editState().cursorCol()).isEqualTo(6); + } + + @Test + void ctrlFOpensFindInEditMode() { + viewer.handleKeyEvent(KeyEvent.ofChar('f', CTRL)); + + assertThat(viewer.isSearchInputActive()).isTrue(); + } + + @Test + void footerShowsNewEditorHints() { + List<dev.tamboui.text.Span> spans = new ArrayList<>(); + viewer.renderFooter(spans); + String footer = spansToString(spans); + + assertThat(footer).contains("Ctrl+Z"); + assertThat(footer).contains("Alt+↑/↓"); + assertThat(footer).contains("Ctrl+D"); + assertThat(footer).contains("Ctrl+/"); + assertThat(footer).contains("Ctrl+F"); + } + + private void moveCursorToLineContaining(String needle) { + String[] lines = viewer.editText().split("\n", -1); + for (int row = 0; row < lines.length; row++) { + if (lines[row].contains(needle)) { + SourceEditHistory.positionCursor(viewer.editState(), row, lines[row].indexOf(needle)); + return; + } + } + throw new AssertionError("Line not found: " + needle); + } + + private static int countOccurrences(String text, String needle) { + int count = 0; + int idx = 0; + while ((idx = text.indexOf(needle, idx)) >= 0) { + count++; + idx += needle.length(); + } + return count; + } + + private static String spansToString(List<dev.tamboui.text.Span> spans) { + StringBuilder sb = new StringBuilder(); + for (dev.tamboui.text.Span span : spans) { + sb.append(span.content()); + } + return sb.toString(); + } +} diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/YamlBlockEditorTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/YamlBlockEditorTest.java new file mode 100644 index 000000000000..d1449cffe350 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/YamlBlockEditorTest.java @@ -0,0 +1,160 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.dsl.jbang.core.commands.tui; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for YAML block operations (CAMEL-24372). + */ +class YamlBlockEditorTest { + + private static final String SAMPLE = """ + - route: + from: + uri: timer:tick + steps: + - to: log:info + - to: log:warn + """; + + @Test + void findBlockSelectsYamlListItemWithChildren() { + List<String> lines = YamlBlockEditor.toLines(SAMPLE); + int stepsRow = findLineContaining(lines, "- to: log:info"); + + YamlBlockEditor.BlockRange block = YamlBlockEditor.findBlock(lines, stepsRow, true); + + assertThat(block.startRow()).isEqualTo(stepsRow); + assertThat(lines.get(block.startRow())).contains("log:info"); + assertThat(block.endRow()).isGreaterThanOrEqualTo(block.startRow()); + } + + @Test + void duplicateBlockInsertsCopyBelow() { + List<String> lines = YamlBlockEditor.toLines(SAMPLE); + int row = findLineContaining(lines, "- to: log:info"); + + YamlBlockEditor.EditResult result = YamlBlockEditor.duplicateBlock(lines, row, true); + + assertThat(result.lines()).hasSize(lines.size() + (YamlBlockEditor.findBlock(lines, row, true).endRow() + - YamlBlockEditor.findBlock(lines, row, true).startRow() + 1)); + String text = YamlBlockEditor.fromLines(result.lines()); + assertThat(text.split("- to: log:info", -1)).hasSize(3); + } + + @Test + void deleteBlockRemovesSelectedYamlBlock() { + List<String> lines = YamlBlockEditor.toLines(SAMPLE); + int row = findLineContaining(lines, "- to: log:warn"); + int before = lines.size(); + + YamlBlockEditor.EditResult result = YamlBlockEditor.deleteBlock(lines, row, true); + + assertThat(result.lines()).hasSizeLessThan(before); + assertThat(YamlBlockEditor.fromLines(result.lines())).doesNotContain("log:warn"); + assertThat(YamlBlockEditor.fromLines(result.lines())).contains("log:info"); + } + + @Test + void moveBlockDownSwapsWithNextSibling() { + List<String> lines = YamlBlockEditor.toLines(SAMPLE); + int firstStep = findLineContaining(lines, "- to: log:info"); + + YamlBlockEditor.EditResult result = YamlBlockEditor.moveBlockDown(lines, firstStep, true); + + assertThat(result).isNotNull(); + String text = YamlBlockEditor.fromLines(result.lines()); + assertThat(text.indexOf("log:warn")).isLessThan(text.indexOf("log:info")); + } + + @Test + void moveBlockUpSwapsWithPreviousSibling() { + List<String> lines = YamlBlockEditor.toLines(SAMPLE); + int secondStep = findLineContaining(lines, "- to: log:warn"); + + YamlBlockEditor.EditResult result = YamlBlockEditor.moveBlockUp(lines, secondStep, true); + + assertThat(result).isNotNull(); + String text = YamlBlockEditor.fromLines(result.lines()); + assertThat(text.indexOf("log:warn")).isLessThan(text.indexOf("log:info")); + } + + @Test + void moveBlockUpAtTopReturnsNull() { + List<String> lines = YamlBlockEditor.toLines(SAMPLE); + int row = findLineContaining(lines, "- route:"); + + assertThat(YamlBlockEditor.moveBlockUp(lines, row, true)).isNull(); + } + + @Test + void toggleCommentCommentsUncommentedLines() { + List<String> lines = YamlBlockEditor.toLines("key: value\nother: x\n"); + YamlBlockEditor.BlockRange block = new YamlBlockEditor.BlockRange(0, 1); + + List<String> commented = YamlBlockEditor.toggleComment(lines, block); + + assertThat(commented.get(0)).startsWith("# "); + assertThat(commented.get(1)).startsWith("# "); + } + + @Test + void toggleCommentUncommentsWhenAllLinesCommented() { + List<String> lines = YamlBlockEditor.toLines("# key: value\n# other: x\n"); + YamlBlockEditor.BlockRange block = new YamlBlockEditor.BlockRange(0, 1); + + List<String> uncommented = YamlBlockEditor.toggleComment(lines, block); + + assertThat(uncommented.get(0)).doesNotStartWith("#"); + assertThat(uncommented.get(0)).contains("key: value"); + assertThat(uncommented.get(1)).contains("other: x"); + } + + @Test + void nonYamlListModeUsesSingleLineBlocks() { + List<String> lines = YamlBlockEditor.toLines("alpha\nbeta\ngamma\n"); + + YamlBlockEditor.BlockRange block = YamlBlockEditor.findBlock(lines, 1, false); + assertThat(block.startRow()).isEqualTo(1); + assertThat(block.endRow()).isEqualTo(1); + + YamlBlockEditor.EditResult moved = YamlBlockEditor.moveBlockDown(lines, 0, false); + assertThat(moved).isNotNull(); + assertThat(YamlBlockEditor.fromLines(moved.lines())).isEqualTo("beta\nalpha\ngamma\n"); + } + + @Test + void toLinesAndFromLinesPreserveTrailingNewlineContent() { + List<String> lines = YamlBlockEditor.toLines("a\nb"); + assertThat(lines).containsExactly("a", "b"); + assertThat(YamlBlockEditor.fromLines(lines)).isEqualTo("a\nb"); + } + + private static int findLineContaining(List<String> lines, String needle) { + for (int i = 0; i < lines.size(); i++) { + if (lines.get(i).contains(needle)) { + return i; + } + } + throw new AssertionError("Line not found: " + needle); + } +}
