This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new e78bb3caf043 CAMEL-24372: TUI YAML editor undo/redo, block ops, word
nav, find in edit
e78bb3caf043 is described below
commit e78bb3caf043121bfd9e6c0004c62992de9db25d
Author: Omar Atie <[email protected]>
AuthorDate: Mon Aug 10 01:31:22 2026 -0700
CAMEL-24372: TUI YAML editor undo/redo, block ops, word nav, find in edit
Add enhanced plain-text edit mode to the TUI source viewer with
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. Decomposed into SourceEditHistory,
YamlBlockEditor, and SourceEditorNavigation helpers with comprehensive
unit and integration tests.
Closes #25408
Co-authored-by: cursoragent <[email protected]>
Co-authored-by: Omar Atie <[email protected]>
Co-Authored-By: Claude Opus 4.6 <[email protected]>
---
.../jbang/core/commands/tui/SearchHighlighter.java | 38 +++
.../jbang/core/commands/tui/SourceEditHistory.java | 89 +++++++
.../core/commands/tui/SourceEditorNavigation.java | 147 +++++++++++
.../dsl/jbang/core/commands/tui/SourceTab.java | 13 +
.../dsl/jbang/core/commands/tui/SourceViewer.java | 141 ++++++++++-
.../jbang/core/commands/tui/YamlBlockEditor.java | 274 +++++++++++++++++++++
.../core/commands/tui/SourceEditHistoryTest.java | 87 +++++++
.../commands/tui/SourceEditorNavigationTest.java | 95 +++++++
.../commands/tui/SourceViewerEditorOpsTest.java | 219 ++++++++++++++++
.../core/commands/tui/YamlBlockEditorTest.java | 173 +++++++++++++
10 files changed, 1268 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..059c2a3350ff 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,42 @@ 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,
Ctrl+N/Ctrl+Shift+N step matches).
+ * <p>
+ * Unlike view mode, plain {@code n}/{@code N} are not consumed so users
can type freely while a find term is
+ * active.
+ */
+ boolean handleEditFindKeyEvent(KeyEvent ke) {
+ if (findInputActive || highlightInputActive) {
+ return handleSearchInput(ke);
+ }
+ if (ke.hasCtrl() && ke.isCharIgnoreCase('f')) {
+ openFindInput();
+ return true;
+ }
+ if (findTerm != null && ke.hasCtrl() && ke.isCharIgnoreCase('n') &&
!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..fac4bf77511b
--- /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,89 @@
+/*
+ * 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());
+ SourceEditorNavigation.positionCursor(state, snapshot.row(),
snapshot.col());
+ }
+
+ 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..0e95d498130f
--- /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,147 @@
+/*
+ * 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);
+ }
+ 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);
+ }
+ 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) {
+ positionCursor(state, state.cursorRow(), 0);
+ } else {
+ positionCursor(state, state.cursorRow(), contentStart);
+ }
+ }
+
+ 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 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());
+ 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/SourceTab.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceTab.java
index cce9ca721d92..ebf50ccf961e 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceTab.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceTab.java
@@ -416,6 +416,19 @@ class SourceTab extends AbstractTab {
- **p** — toggle plain mode (hides line numbers, borders, and
file panel for easy copy/paste)
- **Esc/c** — close source viewer
+ ## Edit Mode (Shortcuts)
+ - **Ctrl+Z** — undo
+ - **Ctrl+Y / Ctrl+Shift+Z** — redo
+ - **Alt+Up / Alt+Down** — move YAML list block up/down
+ - **Ctrl+D** — duplicate current block
+ - **Ctrl+Shift+K** — delete current block
+ - **Ctrl+/** — toggle comment on current block
+ - **Ctrl+Left / Ctrl+Right** — word navigation
+ - **Ctrl+Backspace / Ctrl+Delete** — delete word
backward/forward
+ - **Ctrl+F** — find in edit mode
+ - **Ctrl+N / Ctrl+Shift+N** — next/previous find match
+ - **Home** — smart home (content indent, then column 0)
+
## Edit Mode (Tab Completion)
Press **F4** to enter edit mode, then **Tab** for
context-aware completion:
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..e4eee1a0b03d 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,
@@ -272,6 +273,7 @@ class SourceViewer {
return dirty;
}
+ /** Package-private for tests that drive the edit buffer directly. */
TextAreaState editState() {
return editState;
}
@@ -519,6 +521,44 @@ 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()));
+ SourceEditorNavigation.positionCursor(editState, result.cursorRow(),
result.cursorCol());
+ }
+
+ private void refreshEditFindMatches() {
+ search.buildFindMatches(editLines());
+ }
+
+ private void jumpEditToCurrentFindMatch() {
+ int line = search.jumpToNearestMatch(editState.cursorRow());
+ if (line >= 0) {
+ SourceEditorNavigation.positionCursor(editState, line, 0);
+ }
+ }
+
+ /** Package-private for tests that assert on edit buffer content. */
+ String editText() {
+ return editState.text();
+ }
+
private boolean handleEditKeyEvent(KeyEvent ke) {
if (validationErrors != null) {
if (ke.isCancel() || ke.isKey(KeyCode.ENTER)) {
@@ -547,6 +587,72 @@ class SourceViewer {
}
return true;
}
+ if (search.handleEditFindKeyEvent(ke)) {
+ if (!search.isSearchInputActive()) {
+ refreshEditFindMatches();
+ jumpEditToCurrentFindMatch();
+ }
+ return true;
+ }
+ if (ke.hasCtrl() && ke.isCharIgnoreCase('z') && !ke.hasShift()) {
+ if (editHistory.undo(editState)) {
+ dirty = true;
+ refreshEditFindMatches();
+ }
+ return true;
+ }
+ if (ke.hasCtrl() && (ke.isCharIgnoreCase('y') ||
(ke.isCharIgnoreCase('z') && ke.hasShift()))) {
+ if (editHistory.redo(editState)) {
+ dirty = true;
+ refreshEditFindMatches();
+ }
+ return true;
+ }
+ boolean yamlListBlocks = isCamelYamlFile();
+ if (ke.isKey(KeyCode.UP) && ke.hasAlt() && !ke.hasShift()) {
+ applyBlockEdit(YamlBlockEditor.moveBlockUp(editLines(),
editState.cursorRow(), yamlListBlocks));
+ return true;
+ }
+ if (ke.isKey(KeyCode.DOWN) && ke.hasAlt() && !ke.hasShift()) {
+ applyBlockEdit(YamlBlockEditor.moveBlockDown(editLines(),
editState.cursorRow(), yamlListBlocks));
+ return true;
+ }
+ if (ke.hasCtrl() && ke.isCharIgnoreCase('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));
+ SourceEditorNavigation.positionCursor(editState, block.startRow(),
+
YamlBlockEditor.leadingSpaces(toggled.get(block.startRow())));
+ return true;
+ }
+ if (ke.isKey(KeyCode.LEFT) && ke.hasCtrl()) {
+ SourceEditorNavigation.moveWordLeft(editState);
+ return true;
+ }
+ if (ke.isKey(KeyCode.RIGHT) && ke.hasCtrl()) {
+ SourceEditorNavigation.moveWordRight(editState);
+ return true;
+ }
+ if (ke.isKey(KeyCode.BACKSPACE) && ke.hasCtrl()) {
+ recordEditChange();
+ SourceEditorNavigation.deleteWordBackward(editState);
+ return true;
+ }
+ if (ke.isKey(KeyCode.DELETE) && ke.hasCtrl()) {
+ recordEditChange();
+ SourceEditorNavigation.deleteWordForward(editState);
+ return true;
+ }
if (pendingDiscard) {
if (ke.isConfirm()) {
pendingDiscard = false;
@@ -557,6 +663,9 @@ class SourceViewer {
return true;
}
if (ke.isCancel()) {
+ if (search.handleEscape()) {
+ return true;
+ }
if (dirty) {
pendingDiscard = true;
return true;
@@ -573,6 +682,7 @@ class SourceViewer {
return true;
}
if (ke.isConfirm()) {
+ recordEditChange();
int prevRow = editState.cursorRow();
String prevLine = editState.getLine(prevRow);
int indent = countLeadingSpaces(prevLine);
@@ -593,7 +703,6 @@ class SourceViewer {
} else if (indent > 0) {
editState.insert(" ".repeat(indent));
}
- dirty = true;
return true;
}
if (ke.isUp()) {
@@ -613,7 +722,7 @@ class SourceViewer {
return true;
}
if (ke.isHome() || ke.isKey(KeyCode.HOME)) {
- editState.moveCursorToLineStart();
+ SourceEditorNavigation.smartHome(editState, false);
return true;
}
if (ke.isEnd() || ke.isKey(KeyCode.END)) {
@@ -635,13 +744,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 +758,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 +779,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 +1643,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);
@@ -1858,9 +1970,13 @@ class SourceViewer {
void handlePaste(String text) {
if (editMode) {
+ if (search.isSearchInputActive()) {
+ search.handlePaste(text);
+ return;
+ }
if (text != null && !text.isEmpty()) {
+ recordEditChange();
editState.insert(text);
- dirty = true;
}
return;
}
@@ -2214,10 +2330,19 @@ 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, "Ctrl+Y", "redo");
+ TuiHelper.hint(spans, "Alt+↑/↓", "move block");
+ TuiHelper.hint(spans, "Ctrl+D", "duplicate");
+ TuiHelper.hint(spans, "Ctrl+Shift+K", "delete block");
+ TuiHelper.hint(spans, "Ctrl+/", "comment");
+ TuiHelper.hint(spans, "Ctrl+F", "find");
+ TuiHelper.hint(spans, "Ctrl+N", "next match");
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..8c31ad3cef94
--- /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,274 @@
+/*
+ * 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;
+ }
+ EditResult swapped = swapBlocks(lines, previous, block);
+ List<String> answer = swapped.lines();
+ int cursorRow = previous.startRow();
+ int cursorCol = answer.isEmpty() ? 0 :
YamlBlockEditor.leadingSpaces(answer.get(cursorRow));
+ return new EditResult(answer, cursorRow, cursorCol);
+ }
+
+ 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;
+ }
+ EditResult swapped = swapBlocks(lines, block, next);
+ List<String> answer = swapped.lines();
+ int nextHeight = next.endRow() - next.startRow() + 1;
+ int cursorRow = block.startRow() + nextHeight + (next.startRow() -
block.endRow() - 1);
+ int cursorCol = answer.isEmpty() ? 0 :
leadingSpaces(answer.get(cursorRow));
+ return new EditResult(answer, cursorRow, cursorCol);
+ }
+
+ 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..f2d35b55ae7d
--- /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() {
+ SourceEditorNavigation.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..f3e12ec34032
--- /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");
+ SourceEditorNavigation.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() {
+ SourceEditorNavigation.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..1eebd6fafa46
--- /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,219 @@
+/*
+ * 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.KeyCode;
+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 ALT = KeyModifiers.of(false, true,
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());
+ SourceEditorNavigation.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 altDownMovesBlockDown() {
+ moveCursorToLineContaining("log:info");
+
+ viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.DOWN, KeyModifiers.ALT));
+
+
assertThat(viewer.editText().indexOf("log:warn")).isLessThan(viewer.editText().indexOf("log:info"));
+ }
+
+ @Test
+ void ctrlLeftAndRightMoveByWordViaKeyBindings() {
+ moveCursorToLineContaining("uri:");
+ SourceEditorNavigation.positionCursor(viewer.editState(),
viewer.editState().cursorRow(), 0);
+
+ viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.RIGHT, CTRL));
+ assertThat(viewer.editState().cursorCol()).isEqualTo(9);
+
+ viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.LEFT, CTRL));
+ assertThat(viewer.editState().cursorCol()).isEqualTo(6);
+ }
+
+ @Test
+ void ctrlFOpensFindInEditMode() {
+ viewer.handleKeyEvent(KeyEvent.ofChar('f', CTRL));
+
+ assertThat(viewer.isSearchInputActive()).isTrue();
+ }
+
+ @Test
+ void plainNInsertsWhileFindTermActive() {
+ viewer.handleKeyEvent(KeyEvent.ofChar('f', CTRL));
+ viewer.handleKeyEvent(KeyEvent.ofChar('l', KeyModifiers.NONE));
+ viewer.handleKeyEvent(KeyEvent.ofChar('o', KeyModifiers.NONE));
+ viewer.handleKeyEvent(KeyEvent.ofChar('g', KeyModifiers.NONE));
+ viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER,
KeyModifiers.NONE));
+
+ viewer.handleKeyEvent(KeyEvent.ofChar('n', KeyModifiers.NONE));
+
+ assertThat(viewer.editText()).contains("n");
+ }
+
+ @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("Ctrl+Y");
+ assertThat(footer).contains("Alt+↑/↓");
+ assertThat(footer).contains("Ctrl+D");
+ assertThat(footer).contains("Ctrl+Shift+K");
+ assertThat(footer).contains("Ctrl+/");
+ assertThat(footer).contains("Ctrl+F");
+ assertThat(footer).contains("Ctrl+N");
+ }
+
+ 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)) {
+ SourceEditorNavigation.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..f2fa1e947eeb
--- /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,173 @@
+/*
+ * 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 moveBlockDownCursorFollowsMovedBlock() {
+ List<String> lines = YamlBlockEditor.toLines(SAMPLE);
+ int firstStep = findLineContaining(lines, "- to: log:info");
+
+ YamlBlockEditor.EditResult result =
YamlBlockEditor.moveBlockDown(lines, firstStep, true);
+
+ assertThat(result).isNotNull();
+
assertThat(result.lines().get(result.cursorRow())).contains("log:info");
+
assertThat(YamlBlockEditor.fromLines(result.lines()).indexOf("log:warn"))
+
.isLessThan(YamlBlockEditor.fromLines(result.lines()).indexOf("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);
+ }
+}