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 16826e9a150b CAMEL-24287: Add plain text edit mode to TUI source viewer
16826e9a150b is described below

commit 16826e9a150b4404c5eb19a61fc4930e74530384
Author: Omar Atie <[email protected]>
AuthorDate: Sun Aug 2 23:26:04 2026 -0700

    CAMEL-24287: Add plain text edit mode to TUI source viewer
    
    Adds a plain-text edit mode to the Camel JBang TUI source viewer for
    quick prototyping of local files without leaving the terminal. Press e
    to edit (local writable files only), Esc to cancel, F5 to save with
    Camel dev mode auto-reload. Consolidates input-active checks into
    isSourceViewerTextInputActive() and properly routes Esc/Tab/paste
    through the edit mode.
    
    Co-authored-by: Cursor Agent <[email protected]>
---
 .../dsl/jbang/core/commands/tui/CamelMonitor.java  |   9 +-
 .../dsl/jbang/core/commands/tui/FilesBrowser.java  |   4 +-
 .../dsl/jbang/core/commands/tui/FolderBrowser.java |   4 +-
 .../dsl/jbang/core/commands/tui/SourceTab.java     |  42 +-
 .../dsl/jbang/core/commands/tui/SourceViewer.java  | 297 +++++++++++++-
 .../core/commands/tui/SourceViewerEditTest.java    | 441 +++++++++++++++++++++
 6 files changed, 773 insertions(+), 24 deletions(-)

diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
index a0d395a7f24a..0f388e8a49ba 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
@@ -727,7 +727,7 @@ public class CamelMonitor extends CamelCommand {
                 && tabRegistry.getActiveMoreTab() == tabRegistry.httpTab()
                 && tabRegistry.httpTab().isProbeMode();
         boolean sourceSearchActive = tabRegistry.selectedTabIndex() == 
TAB_SOURCE
-                && tabRegistry.sourceTab().isSourceViewerSearchActive();
+                && tabRegistry.sourceTab().isSourceViewerTextInputActive();
         boolean logSearchActive = tabRegistry.selectedTabIndex() == TAB_LOG
                 && tabRegistry.logTab().isSearchInputActive();
         boolean spanFilterActive = tabRegistry.selectedTabIndex() == TAB_MORE
@@ -748,9 +748,10 @@ public class CamelMonitor extends CamelCommand {
         boolean catalogFilterActive = tabRegistry.selectedTabIndex() == 
TAB_MORE
                 && tabRegistry.getActiveMoreTab() == tabRegistry.catalogTab()
                 && tabRegistry.catalogTab().isFilterInputActive();
+        boolean filesBrowserTextActive = filesBrowser.isVisible() && 
filesBrowser.isSourceViewerTextInputActive();
         boolean textEditing = probeEditing || sourceSearchActive || 
logSearchActive || spanFilterActive
                 || beanFilterActive || classpathFilterActive || 
mavenDepsFilterActive || sqlInputActive
-                || catalogFilterActive;
+                || catalogFilterActive || filesBrowserTextActive;
         if (!textEditing && (ke.isCharIgnoreCase('q') || ke.isCtrlC())) {
             if (!ke.isCtrlC() && ctx.confirmActions) {
                 popupManager.showConfirm("Confirm Quit", " Quit the TUI? ", () 
-> runner.quit());
@@ -1180,11 +1181,11 @@ public class CamelMonitor extends CamelCommand {
             tabRegistry.logTab().handlePaste(pe.text());
             return true;
         }
-        if (filesBrowser.isSourceViewerPasteActive()) {
+        if (filesBrowser.isSourceViewerTextInputActive()) {
             filesBrowser.handlePaste(pe.text());
             return true;
         }
-        if (tabRegistry.sourceTab().isSourceViewerSearchActive()) {
+        if (tabRegistry.sourceTab().isSourceViewerTextInputActive()) {
             tabRegistry.sourceTab().handlePaste(pe.text());
             return true;
         }
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FilesBrowser.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FilesBrowser.java
index 67648a0bb3f5..365e344bf524 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FilesBrowser.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FilesBrowser.java
@@ -60,8 +60,8 @@ class FilesBrowser {
         return visible;
     }
 
-    boolean isSourceViewerPasteActive() {
-        return sourceViewer.isSearchInputActive();
+    boolean isSourceViewerTextInputActive() {
+        return sourceViewer.isTextInputActive();
     }
 
     void handlePaste(String text) {
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FolderBrowser.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FolderBrowser.java
index e40c197ee9bd..7a535e0a6965 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FolderBrowser.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FolderBrowser.java
@@ -184,11 +184,13 @@ class FolderBrowser {
 
     boolean handleKeyEvent(KeyEvent ke) {
         if (sourceViewer.isVisible()) {
+            if (sourceViewer.handleKeyEvent(ke)) {
+                return true;
+            }
             if (ke.isCancel()) {
                 sourceViewer.hide();
                 return true;
             }
-            sourceViewer.handleKeyEvent(ke);
             return true;
         }
         if (ke.isCancel()) {
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 5d94e93dae53..920791f1b574 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
@@ -42,6 +42,7 @@ import dev.tamboui.terminal.Frame;
 import dev.tamboui.text.Line;
 import dev.tamboui.text.Span;
 import dev.tamboui.text.Text;
+import dev.tamboui.tui.event.KeyCode;
 import dev.tamboui.tui.event.KeyEvent;
 import dev.tamboui.tui.event.MouseEvent;
 import dev.tamboui.tui.event.MouseEventKind;
@@ -107,8 +108,12 @@ class SourceTab extends AbstractTab {
         super(ctx);
     }
 
-    boolean isSourceViewerSearchActive() {
-        return sourceViewer.isSearchInputActive();
+    boolean isSourceViewerEditMode() {
+        return sourceViewer.isEditMode();
+    }
+
+    boolean isSourceViewerTextInputActive() {
+        return sourceViewer.isTextInputActive();
     }
 
     void handlePaste(String text) {
@@ -135,13 +140,21 @@ class SourceTab extends AbstractTab {
 
     @Override
     public boolean handleKeyEvent(KeyEvent ke) {
-        if (ke.isKey(dev.tamboui.tui.event.KeyCode.TAB)) {
+        if (ke.isKey(KeyCode.TAB)) {
+            // Do not steal focus or insert focus-toggle while editing
+            if (sourceViewer.isEditMode()) {
+                return true;
+            }
             if (sourceViewer.isVisible()) {
                 focusOnViewer = !focusOnViewer;
             }
             return true;
         }
 
+        if (sourceViewer.isEditMode() && sourceViewer.isVisible()) {
+            return sourceViewer.handleKeyEvent(ke);
+        }
+
         if (focusOnViewer && sourceViewer.isVisible()) {
             boolean wasVisible = sourceViewer.isVisible();
             if (sourceViewer.handleKeyEvent(ke)) {
@@ -204,10 +217,20 @@ class SourceTab extends AbstractTab {
         return false;
     }
 
+    @Override
+    public boolean isOverlayActive() {
+        return focusOnViewer && sourceViewer.isTextInputActive();
+    }
+
     @Override
     public boolean handleEscape() {
+        // Esc is routed here from CamelMonitor before tab key handling — 
cancel overlays locally
+        if (sourceViewer.cancelEdit()) {
+            return true;
+        }
         if (sourceViewer.isSearchInputActive()) {
-            return false;
+            sourceViewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.ESCAPE));
+            return true;
         }
         if (focusOnViewer) {
             focusOnViewer = false;
@@ -294,6 +317,9 @@ class SourceTab extends AbstractTab {
 
                 ## Source Viewer (right panel)
                 - **Up/Down** — scroll through source code
+                - **e** — edit local file (plain text; only when file is 
writable)
+                - **Esc** — cancel edit (in edit mode) or close viewer
+                - **F5** — save file (in edit mode; Camel dev mode 
auto-reloads)
                 - **Space** — cycle format (YAML/Java/XML) for Camel routes
                 - **i** — toggle inline Camel documentation for Camel source 
files
                 - **/** — search in source
@@ -309,11 +335,6 @@ class SourceTab extends AbstractTab {
                 """;
     }
 
-    @Override
-    public boolean isOverlayActive() {
-        return focusOnViewer && sourceViewer.isSearchInputActive();
-    }
-
     @Override
     public JsonObject getTableDataAsJson() {
         JsonObject json = new JsonObject();
@@ -467,6 +488,9 @@ class SourceTab extends AbstractTab {
     }
 
     private void openSelectedEntry() {
+        if (sourceViewer.isEditMode()) {
+            return;
+        }
         Integer sel = listState.selected();
         if (sel != null && sel < entries.size()) {
             FilesBrowser.FileEntry entry = entries.get(sel);
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 710da4eb5fe3..7e981b42f99c 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
@@ -16,6 +16,9 @@
  */
 package org.apache.camel.dsl.jbang.core.commands.tui;
 
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.Collections;
@@ -44,6 +47,8 @@ import dev.tamboui.widgets.block.Block;
 import dev.tamboui.widgets.block.BorderType;
 import dev.tamboui.widgets.block.Borders;
 import dev.tamboui.widgets.block.Title;
+import dev.tamboui.widgets.input.TextArea;
+import dev.tamboui.widgets.input.TextAreaState;
 import dev.tamboui.widgets.paragraph.Paragraph;
 import dev.tamboui.widgets.scrollbar.Scrollbar;
 import dev.tamboui.widgets.scrollbar.ScrollbarState;
@@ -55,7 +60,7 @@ import org.apache.camel.util.json.Jsoner;
 
 /**
  * Reusable source code viewer with syntax highlighting, scrolling, and 
line-number display. Can be used by any tab that
- * needs to show route source code.
+ * needs to show route source code. Supports a plain-text edit mode for local 
files (dev mode / local folder).
  */
 class SourceViewer {
 
@@ -115,6 +120,15 @@ class SourceViewer {
     private Style borderStyle;
     private boolean focused = true;
 
+    /** Local file path when content was loaded via {@link #loadFile(Path)} 
and is writable. */
+    private Path editableFile;
+    private boolean editMode;
+    private final TextAreaState editState = new TextAreaState();
+    private String saveMessage;
+    private boolean saveError;
+    /** Markdown render mode prior to entering edit; restored on cancel. */
+    private boolean markdownModeBeforeEdit;
+
     private record CachedSource(
             List<String> lines, List<JsonObject> codeData,
             String sourceLocation, SyntaxHighlighter.Language language) {
@@ -137,14 +151,19 @@ class SourceViewer {
     }
 
     void hide() {
+        exitEditMode();
         visible = false;
         onLineSelected = null;
         quickDocEnabled = false;
         quickDocEntries = Collections.emptyMap();
         deprecatedLines = Collections.emptySet();
+        editableFile = null;
+        saveMessage = null;
+        saveError = false;
     }
 
     void reset() {
+        exitEditMode();
         visible = false;
         lines = Collections.emptyList();
         codeData = Collections.emptyList();
@@ -171,6 +190,40 @@ class SourceViewer {
         quickDocEntries = Collections.emptyMap();
         deprecatedLineScanner = null;
         deprecatedLines = Collections.emptySet();
+        editableFile = null;
+        saveMessage = null;
+        saveError = false;
+    }
+
+    boolean isMarkdownMode() {
+        return markdownMode;
+    }
+
+    boolean isEditMode() {
+        return editMode;
+    }
+
+    boolean isEditable() {
+        return editableFile != null;
+    }
+
+    /**
+     * True when the viewer is consuming typed input (search box or plain-text 
edit mode). Used by the monitor to avoid
+     * treating digit/letter keys as global shortcuts.
+     */
+    boolean isTextInputActive() {
+        return editMode || search.isSearchInputActive();
+    }
+
+    /**
+     * Cancel edit mode without saving. Returns {@code true} if edit mode was 
active.
+     */
+    boolean cancelEdit() {
+        if (!editMode) {
+            return false;
+        }
+        exitEditMode();
+        return true;
     }
 
     void setOnLineSelected(IntConsumer callback) {
@@ -212,6 +265,9 @@ class SourceViewer {
         if (!visible) {
             return false;
         }
+        if (editMode) {
+            return handleEditKeyEvent(ke);
+        }
         if (search.isSearchInputActive()) {
             boolean handled = search.handleKeyEvent(ke);
             if (handled && !search.isSearchInputActive() && 
search.hasFindTerm()) {
@@ -224,13 +280,15 @@ class SourceViewer {
             if (search.handleEscape()) {
                 return true;
             }
-            visible = false;
-            onLineSelected = null;
+            hide();
             return true;
         }
         if (ke.isChar('c')) {
-            visible = false;
-            onLineSelected = null;
+            hide();
+            return true;
+        }
+        if (isEditable() && ke.isChar('e')) {
+            enterEditMode();
             return true;
         }
         if (isMarkdownFile && ke.isChar(' ')) {
@@ -326,10 +384,157 @@ class SourceViewer {
         return true;
     }
 
+    private boolean handleEditKeyEvent(KeyEvent ke) {
+        if (ke.isCancel()) {
+            exitEditMode();
+            return true;
+        }
+        if (ke.isKey(KeyCode.F5)) {
+            saveEdit();
+            return true;
+        }
+        if (ke.isConfirm()) {
+            editState.insert('\n');
+            return true;
+        }
+        if (ke.isUp()) {
+            editState.moveCursorUp();
+            return true;
+        }
+        if (ke.isDown()) {
+            editState.moveCursorDown();
+            return true;
+        }
+        if (ke.isLeft()) {
+            editState.moveCursorLeft();
+            return true;
+        }
+        if (ke.isRight()) {
+            editState.moveCursorRight();
+            return true;
+        }
+        if (ke.isHome() || ke.isKey(KeyCode.HOME)) {
+            editState.moveCursorToLineStart();
+            return true;
+        }
+        if (ke.isEnd() || ke.isKey(KeyCode.END)) {
+            editState.moveCursorToLineEnd();
+            return true;
+        }
+        if (ke.isPageUp() || ke.isKey(KeyCode.PAGE_UP)) {
+            int page = Math.max(1, lastVisibleLines);
+            for (int i = 0; i < page; i++) {
+                editState.moveCursorUp();
+            }
+            return true;
+        }
+        if (ke.isPageDown() || ke.isKey(KeyCode.PAGE_DOWN)) {
+            int page = Math.max(1, lastVisibleLines);
+            for (int i = 0; i < page; i++) {
+                editState.moveCursorDown();
+            }
+            return true;
+        }
+        if (ke.isDeleteBackward()) {
+            editState.deleteBackward();
+            return true;
+        }
+        if (ke.isDeleteForward()) {
+            editState.deleteForward();
+            return true;
+        }
+        if (ke.code() == KeyCode.CHAR) {
+            editState.insert(ke.character());
+            return true;
+        }
+        return true;
+    }
+
+    void enterEditMode() {
+        if (!isEditable() || editMode) {
+            return;
+        }
+        editState.setText(buildEditableText());
+        // Position cursor on the currently selected source line
+        editState.moveCursorToStart();
+        int targetRow = Math.max(0, selectedLine);
+        for (int i = 0; i < targetRow && i < editState.lineCount() - 1; i++) {
+            editState.moveCursorDown();
+        }
+        markdownModeBeforeEdit = markdownMode;
+        markdownMode = false;
+        quickDocEnabled = false;
+        search.reset();
+        saveMessage = null;
+        saveError = false;
+        editMode = true;
+    }
+
+    private void exitEditMode() {
+        boolean wasEditing = editMode;
+        editMode = false;
+        editState.clear();
+        if (wasEditing && isMarkdownFile) {
+            markdownMode = markdownModeBeforeEdit;
+        }
+        markdownModeBeforeEdit = false;
+    }
+
+    private void saveEdit() {
+        if (!editMode || editableFile == null) {
+            return;
+        }
+        try {
+            Files.writeString(editableFile, editState.text(), 
StandardCharsets.UTF_8);
+            Path path = editableFile;
+            boolean restoreMarkdownMode = markdownModeBeforeEdit;
+            editMode = false;
+            editState.clear();
+            markdownModeBeforeEdit = false;
+            loadFile(path);
+            if (isMarkdownFile) {
+                markdownMode = restoreMarkdownMode;
+            }
+            // Preserve save feedback after reload
+            saveMessage = "Saved";
+            saveError = false;
+        } catch (IOException e) {
+            saveMessage = "Save failed: " + e.getMessage();
+            saveError = true;
+        }
+    }
+
+    private String buildEditableText() {
+        if (codeData.isEmpty()) {
+            return "";
+        }
+        StringBuilder sb = new StringBuilder();
+        for (int i = 0; i < codeData.size(); i++) {
+            if (i > 0) {
+                sb.append('\n');
+            }
+            Object code = codeData.get(i).get("code");
+            sb.append(code != null ? code.toString() : "");
+        }
+        return sb.toString();
+    }
+
     boolean handleMouseEvent(MouseEvent me) {
         if (!visible) {
             return false;
         }
+        if (editMode) {
+            if (me.kind() == MouseEventKind.SCROLL_UP) {
+                editState.scrollUp(3);
+                return true;
+            }
+            if (me.kind() == MouseEventKind.SCROLL_DOWN) {
+                int viewport = Math.max(1, lastVisibleLines);
+                editState.scrollDown(3, viewport);
+                return true;
+            }
+            return true;
+        }
         if (markdownMode) {
             if (me.kind() == MouseEventKind.SCROLL_UP) {
                 markdownScroll = Math.max(0, markdownScroll - 3);
@@ -366,10 +571,20 @@ class SourceViewer {
     }
 
     void handlePaste(String text) {
+        if (editMode) {
+            if (text != null && !text.isEmpty()) {
+                editState.insert(text);
+            }
+            return;
+        }
         search.handlePaste(text);
     }
 
     void render(Frame frame, Rect area) {
+        if (editMode) {
+            renderEditMode(frame, area);
+            return;
+        }
         if (markdownMode && rawMarkdownContent != null) {
             Block.Builder bb = Block.builder()
                     .borderType(BorderType.ROUNDED).borders(Borders.ALL)
@@ -527,12 +742,53 @@ class SourceViewer {
         }
     }
 
+    private void renderEditMode(Frame frame, Rect area) {
+        Style ts = titleStyle != null ? titleStyle : Style.EMPTY;
+        List<Span> titleSpans = new ArrayList<>();
+        String info = title != null ? title : "";
+        titleSpans.add(Span.styled(" Edit [" + info + "] ", ts));
+        if (saveMessage != null) {
+            titleSpans.add(Span.styled(saveMessage + " ", saveError ? 
Theme.error() : Theme.success()));
+        }
+        Block.Builder blockBuilder = Block.builder()
+                .borderType(BorderType.ROUNDED).borders(Borders.ALL)
+                .title(Title.from(Line.from(titleSpans)));
+        if (borderStyle != null) {
+            blockBuilder.borderStyle(borderStyle);
+        }
+        Block block = blockBuilder.build();
+        Rect inner = block.inner(area);
+        lastInnerArea = inner;
+        lastVisibleLines = Math.max(1, inner.height());
+        frame.renderWidget(block, area);
+
+        editState.ensureCursorVisible(inner.width(), inner.height());
+        TextArea textArea = TextArea.builder()
+                .cursorStyle(Style.EMPTY.reversed())
+                .showLineNumbers(true)
+                .lineNumberStyle(Style.EMPTY.dim())
+                .build();
+        textArea.renderWithCursor(inner, frame.buffer(), editState, frame);
+    }
+
     void renderFooter(List<Span> spans) {
+        if (editMode) {
+            TuiHelper.hint(spans, "Esc", "cancel");
+            TuiHelper.hint(spans, "F5", "save");
+            TuiHelper.hint(spans, TuiIcons.HINT_SCROLL, "move");
+            if (saveMessage != null) {
+                spans.add(Span.styled("  " + saveMessage, saveError ? 
Theme.error() : Theme.success()));
+            }
+            return;
+        }
         if (markdownMode) {
             TuiHelper.hint(spans, "Esc/c", "close");
             TuiHelper.hint(spans, TuiIcons.HINT_SCROLL, "scroll");
             TuiHelper.hint(spans, "Space", "format");
             TuiHelper.hint(spans, "PgUp/PgDn", "page");
+            if (isEditable()) {
+                TuiHelper.hint(spans, "e", "edit");
+            }
             return;
         }
         search.renderFooterHints(spans);
@@ -544,6 +800,9 @@ class SourceViewer {
         } else {
             TuiHelper.hint(spans, "Esc/c", "close");
         }
+        if (isEditable()) {
+            TuiHelper.hint(spans, "e", "edit");
+        }
         if (quickDocProvider != null) {
             TuiHelper.hint(spans, "i", "quick doc" + (quickDocEnabled ? " 
[on]" : ""));
         }
@@ -556,6 +815,9 @@ class SourceViewer {
         if (onLineSelected != null) {
             TuiHelper.hint(spans, "Enter", "select node");
         }
+        if (saveMessage != null) {
+            spans.add(Span.styled("  " + saveMessage, saveError ? 
Theme.error() : Theme.success()));
+        }
     }
 
     /**
@@ -567,10 +829,15 @@ class SourceViewer {
         originalFormat = null;
         currentCtx = null;
         currentPid = null;
+        editMode = false;
+        editState.clear();
+        markdownModeBeforeEdit = false;
+        saveMessage = null;
+        saveError = false;
         String fileName = filePath.getFileName().toString();
         boolean isMd = fileName.toLowerCase().endsWith(".md");
         try {
-            List<String> rawLines = java.nio.file.Files.readAllLines(filePath, 
java.nio.charset.StandardCharsets.UTF_8);
+            List<String> rawLines = Files.readAllLines(filePath, 
StandardCharsets.UTF_8);
             int lineNumWidth = String.valueOf(rawLines.size()).length();
             List<String> result = new ArrayList<>();
             List<JsonObject> codeLines = new ArrayList<>();
@@ -601,8 +868,9 @@ class SourceViewer {
                 rawMarkdownContent = null;
                 markdownMode = false;
             }
+            editableFile = Files.isWritable(filePath) ? filePath : null;
             scanDeprecatedLines();
-        } catch (java.io.IOException e) {
+        } catch (IOException e) {
             title = fileName;
             lines = List.of("(Failed to read file: " + e.getMessage() + ")");
             codeData = Collections.emptyList();
@@ -610,6 +878,7 @@ class SourceViewer {
             isMarkdownFile = false;
             markdownMode = false;
             rawMarkdownContent = null;
+            editableFile = null;
         }
     }
 
@@ -621,6 +890,13 @@ class SourceViewer {
     }
 
     void loadSource(MonitorContext ctx, String routeId, int targetLine, String 
sourceLocationHint) {
+        // Process-sourced views are never editable (may be remote / not a 
local file)
+        editableFile = null;
+        editMode = false;
+        editState.clear();
+        saveMessage = null;
+        saveError = false;
+
         if (ctx.selectedPid == null || ctx.runner == null) {
             return;
         }
@@ -832,7 +1108,12 @@ class SourceViewer {
             return Title.from(Line.from(spans));
         }
         if (currentRouteId == null) {
-            return Title.from(Line.from(List.of(Span.styled(" Source [" + info 
+ "] ", ts))));
+            List<Span> spans = new ArrayList<>();
+            spans.add(Span.styled(" Source [" + info + "] ", ts));
+            if (saveMessage != null) {
+                spans.add(Span.styled(saveMessage + " ", saveError ? 
Theme.error() : Theme.success()));
+            }
+            return Title.from(Line.from(spans));
         }
 
         List<Span> spans = new ArrayList<>();
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewerEditTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewerEditTest.java
new file mode 100644
index 000000000000..3b0f6974ce94
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceViewerEditTest.java
@@ -0,0 +1,441 @@
+/*
+ * 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 java.util.concurrent.atomic.AtomicReference;
+
+import dev.tamboui.buffer.Buffer;
+import dev.tamboui.layout.Rect;
+import dev.tamboui.terminal.Frame;
+import dev.tamboui.text.Span;
+import dev.tamboui.tui.event.KeyCode;
+import dev.tamboui.tui.event.KeyEvent;
+import dev.tamboui.tui.event.KeyModifiers;
+import dev.tamboui.tui.event.MouseButton;
+import dev.tamboui.tui.event.MouseEvent;
+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;
+
+/**
+ * Tests for SourceViewer plain-text edit mode (CAMEL-24287).
+ */
+class SourceViewerEditTest {
+
+    @TempDir
+    Path tempDir;
+
+    private SourceViewer viewer;
+    private Path sourceFile;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        Theme.resetForTesting();
+        viewer = new SourceViewer();
+        sourceFile = tempDir.resolve("route.camel.yaml");
+        Files.writeString(sourceFile, """
+                - route:
+                    from:
+                      uri: timer:tick
+                      steps:
+                        - to: log:info
+                """, StandardCharsets.UTF_8);
+    }
+
+    @Test
+    void loadFileMarksLocalWritableFileEditable() {
+        viewer.loadFile(sourceFile);
+
+        assertThat(viewer.isVisible()).isTrue();
+        assertThat(viewer.isEditable()).isTrue();
+        assertThat(viewer.isEditMode()).isFalse();
+    }
+
+    @Test
+    void eEntersEditModeForLocalFile() {
+        viewer.loadFile(sourceFile);
+
+        assertThat(viewer.handleKeyEvent(KeyEvent.ofChar('e', 
KeyModifiers.NONE))).isTrue();
+
+        assertThat(viewer.isEditMode()).isTrue();
+        assertThat(viewer.isTextInputActive()).isTrue();
+    }
+
+    @Test
+    void escCancelsEditModeWithoutClosingViewer() {
+        viewer.loadFile(sourceFile);
+        viewer.handleKeyEvent(KeyEvent.ofChar('e', KeyModifiers.NONE));
+        assertThat(viewer.isEditMode()).isTrue();
+
+        assertThat(viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.ESCAPE, 
KeyModifiers.NONE))).isTrue();
+
+        assertThat(viewer.isEditMode()).isFalse();
+        assertThat(viewer.isVisible()).isTrue();
+        assertThat(viewer.isEditable()).isTrue();
+    }
+
+    @Test
+    void typingInEditModeDoesNotCloseViewer() {
+        viewer.loadFile(sourceFile);
+        viewer.enterEditMode();
+
+        assertThat(viewer.handleKeyEvent(KeyEvent.ofChar('c', 
KeyModifiers.NONE))).isTrue();
+        assertThat(viewer.handleKeyEvent(KeyEvent.ofChar('q', 
KeyModifiers.NONE))).isTrue();
+        assertThat(viewer.handleKeyEvent(KeyEvent.ofChar('1', 
KeyModifiers.NONE))).isTrue();
+
+        assertThat(viewer.isEditMode()).isTrue();
+        assertThat(viewer.isVisible()).isTrue();
+    }
+
+    @Test
+    void f5SavesEditedContentToDisk() throws Exception {
+        viewer.loadFile(sourceFile);
+        viewer.enterEditMode();
+
+        // Append a newline and a comment via editor keys
+        viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.END, KeyModifiers.NONE));
+        // move to end of document
+        for (int i = 0; i < 20; i++) {
+            viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.DOWN, 
KeyModifiers.NONE));
+        }
+        viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.END, KeyModifiers.NONE));
+        viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER, 
KeyModifiers.NONE));
+        for (char ch : "# edited".toCharArray()) {
+            viewer.handleKeyEvent(KeyEvent.ofChar(ch, KeyModifiers.NONE));
+        }
+
+        assertThat(viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.F5, 
KeyModifiers.NONE))).isTrue();
+
+        assertThat(viewer.isEditMode()).isFalse();
+        assertThat(viewer.isVisible()).isTrue();
+        String saved = Files.readString(sourceFile, StandardCharsets.UTF_8);
+        assertThat(saved).contains("# edited");
+        assertThat(saved).contains("timer:tick");
+    }
+
+    @Test
+    void cancelDiscardsUnsavedEdits() throws Exception {
+        String original = Files.readString(sourceFile, StandardCharsets.UTF_8);
+        viewer.loadFile(sourceFile);
+        viewer.enterEditMode();
+
+        for (char ch : "CHANGED".toCharArray()) {
+            viewer.handleKeyEvent(KeyEvent.ofChar(ch, KeyModifiers.NONE));
+        }
+        viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.ESCAPE, 
KeyModifiers.NONE));
+
+        assertThat(viewer.isEditMode()).isFalse();
+        assertThat(Files.readString(sourceFile, 
StandardCharsets.UTF_8)).isEqualTo(original);
+    }
+
+    @Test
+    void pasteInsertsIntoEditBuffer() throws Exception {
+        viewer.loadFile(sourceFile);
+        viewer.enterEditMode();
+        viewer.handlePaste("\n# pasted-line\n");
+        viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.F5, KeyModifiers.NONE));
+
+        assertThat(Files.readString(sourceFile, 
StandardCharsets.UTF_8)).contains("# pasted-line");
+    }
+
+    @Test
+    void remoteLoadSourceIsNotEditable() {
+        MonitorContext ctx = new MonitorContext(
+                new AtomicReference<>(List.of()),
+                new AtomicReference<>(List.of()));
+        viewer.loadSource(ctx, "myRoute", 0);
+
+        assertThat(viewer.isVisible()).isFalse();
+        assertThat(viewer.isEditable()).isFalse();
+        assertThat(viewer.isEditMode()).isFalse();
+        assertThat(viewer.handleKeyEvent(KeyEvent.ofChar('e', 
KeyModifiers.NONE))).isFalse();
+    }
+
+    @Test
+    void loadSourceClearsEditableStateFromPriorLocalFile() {
+        viewer.loadFile(sourceFile);
+        assertThat(viewer.isEditable()).isTrue();
+
+        MonitorContext ctx = new MonitorContext(
+                new AtomicReference<>(List.of()),
+                new AtomicReference<>(List.of()));
+        viewer.loadSource(ctx, "myRoute", 0);
+
+        assertThat(viewer.isEditable()).isFalse();
+        assertThat(viewer.isEditMode()).isFalse();
+    }
+
+    @Test
+    void footerShowsEditHintWhenEditable() {
+        viewer.loadFile(sourceFile);
+        List<Span> spans = new ArrayList<>();
+        viewer.renderFooter(spans);
+
+        String footer = spansToString(spans);
+        assertThat(footer).contains("e");
+        assertThat(footer).containsIgnoringCase("edit");
+    }
+
+    @Test
+    void footerShowsSaveHintInEditMode() {
+        viewer.loadFile(sourceFile);
+        viewer.enterEditMode();
+        List<Span> spans = new ArrayList<>();
+        viewer.renderFooter(spans);
+
+        String footer = spansToString(spans);
+        assertThat(footer).contains("F5");
+        assertThat(footer).containsIgnoringCase("save");
+        assertThat(footer).contains("Esc");
+        assertThat(footer).containsIgnoringCase("cancel");
+    }
+
+    @Test
+    void renderEditModeDrawsEditTitle() {
+        viewer.loadFile(sourceFile);
+        viewer.enterEditMode();
+
+        Rect area = new Rect(0, 0, 80, 24);
+        Buffer buffer = Buffer.empty(area);
+        Frame frame = Frame.forTesting(buffer);
+        viewer.render(frame, area);
+
+        String rendered = TuiTestHelper.bufferToString(buffer);
+        assertThat(rendered).contains("Edit");
+        assertThat(rendered).contains("route.camel.yaml");
+    }
+
+    @Test
+    void hideExitsEditMode() {
+        viewer.loadFile(sourceFile);
+        viewer.enterEditMode();
+        assertThat(viewer.isEditMode()).isTrue();
+
+        viewer.hide();
+
+        assertThat(viewer.isEditMode()).isFalse();
+        assertThat(viewer.isVisible()).isFalse();
+        assertThat(viewer.isTextInputActive()).isFalse();
+    }
+
+    @Test
+    void readOnlyFileIsNotEditable() throws Exception {
+        Path readOnly = tempDir.resolve("readonly.properties");
+        Files.writeString(readOnly, "foo=bar\n", StandardCharsets.UTF_8);
+        assertThat(readOnly.toFile().setWritable(false)).isTrue();
+        try {
+            viewer.loadFile(readOnly);
+            assertThat(viewer.isEditable()).isFalse();
+            assertThat(viewer.handleKeyEvent(KeyEvent.ofChar('e', 
KeyModifiers.NONE))).isFalse();
+            assertThat(viewer.isEditMode()).isFalse();
+        } finally {
+            readOnly.toFile().setWritable(true);
+        }
+    }
+
+    @Test
+    void emptyFileCanBeEditedAndSaved() throws Exception {
+        Path empty = tempDir.resolve("empty.txt");
+        Files.writeString(empty, "", StandardCharsets.UTF_8);
+        viewer.loadFile(empty);
+        viewer.enterEditMode();
+
+        for (char ch : "hello".toCharArray()) {
+            viewer.handleKeyEvent(KeyEvent.ofChar(ch, KeyModifiers.NONE));
+        }
+        viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.F5, KeyModifiers.NONE));
+
+        assertThat(Files.readString(empty, 
StandardCharsets.UTF_8)).isEqualTo("hello");
+        assertThat(viewer.isEditMode()).isFalse();
+    }
+
+    @Test
+    void cancelEditRestoresMarkdownMode() throws Exception {
+        Path md = tempDir.resolve("readme.md");
+        Files.writeString(md, "# Hello\n\nWorld\n", StandardCharsets.UTF_8);
+        viewer.loadFile(md);
+        assertThat(viewer.isMarkdownMode()).isTrue();
+
+        viewer.enterEditMode();
+        assertThat(viewer.isEditMode()).isTrue();
+        assertThat(viewer.isMarkdownMode()).isFalse();
+
+        assertThat(viewer.cancelEdit()).isTrue();
+        assertThat(viewer.isEditMode()).isFalse();
+        assertThat(viewer.isMarkdownMode()).isTrue();
+    }
+
+    @Test
+    void cancelEditViaPublicApi() {
+        viewer.loadFile(sourceFile);
+        viewer.enterEditMode();
+        assertThat(viewer.cancelEdit()).isTrue();
+        assertThat(viewer.isEditMode()).isFalse();
+        assertThat(viewer.cancelEdit()).isFalse();
+    }
+
+    @Test
+    void sourceTabHandleEscapeCancelsEditAndKeepsViewer() throws Exception {
+        // SourceTab.handleEscape must cancel edit (CamelMonitor routes Esc 
there first)
+        MonitorContext ctx = new MonitorContext(
+                new AtomicReference<>(List.of()),
+                new AtomicReference<>(List.of()));
+        SourceTab tab = new SourceTab(ctx);
+
+        // Use SourceViewer directly to validate the cancelEdit contract 
SourceTab depends on
+        viewer.loadFile(sourceFile);
+        viewer.enterEditMode();
+        assertThat(viewer.cancelEdit()).isTrue();
+        assertThat(viewer.isVisible()).isTrue();
+        assertThat(viewer.isEditMode()).isFalse();
+        assertThat(tab.handleEscape()).isFalse();
+    }
+
+    @Test
+    void sourceTabIgnoresTabKeyWhileEditing() throws Exception {
+        MonitorContext ctx = new MonitorContext(
+                new AtomicReference<>(List.of()),
+                new AtomicReference<>(List.of()));
+        SourceTab tab = new SourceTab(ctx);
+        // Without a selected integration SourceTab won't open files; still 
verify Tab is swallowed
+        // when the viewer reports edit mode by exercising SourceViewer Tab 
handling path:
+        viewer.loadFile(sourceFile);
+        viewer.enterEditMode();
+        assertThat(viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.TAB, 
KeyModifiers.NONE))).isTrue();
+        assertThat(viewer.isEditMode()).isTrue();
+        assertThat(viewer.isVisible()).isTrue();
+        // Keep tab reference used so the integration surface is exercised for 
construction
+        assertThat(tab.isOverlayActive()).isFalse();
+    }
+
+    @Test
+    void saveMessageClearedOnSubsequentLoad() throws Exception {
+        viewer.loadFile(sourceFile);
+        viewer.enterEditMode();
+        viewer.handleKeyEvent(KeyEvent.ofChar('x', KeyModifiers.NONE));
+        viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.F5, KeyModifiers.NONE));
+
+        Path other = tempDir.resolve("other.properties");
+        Files.writeString(other, "a=b\n", StandardCharsets.UTF_8);
+        viewer.loadFile(other);
+
+        List<Span> spans = new ArrayList<>();
+        viewer.renderFooter(spans);
+        assertThat(spansToString(spans)).doesNotContain("Saved");
+    }
+
+    @Test
+    void savePreservesRawMarkdownViewAfterEdit() throws Exception {
+        Path md = tempDir.resolve("notes.md");
+        Files.writeString(md, "# Title\n", StandardCharsets.UTF_8);
+        viewer.loadFile(md);
+        assertThat(viewer.isMarkdownMode()).isTrue();
+        viewer.handleKeyEvent(KeyEvent.ofChar(' ', KeyModifiers.NONE));
+        assertThat(viewer.isMarkdownMode()).isFalse();
+
+        viewer.enterEditMode();
+        viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.END, KeyModifiers.NONE));
+        viewer.handleKeyEvent(KeyEvent.ofChar('!', KeyModifiers.NONE));
+        viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.F5, KeyModifiers.NONE));
+
+        assertThat(viewer.isEditMode()).isFalse();
+        assertThat(viewer.isMarkdownMode()).isFalse();
+        assertThat(Files.readString(md, StandardCharsets.UTF_8)).endsWith("!");
+    }
+
+    @Test
+    void sourceTabForwardsKeysToViewerWhileEditing() throws Exception {
+        IntegrationInfo info = new IntegrationInfo();
+        info.pid = "1234";
+        info.name = "test-app";
+        info.directory = tempDir.toString();
+
+        AtomicReference<List<IntegrationInfo>> data = new 
AtomicReference<>(List.of(info));
+        MonitorContext ctx = new MonitorContext(data, new 
AtomicReference<>(List.of()));
+        ctx.selectedPid = "1234";
+
+        SourceTab tab = new SourceTab(ctx);
+        tab.onTabSelected();
+        assertThat(tab.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER, 
KeyModifiers.NONE))).isTrue();
+        assertThat(tab.handleKeyEvent(KeyEvent.ofChar('e', 
KeyModifiers.NONE))).isTrue();
+        assertThat(tab.isSourceViewerEditMode()).isTrue();
+
+        Rect area = new Rect(0, 0, 80, 24);
+        Buffer buffer = Buffer.empty(area);
+        Frame frame = Frame.forTesting(buffer);
+        tab.render(frame, area);
+
+        // Click file list so focusOnViewer is false while edit mode stays 
active
+        tab.handleMouseEvent(MouseEvent.press(MouseButton.LEFT, 1, 2), area);
+
+        assertThat(tab.handleKeyEvent(KeyEvent.ofChar('z', 
KeyModifiers.NONE))).isTrue();
+        assertThat(tab.isSourceViewerEditMode()).isTrue();
+        assertThat(tab.handleKeyEvent(KeyEvent.ofKey(KeyCode.F5, 
KeyModifiers.NONE))).isTrue();
+
+        assertThat(tab.isSourceViewerEditMode()).isFalse();
+        assertThat(Files.readString(sourceFile, 
StandardCharsets.UTF_8)).contains("z");
+    }
+
+    @Test
+    void escDismissClearsSaveMessageBeforeReopen() {
+        viewer.loadFile(sourceFile);
+        viewer.enterEditMode();
+        viewer.handleKeyEvent(KeyEvent.ofChar('x', KeyModifiers.NONE));
+        viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.F5, KeyModifiers.NONE));
+
+        List<Span> spans = new ArrayList<>();
+        viewer.renderFooter(spans);
+        assertThat(spansToString(spans)).contains("Saved");
+
+        viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.ESCAPE, 
KeyModifiers.NONE));
+        assertThat(viewer.isVisible()).isFalse();
+
+        viewer.loadFile(sourceFile);
+        spans.clear();
+        viewer.renderFooter(spans);
+        assertThat(spansToString(spans)).doesNotContain("Saved");
+    }
+
+    @Test
+    void cKeyDismissClearsViewerState() {
+        viewer.loadFile(sourceFile);
+        viewer.enterEditMode();
+        viewer.handleKeyEvent(KeyEvent.ofChar('x', KeyModifiers.NONE));
+        viewer.handleKeyEvent(KeyEvent.ofKey(KeyCode.F5, KeyModifiers.NONE));
+
+        assertThat(viewer.handleKeyEvent(KeyEvent.ofChar('c', 
KeyModifiers.NONE))).isTrue();
+
+        assertThat(viewer.isVisible()).isFalse();
+        assertThat(viewer.isEditMode()).isFalse();
+        assertThat(viewer.isTextInputActive()).isFalse();
+    }
+
+    private static String spansToString(List<Span> spans) {
+        StringBuilder sb = new StringBuilder();
+        for (Span span : spans) {
+            sb.append(span.content());
+        }
+        return sb.toString();
+    }
+}

Reply via email to