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 f8fe773a04f7 Camel TUI: Add basic file management to Source tab (F12)
f8fe773a04f7 is described below
commit f8fe773a04f7c144c98e622955630275515e5378
Author: Claus Ibsen <[email protected]>
AuthorDate: Wed Sep 2 16:59:36 2026 +0200
Camel TUI: Add basic file management to Source tab (F12)
Add an F12 file-actions menu to the Source tab file list for basic file
management: new file, new folder, rename, duplicate, delete, and copy
path to clipboard.
- File operations live in a UI-free SourceFileOps helper (unit tested).
- Delete is a destructive action: it requires an explicit 'y' to confirm
(Enter never deletes) and uses a warning-styled confirmation dialog
consistent with the other TUI dialogs.
- New/renamed files reflect their file type via the file-list emoji icon.
- Name-entry dialog shows a visible caret while typing.
- New file leaves a hook for a future template-selection wizard.
Camel TUI: Show caret in text input fields via renderWithCursor
TextInput.render() (invoked by Frame.renderStatefulWidget) paints the
text but no cursor cell, so the caret was invisible while typing in
several dialogs and forms. Switch the focused/active input fields to
TextInput.renderWithCursor(), which paints the caret cell.
Applied to the single active/focused field in: FolderInputPopup,
SettingsPopup, InfraBrowserPopup, RunOptionsForm, SendMessagePopup,
HttpProbe, and SqlQueryTab.
Camel TUI: Group F12 file-actions hint with global F-keys in footer
Add a MonitorTab.renderFKeyHints hook so a tab can contribute an F-key
hint that renders grouped with the global F-keys (F1/F2/F10) rather than
at the tail. SourceTab uses it to place F12 file actions next to F10.
Camel TUI: Prettier file-actions menu and confirm/input dialogs
- F12 file-actions menu: drop accelerator keys (cursor navigation only)
and show an emoji per action; roomier padded box with styled title.
- Name-entry input dialog enlarged from a cramped 3-row box to a padded
5-row box with a focused border and styled title.
- Delete confirmation and the other confirm dialogs (kill, generic
confirm, discard changes) now center their text for a cleaner look.
Camel TUI: Use terminal-safe icons for delete and rename file actions
The wastebasket (\U0001F5D1) misaligns because terminals render it narrower
than its reported width; swap it for the cross-mark. The memo icon read as
edit, so rename now uses the letters glyph.
Camel TUI: Harden file-name validation against path traversal
Reject absolute paths, parent traversal, drive letters (':'), control
characters and overlong names when creating/renaming/duplicating source
files, and add a defense-in-depth guard that refuses to operate outside
the intended directory even if a name slips past validation.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Signed-off-by: Claus Ibsen <[email protected]>
---
.../camel/dsl/jbang/core/commands/tui/AiPanel.java | 19 +-
.../dsl/jbang/core/commands/tui/CamelMonitor.java | 3 +
.../jbang/core/commands/tui/FileActionsPopup.java | 375 +++++++++++++++++++++
.../jbang/core/commands/tui/FolderInputPopup.java | 3 +-
.../dsl/jbang/core/commands/tui/HttpProbe.java | 12 +-
.../jbang/core/commands/tui/InfraBrowserPopup.java | 3 +-
.../dsl/jbang/core/commands/tui/MonitorTab.java | 7 +
.../dsl/jbang/core/commands/tui/PopupManager.java | 16 +-
.../jbang/core/commands/tui/RunOptionsForm.java | 3 +-
.../jbang/core/commands/tui/SendMessagePopup.java | 6 +-
.../dsl/jbang/core/commands/tui/SettingsPopup.java | 3 +-
.../dsl/jbang/core/commands/tui/SourceFileOps.java | 164 +++++++++
.../dsl/jbang/core/commands/tui/SourceTab.java | 119 ++++++-
.../dsl/jbang/core/commands/tui/SourceViewer.java | 12 +-
.../dsl/jbang/core/commands/tui/SqlQueryTab.java | 3 +-
.../dsl/jbang/core/commands/tui/TuiHelper.java | 30 +-
.../dsl/jbang/core/commands/tui/TuiIcons.java | 10 +
.../jbang/core/commands/tui/SourceFileOpsTest.java | 160 +++++++++
18 files changed, 903 insertions(+), 45 deletions(-)
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java
index ba7dbb3a91e8..fda8cd5ff4dd 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java
@@ -1286,24 +1286,7 @@ class AiPanel {
}
private static void copyToSystemClipboard(String text) throws IOException {
- String os = System.getProperty("os.name", "").toLowerCase();
- String[] cmd;
- if (os.contains("mac")) {
- cmd = new String[] { "pbcopy" };
- } else if (os.contains("win")) {
- cmd = new String[] { "clip" };
- } else {
- cmd = new String[] { "xclip", "-selection", "clipboard" };
- }
- Process p = new ProcessBuilder(cmd).start();
- try (java.io.OutputStream out = p.getOutputStream()) {
- out.write(text.getBytes(java.nio.charset.StandardCharsets.UTF_8));
- }
- try {
- p.waitFor(5, java.util.concurrent.TimeUnit.SECONDS);
- } catch (InterruptedException e) {
- Thread.currentThread().interrupt();
- }
+ TuiHelper.copyToClipboard(text);
}
private void exportChatToFile() {
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 197aa3e99377..8bbd59f202da 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
@@ -2370,6 +2370,9 @@ public class CamelMonitor extends CamelCommand {
}
hint(fKeySpans, "F2", "actions");
hint(fKeySpans, "F10", "run");
+ if (tab != null) {
+ tab.renderFKeyHints(fKeySpans);
+ }
spans.addAll(insertPos, fKeySpans);
// Return total F-key span count. The footer drop loop uses this to
remove pairs from
// the tail, stopping before the first pair (F1 help when present).
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FileActionsPopup.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FileActionsPopup.java
new file mode 100644
index 000000000000..3a62de0ab9ad
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FileActionsPopup.java
@@ -0,0 +1,375 @@
+/*
+ * 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;
+
+import dev.tamboui.layout.Padding;
+import dev.tamboui.layout.Rect;
+import dev.tamboui.style.Style;
+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.widgets.Clear;
+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.TextInput;
+import dev.tamboui.widgets.input.TextInputState;
+import dev.tamboui.widgets.list.ListItem;
+import dev.tamboui.widgets.list.ListState;
+import dev.tamboui.widgets.list.ListWidget;
+import dev.tamboui.widgets.list.ScrollMode;
+import dev.tamboui.widgets.paragraph.Paragraph;
+
+/**
+ * File-actions menu for the Source tab file list (opened with F12). Presents
basic file management (new file, new
+ * folder, rename, duplicate, delete, copy path) and drives the name-entry
prompt and delete confirmation itself,
+ * emitting a single {@link Request} for the host tab to execute.
+ */
+class FileActionsPopup {
+
+ enum Action {
+ NEW_FILE,
+ NEW_FOLDER,
+ RENAME,
+ DUPLICATE,
+ DELETE,
+ COPY_PATH
+ }
+
+ /** A completed, ready-to-execute request. {@code name} is null for DELETE
and COPY_PATH. */
+ record Request(Action action, String name) {
+ }
+
+ private record MenuItem(Action action, String icon, String label) {
+ }
+
+ private enum Phase {
+ MENU,
+ INPUT,
+ CONFIRM
+ }
+
+ private boolean visible;
+ private Phase phase = Phase.MENU;
+
+ private String targetName;
+ private boolean hasTarget;
+
+ private final ListState menuState = new ListState();
+ private List<MenuItem> items = List.of();
+
+ private Action inputAction;
+ private String inputTitle;
+ private TextInputState inputState;
+
+ private Rect popupRect;
+ private Request result;
+
+ void open(String selectedName, boolean hasTarget) {
+ this.visible = true;
+ this.phase = Phase.MENU;
+ this.targetName = selectedName;
+ this.hasTarget = hasTarget;
+ this.result = null;
+ this.inputState = null;
+ buildMenu();
+ menuState.select(items.isEmpty() ? null : 0);
+ }
+
+ void close() {
+ visible = false;
+ phase = Phase.MENU;
+ inputState = null;
+ }
+
+ boolean isVisible() {
+ return visible;
+ }
+
+ Request consumeResult() {
+ Request r = result;
+ result = null;
+ return r;
+ }
+
+ private void buildMenu() {
+ List<MenuItem> list = new ArrayList<>();
+ list.add(new MenuItem(Action.NEW_FILE, TuiIcons.NEW_FILE, "New
file…"));
+ list.add(new MenuItem(Action.NEW_FOLDER, TuiIcons.NEW_FOLDER, "New
folder…"));
+ if (hasTarget) {
+ list.add(new MenuItem(Action.RENAME, TuiIcons.RENAME, "Rename…"));
+ list.add(new MenuItem(Action.DUPLICATE, TuiIcons.DUPLICATE,
"Duplicate…"));
+ list.add(new MenuItem(Action.DELETE, TuiIcons.DELETE, "Delete"));
+ list.add(new MenuItem(Action.COPY_PATH, TuiIcons.CLIPBOARD, "Copy
path to clipboard"));
+ }
+ this.items = list;
+ }
+
+ boolean handleKeyEvent(KeyEvent ke) {
+ switch (phase) {
+ case MENU:
+ return handleMenuKey(ke);
+ case INPUT:
+ return handleInputKey(ke);
+ case CONFIRM:
+ return handleConfirmKey(ke);
+ default:
+ return true;
+ }
+ }
+
+ private boolean handleMenuKey(KeyEvent ke) {
+ if (ke.isCancel()) {
+ close();
+ return true;
+ }
+ if (ke.isUp()) {
+ menuState.selectPrevious();
+ return true;
+ }
+ if (ke.isDown()) {
+ menuState.selectNext(items.size());
+ return true;
+ }
+ if (ke.isConfirm()) {
+ Integer sel = menuState.selected();
+ if (sel != null && sel < items.size()) {
+ dispatch(items.get(sel).action());
+ }
+ return true;
+ }
+ // Menu is navigated with the cursor only (no accelerator keys);
swallow everything else.
+ return true;
+ }
+
+ private void dispatch(Action action) {
+ switch (action) {
+ case NEW_FILE -> startInput(action, "New file", "");
+ case NEW_FOLDER -> startInput(action, "New folder", "");
+ case RENAME -> startInput(action, "Rename", targetName);
+ case DUPLICATE -> startInput(action, "Duplicate",
SourceFileOps.suggestDuplicateName(targetName));
+ case DELETE -> phase = Phase.CONFIRM;
+ case COPY_PATH -> {
+ result = new Request(Action.COPY_PATH, null);
+ close();
+ }
+ }
+ }
+
+ private void startInput(Action action, String title, String initial) {
+ this.inputAction = action;
+ this.inputTitle = title;
+ this.inputState = new TextInputState(initial != null ? initial : "");
+ this.inputState.moveCursorToEnd();
+ this.phase = Phase.INPUT;
+ }
+
+ private boolean handleInputKey(KeyEvent ke) {
+ if (ke.isCancel()) {
+ // go back to the menu rather than dismissing everything
+ phase = Phase.MENU;
+ inputState = null;
+ return true;
+ }
+ if (ke.isConfirm()) {
+ String text = inputState.text().trim();
+ if (!text.isEmpty()) {
+ result = new Request(inputAction, text);
+ close();
+ }
+ return true;
+ }
+ if (ke.isDeleteBackward()) {
+ inputState.deleteBackward();
+ } else if (ke.isDeleteForward()) {
+ inputState.deleteForward();
+ } else if (ke.isLeft()) {
+ inputState.moveCursorLeft();
+ } else if (ke.isRight()) {
+ inputState.moveCursorRight();
+ } else if (ke.isHome()) {
+ inputState.moveCursorToStart();
+ } else if (ke.isEnd()) {
+ inputState.moveCursorToEnd();
+ } else if (ke.code() == KeyCode.CHAR) {
+ char ch = ke.string().charAt(0);
+ if (ch >= 0x20 && ch != 0x7F) {
+ inputState.insert(ch);
+ }
+ }
+ return true;
+ }
+
+ private boolean handleConfirmKey(KeyEvent ke) {
+ // Delete is a destructive action: only an explicit "y" confirms it.
Enter must NOT delete, so it
+ // (and Esc, "n", or any other key) simply returns to the menu.
+ if (ke.code() == KeyCode.CHAR && "y".equalsIgnoreCase(ke.string())) {
+ result = new Request(Action.DELETE, null);
+ close();
+ return true;
+ }
+ phase = Phase.MENU;
+ return true;
+ }
+
+ void render(Frame frame, Rect area) {
+ if (!visible) {
+ return;
+ }
+ switch (phase) {
+ case MENU -> renderMenu(frame, area);
+ case INPUT -> renderInput(frame, area);
+ case CONFIRM -> renderConfirm(frame, area);
+ default -> {
+ }
+ }
+ }
+
+ private void renderMenu(Frame frame, Rect area) {
+ int popupW = Math.max(34, Math.min(44, area.width() - 4));
+ popupW = Math.min(popupW, area.width() - 2);
+ // one blank padding row above and below the items, plus the two
border rows
+ int popupH = items.size() + 4;
+ int x = area.left() + Math.max(0, (area.width() - popupW) / 2);
+ int y = area.top() + Math.max(0, (area.height() - popupH) / 3);
+ Rect popup = new Rect(x, y, popupW, Math.min(popupH, area.height() -
2));
+ this.popupRect = popup;
+
+ frame.renderWidget(Clear.INSTANCE, popup);
+
+ List<ListItem> listItems = new ArrayList<>();
+ for (MenuItem item : items) {
+ List<Span> spans = new ArrayList<>();
+ spans.add(Span.raw(" "));
+ spans.add(Span.raw(item.icon()));
+ spans.add(Span.raw(" "));
+ spans.add(Span.raw(item.label()));
+ listItems.add(ListItem.from(Line.from(spans)));
+ }
+
+ ListWidget list = ListWidget.builder()
+ .items(listItems.toArray(ListItem[]::new))
+ .highlightStyle(Theme.selectionBg())
+ .highlightSymbol("")
+ .scrollMode(ScrollMode.AUTO_SCROLL)
+ .block(Block.builder()
+ .borderType(BorderType.ROUNDED).borders(Borders.ALL)
+ .borderStyle(Theme.borderFocused())
+ .padding(Padding.vertical(1))
+ .title(Title.from(Line.from(
+ Span.styled(" " + TuiIcons.FOLDER_OPEN + "
File Actions ", Theme.title().bold()))))
+ .build())
+ .build();
+ frame.renderStatefulWidget(list, popup, menuState);
+ }
+
+ private void renderInput(Frame frame, Rect area) {
+ int popupW = Math.max(50, Math.min(64, area.width() - 4));
+ popupW = Math.min(popupW, area.width() - 2);
+ int popupH = 5;
+ int x = area.left() + Math.max(0, (area.width() - popupW) / 2);
+ int y = area.top() + Math.max(0, (area.height() - popupH) / 3);
+ Rect popup = new Rect(x, y, popupW, Math.min(popupH, area.height()));
+ this.popupRect = popup;
+
+ frame.renderWidget(Clear.INSTANCE, popup);
+ Block block = Block.builder()
+ .borderType(BorderType.ROUNDED).borders(Borders.ALL)
+ .borderStyle(Theme.borderFocused())
+ .title(Title.from(Line.from(Span.styled(" " + inputTitle + "
", Theme.title().bold()))))
+ .build();
+ frame.renderWidget(block, popup);
+ Rect inner = block.inner(popup);
+
+ // Place the input on the middle row with a small horizontal margin,
leaving a blank line above and below so
+ // the dialog does not feel cramped.
+ int pad = 2;
+ int fieldW = Math.max(1, inner.width() - 2 * pad);
+ int fieldY = inner.top() + Math.max(0, (inner.height() - 1) / 2);
+ Rect field = new Rect(inner.left() + pad, fieldY, fieldW, 1);
+
+ TextInput textInput = TextInput.builder()
+ .cursorStyle(Style.EMPTY.reversed())
+ .placeholder("name")
+ .build();
+ // Use renderWithCursor (not renderStatefulWidget, which calls
render() and paints no cursor cell) so the
+ // caret is visible while typing the name.
+ textInput.renderWithCursor(field, frame.buffer(), inputState, frame);
+ }
+
+ private void renderConfirm(Frame frame, Rect area) {
+ String msg = "Delete " + targetName + "?";
+ int popupW = Math.max(40, Math.min(60, msg.length() + 6));
+ popupW = Math.min(popupW, area.width() - 4);
+ int popupH = 6;
+ int x = area.left() + Math.max(0, (area.width() - popupW) / 2);
+ int y = area.top() + Math.max(0, (area.height() - popupH) / 3);
+ Rect popup = new Rect(x, y, Math.min(popupW, area.width()),
Math.min(popupH, area.height()));
+ this.popupRect = popup;
+
+ frame.renderWidget(Clear.INSTANCE, popup);
+ Block block = Block.builder()
+ .borderType(BorderType.ROUNDED).borders(Borders.ALL)
+ .borderStyle(Theme.warning())
+ .title(Title.from(Line.from(Span.styled(" " + TuiIcons.DELETE
+ " Delete file? ", Theme.warning().bold()))))
+ .build();
+ frame.renderWidget(block, popup);
+ Rect inner = block.inner(popup);
+ frame.renderWidget(
+ Paragraph.builder()
+ .centered()
+ .text(Text.from(
+ Line.empty(),
+ Line.from(Span.styled(msg,
Theme.warning().bold())),
+ Line.empty(),
+ Line.from(
+ Span.styled("y", Style.EMPTY.bold()),
Span.raw(" delete "),
+ Span.styled("Esc",
Style.EMPTY.bold()), Span.raw(" cancel"))))
+ .build(),
+ inner);
+ }
+
+ void renderFooter(List<Span> spans) {
+ if (!visible) {
+ return;
+ }
+ switch (phase) {
+ case MENU -> {
+ TuiHelper.hint(spans, TuiIcons.HINT_SCROLL, "navigate");
+ TuiHelper.hint(spans, "Enter", "select");
+ TuiHelper.hintLast(spans, "Esc", "close");
+ }
+ case INPUT -> {
+ TuiHelper.hint(spans, "Enter", "confirm");
+ TuiHelper.hintLast(spans, "Esc", "back");
+ }
+ case CONFIRM -> {
+ TuiHelper.hint(spans, "y", "delete");
+ TuiHelper.hintLast(spans, "Esc", "cancel");
+ }
+ default -> {
+ }
+ }
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FolderInputPopup.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FolderInputPopup.java
index 76700775abab..a98f315fb090 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FolderInputPopup.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FolderInputPopup.java
@@ -358,7 +358,8 @@ class FolderInputPopup {
.cursorStyle(Style.EMPTY.reversed())
.placeholder("/path/to/folder")
.build();
- frame.renderStatefulWidget(textInput, inputArea, inputState);
+ // renderWithCursor (not renderStatefulWidget) so the caret is visible
while typing
+ textInput.renderWithCursor(inputArea, frame.buffer(), inputState,
frame);
}
private void doLaunchFolder(String folder, String pomPath, String
displayName, List<String> extraArgs) {
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HttpProbe.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HttpProbe.java
index df41c549381c..32933ed55de6 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HttpProbe.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HttpProbe.java
@@ -1309,7 +1309,8 @@ class HttpProbe {
Rect pathArea = new Rect(innerX + labelW, row, fieldW, 1);
if (probeField == PROBE_PATH && !probeSending.get()) {
TextInput textInput =
TextInput.builder().cursorStyle(Style.EMPTY.reversed()).build();
- frame.renderStatefulWidget(textInput, pathArea, probePathState);
+ // renderWithCursor (not renderStatefulWidget) so the caret is
painted on the active field
+ textInput.renderWithCursor(pathArea, frame.buffer(),
probePathState, frame);
} else {
String pathText = probePathState.text();
frame.renderWidget(Paragraph.from(Line.from(
@@ -1342,7 +1343,8 @@ class HttpProbe {
.cursorStyle(Style.EMPTY.reversed())
.placeholder("value for {" + pp.name + "}")
.build();
- frame.renderStatefulWidget(textInput, paramInputArea,
pp.input);
+ // renderWithCursor (not renderStatefulWidget) so the
caret is painted on the active field
+ textInput.renderWithCursor(paramInputArea, frame.buffer(),
pp.input, frame);
} else {
String val = pp.input.text();
frame.renderWidget(Paragraph.from(Line.from(
@@ -1436,7 +1438,8 @@ class HttpProbe {
Rect keyArea = new Rect(fieldX, row, keyW, 1);
if (isSelected && editingKey && !probeSending.get()) {
TextInput keyInput =
TextInput.builder().cursorStyle(Style.EMPTY.reversed()).build();
- frame.renderStatefulWidget(keyInput, keyArea, he.keyInput());
+ // renderWithCursor (not renderStatefulWidget) so the caret is
painted on the active field
+ keyInput.renderWithCursor(keyArea, frame.buffer(),
he.keyInput(), frame);
} else {
String keyText = he.keyInput().text();
Style keyStyle = keyText.isEmpty() ? Style.EMPTY.dim()
@@ -1452,7 +1455,8 @@ class HttpProbe {
Rect valArea = new Rect(fieldX + keyW + 3, row, valW, 1);
if (isSelected && !editingKey && !probeSending.get()) {
TextInput valInput =
TextInput.builder().cursorStyle(Style.EMPTY.reversed()).build();
- frame.renderStatefulWidget(valInput, valArea, he.valueInput());
+ // renderWithCursor (not renderStatefulWidget) so the caret is
painted on the active field
+ valInput.renderWithCursor(valArea, frame.buffer(),
he.valueInput(), frame);
} else {
String valText = he.valueInput().text();
Style valStyle = valText.isEmpty() ? Style.EMPTY.dim()
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/InfraBrowserPopup.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/InfraBrowserPopup.java
index 72a5c8c30be9..1c0cbcda56ec 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/InfraBrowserPopup.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/InfraBrowserPopup.java
@@ -463,7 +463,8 @@ class InfraBrowserPopup {
.cursorStyle(Style.EMPTY.reversed())
.placeholder("default")
.build();
- frame.renderStatefulWidget(textInput, portArea, portState);
+ // renderWithCursor (not renderStatefulWidget) so the caret is
painted on the active field
+ textInput.renderWithCursor(portArea, frame.buffer(), portState,
frame);
} else {
String portText = portState != null ? portState.text() : "";
frame.renderWidget(Paragraph.from(Line.from(
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorTab.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorTab.java
index c965a750fd79..673fc5474058 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorTab.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorTab.java
@@ -58,6 +58,13 @@ interface MonitorTab {
default void renderFooter(List<Span> spans) {
}
+ /**
+ * Contributes tab-specific F-key hints that should render grouped with
the global F-key hints (F1/F2/F10) in the
+ * footer, rather than at the tail with the other tab hints. Appended
right after the global F-keys.
+ */
+ default void renderFKeyHints(List<Span> spans) {
+ }
+
default void onTabSelected() {
}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/PopupManager.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/PopupManager.java
index 4ea5fad6720d..2f7250a2c4ae 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/PopupManager.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/PopupManager.java
@@ -713,14 +713,14 @@ class PopupManager {
Rect inner = block.inner(popup);
frame.renderWidget(
Paragraph.builder()
+ .centered()
.text(Text.from(
- Line.from(Span.raw("")),
+ Line.empty(),
Line.from(Span.styled(msg,
Theme.error().bold())),
- Line.from(Span.raw("")),
+ Line.empty(),
Line.from(
- Span.raw(" "),
Span.styled("Enter",
Style.EMPTY.bold()),
- Span.raw(" confirm "),
+ Span.raw(" confirm "),
Span.styled("Esc", Style.EMPTY.bold()),
Span.raw(" cancel"))))
.build(),
@@ -746,14 +746,14 @@ class PopupManager {
Rect inner = block.inner(popup);
frame.renderWidget(
Paragraph.builder()
+ .centered()
.text(Text.from(
- Line.from(Span.raw("")),
+ Line.empty(),
Line.from(Span.styled(msg,
Theme.warning().bold())),
- Line.from(Span.raw("")),
+ Line.empty(),
Line.from(
- Span.raw(" "),
Span.styled("Enter",
Style.EMPTY.bold()),
- Span.raw(" confirm "),
+ Span.raw(" confirm "),
Span.styled("Esc", Style.EMPTY.bold()),
Span.raw(" cancel"))))
.build(),
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RunOptionsForm.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RunOptionsForm.java
index 95519389cff7..89c5797ebf7b 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RunOptionsForm.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RunOptionsForm.java
@@ -857,7 +857,8 @@ class RunOptionsForm {
TextInput textInput = TextInput.builder()
.cursorStyle(Style.EMPTY.reversed())
.build();
- frame.renderStatefulWidget(textInput, inputArea, state);
+ // renderWithCursor (not renderStatefulWidget) so the caret is
painted on the active field
+ textInput.renderWithCursor(inputArea, frame.buffer(), state,
frame);
} else {
String text = state.text();
if (text.isEmpty() && hint != null) {
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SendMessagePopup.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SendMessagePopup.java
index 84f898e630fa..73771658249f 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SendMessagePopup.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SendMessagePopup.java
@@ -929,7 +929,8 @@ class SendMessagePopup {
TextInput keyInput = TextInput.builder()
.cursorStyle(Style.EMPTY.reversed())
.build();
- frame.renderStatefulWidget(keyInput, keyArea,
he.keyInput());
+ // renderWithCursor (not renderStatefulWidget) so the
caret is painted on the active field
+ keyInput.renderWithCursor(keyArea, frame.buffer(),
he.keyInput(), frame);
} else {
String keyText = he.keyInput().text();
Style keyStyle;
@@ -954,7 +955,8 @@ class SendMessagePopup {
TextInput valInput = TextInput.builder()
.cursorStyle(Style.EMPTY.reversed())
.build();
- frame.renderStatefulWidget(valInput, valArea,
he.valueInput());
+ // renderWithCursor (not renderStatefulWidget) so the
caret is painted on the active field
+ valInput.renderWithCursor(valArea, frame.buffer(),
he.valueInput(), frame);
} else {
String valText = he.valueInput().text();
Style valStyle;
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SettingsPopup.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SettingsPopup.java
index b56e8b55a937..514dc89bbfdf 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SettingsPopup.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SettingsPopup.java
@@ -568,7 +568,8 @@ class SettingsPopup {
.cursorStyle(Style.EMPTY.reversed())
.placeholder(placeholder)
.build();
- frame.renderStatefulWidget(textInput, area, input);
+ // renderWithCursor (not renderStatefulWidget) so the caret is
painted on the active field
+ textInput.renderWithCursor(area, frame.buffer(), input, frame);
} else {
String text = input.text();
Style style = text.isEmpty() ? Style.EMPTY.dim() : Style.EMPTY;
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceFileOps.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceFileOps.java
new file mode 100644
index 000000000000..2be9ccff9f2f
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceFileOps.java
@@ -0,0 +1,164 @@
+/*
+ * 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.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+/**
+ * Pure, side-effect-focused file-management operations used by the Source
tab's file-actions menu (new file / new
+ * folder / rename / duplicate / delete). Kept free of any UI so the logic can
be unit tested in isolation.
+ */
+final class SourceFileOps {
+
+ private SourceFileOps() {
+ }
+
+ /**
+ * Validates a proposed file or folder name.
+ *
+ * @return {@code null} when the name is acceptable, otherwise a
human-readable error message
+ */
+ static String validateName(String name) {
+ if (name == null || name.isBlank()) {
+ return "Name must not be empty";
+ }
+ String trimmed = name.trim();
+ if (trimmed.equals(".") || trimmed.equals("..")) {
+ return "Invalid name: " + trimmed;
+ }
+ // A name is a single path segment: reject anything that could turn it
into a path (absolute paths such as
+ // /usr/evil, parent traversal, drive letters like C:\).
+ if (trimmed.indexOf('/') >= 0 || trimmed.indexOf('\\') >= 0) {
+ return "Name must not contain path separators";
+ }
+ if (trimmed.indexOf(':') >= 0) {
+ return "Name must not contain ':'";
+ }
+ if (trimmed.length() > 255) {
+ return "Name is too long (max 255 characters)";
+ }
+ for (int i = 0; i < trimmed.length(); i++) {
+ char c = trimmed.charAt(i);
+ if (c < 0x20 || c == 0x7F) {
+ return "Name must not contain control characters";
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Suggests a duplicate name for the given file name, e.g. {@code
route.camel.yaml} becomes
+ * {@code route-copy.camel.yaml}, preserving a compound extension.
+ */
+ static String suggestDuplicateName(String name) {
+ if (name == null || name.isBlank()) {
+ return "copy";
+ }
+ // preserve compound extensions such as .camel.yaml by splitting at
the first dot
+ int dot = name.indexOf('.');
+ if (dot <= 0) {
+ return name + "-copy";
+ }
+ return name.substring(0, dot) + "-copy" + name.substring(dot);
+ }
+
+ static Path createFile(Path dir, String name) throws IOException {
+ Path target = resolveNew(dir, name);
+ Files.createFile(target);
+ return target;
+ }
+
+ static Path createFolder(Path dir, String name) throws IOException {
+ Path target = resolveNew(dir, name);
+ Files.createDirectory(target);
+ return target;
+ }
+
+ static Path rename(Path source, String newName) throws IOException {
+ String err = validateName(newName);
+ if (err != null) {
+ throw new IllegalArgumentException(err);
+ }
+ Path target = source.resolveSibling(newName.trim());
+ ensureChildOf(source.getParent(), target);
+ if (Files.exists(target)) {
+ throw new IOException("Already exists: " + target.getFileName());
+ }
+ Files.move(source, target);
+ return target;
+ }
+
+ static Path copy(Path source, String newName) throws IOException {
+ String err = validateName(newName);
+ if (err != null) {
+ throw new IllegalArgumentException(err);
+ }
+ Path target = source.resolveSibling(newName.trim());
+ ensureChildOf(source.getParent(), target);
+ if (Files.exists(target)) {
+ throw new IOException("Already exists: " + target.getFileName());
+ }
+ Files.copy(source, target);
+ return target;
+ }
+
+ /**
+ * Deletes a file or an empty directory. Refuses to delete a non-empty
directory to avoid accidental recursive loss
+ * of work.
+ */
+ static void delete(Path target) throws IOException {
+ if (Files.isDirectory(target)) {
+ try (var stream = Files.list(target)) {
+ if (stream.findAny().isPresent()) {
+ throw new IOException("Directory is not empty: " +
target.getFileName());
+ }
+ }
+ }
+ Files.delete(target);
+ }
+
+ private static Path resolveNew(Path dir, String name) throws IOException {
+ String err = validateName(name);
+ if (err != null) {
+ throw new IllegalArgumentException(err);
+ }
+ Path target = dir.resolve(name.trim());
+ ensureChildOf(dir, target);
+ if (Files.exists(target)) {
+ throw new IOException("Already exists: " + target.getFileName());
+ }
+ return target;
+ }
+
+ /**
+ * Defense-in-depth guard: verifies {@code target} resolves to a direct
child of {@code parent} after normalization.
+ * {@link #validateName} already blocks separators and traversal, but this
ensures the framework never operates
+ * outside the intended directory even if a name slips past validation.
+ */
+ private static void ensureChildOf(Path parent, Path target) throws
IOException {
+ if (parent == null) {
+ throw new IOException("Cannot resolve target directory");
+ }
+ Path normParent = parent.toAbsolutePath().normalize();
+ Path normTarget = target.toAbsolutePath().normalize();
+ if (!normParent.equals(normTarget.getParent())) {
+ throw new IOException("Refusing to operate outside " + normParent);
+ }
+ }
+}
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 9093fd8e583b..d17c3557a428 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
@@ -138,6 +138,7 @@ class SourceTab extends AbstractTab {
private List<ToEntry> toIndex = Collections.emptyList();
private final GotoRoutePopup gotoRoutePopup = new GotoRoutePopup();
private final GotoSourceNodePopup gotoSourceNodePopup = new
GotoSourceNodePopup();
+ private final FileActionsPopup fileActionsPopup = new FileActionsPopup();
SourceTab(MonitorContext ctx) {
super(ctx);
@@ -155,7 +156,9 @@ class SourceTab extends AbstractTab {
}
boolean isSourceViewerTextInputActive() {
- return sourceViewer.isTextInputActive();
+ // also treat the file-actions menu as active input so global
single-key shortcuts (q, ?, ...)
+ // do not fire while the menu, its name prompt, or delete confirmation
is open
+ return sourceViewer.isTextInputActive() ||
fileActionsPopup.isVisible();
}
void handlePaste(String text) {
@@ -193,6 +196,15 @@ class SourceTab extends AbstractTab {
@Override
public boolean handleKeyEvent(KeyEvent ke) {
+ if (fileActionsPopup.isVisible()) {
+ fileActionsPopup.handleKeyEvent(ke);
+ FileActionsPopup.Request req = fileActionsPopup.consumeResult();
+ if (req != null) {
+ executeFileAction(req);
+ }
+ return true;
+ }
+
if (gotoRoutePopup.isVisible()) {
gotoRoutePopup.handleKeyEvent(ke);
GotoRoutePopup.RouteItem sel = gotoRoutePopup.consumeSelection();
@@ -302,12 +314,16 @@ class SourceTab extends AbstractTab {
@Override
public boolean isOverlayActive() {
- return focusOnViewer && sourceViewer.isTextInputActive();
+ return fileActionsPopup.isVisible() || (focusOnViewer &&
sourceViewer.isTextInputActive());
}
@Override
public boolean handleEscape() {
// Esc is routed here from CamelMonitor before tab key handling —
cancel overlays locally
+ if (fileActionsPopup.isVisible()) {
+ fileActionsPopup.handleKeyEvent(KeyEvent.ofKey(KeyCode.ESCAPE));
+ return true;
+ }
if (gotoRoutePopup.isVisible()) {
gotoRoutePopup.close();
return true;
@@ -389,10 +405,17 @@ class SourceTab extends AbstractTab {
if (gotoSourceNodePopup.isVisible()) {
gotoSourceNodePopup.render(frame, area);
}
+ if (fileActionsPopup.isVisible()) {
+ fileActionsPopup.render(frame, area);
+ }
}
@Override
public void renderFooter(List<Span> spans) {
+ if (fileActionsPopup.isVisible()) {
+ fileActionsPopup.renderFooter(spans);
+ return;
+ }
if (focusOnViewer && sourceViewer.isVisible()) {
sourceViewer.renderFooter(spans);
if (!sourceViewer.isEditMode()) {
@@ -416,6 +439,15 @@ class SourceTab extends AbstractTab {
}
}
+ @Override
+ public void renderFKeyHints(List<Span> spans) {
+ // Group the F12 file-actions hint with the global F-keys (next to
F10) rather than at the tail. Only shown
+ // when the file list is focused (not the viewer) and no dialog is
open.
+ if (!fileActionsPopup.isVisible() && !(focusOnViewer &&
sourceViewer.isVisible())) {
+ TuiHelper.hint(spans, "F12", "file actions");
+ }
+ }
+
@Override
public String description() {
return "Browse and view source files of the integration";
@@ -434,6 +466,7 @@ class SourceTab extends AbstractTab {
- **Up/Down** — navigate files
- **Enter** — open file or directory
- **F4** — open file directly in edit mode
+ - **F12** — file actions menu (new file, new folder, rename,
duplicate, delete, copy path)
- **Backspace** — go to parent directory
## Source Viewer (right panel)
@@ -736,9 +769,91 @@ class SourceTab extends AbstractTab {
}
return true;
}
+ if (ke.isKey(KeyCode.F12)) {
+ openFileActionsMenu();
+ return true;
+ }
return false;
}
+ private void openFileActionsMenu() {
+ if (currentDir == null) {
+ return;
+ }
+ FilesBrowser.FileEntry entry = selectedEntry();
+ boolean hasTarget = entry != null && !"..".equals(entry.name());
+ fileActionsPopup.open(hasTarget ? entry.name() : null, hasTarget);
+ }
+
+ private FilesBrowser.FileEntry selectedEntry() {
+ Integer sel = listState.selected();
+ if (sel != null && sel >= 0 && sel < entries.size()) {
+ return entries.get(sel);
+ }
+ return null;
+ }
+
+ private void executeFileAction(FileActionsPopup.Request req) {
+ FilesBrowser.FileEntry entry = selectedEntry();
+ try {
+ switch (req.action()) {
+ case NEW_FILE -> {
+ // TODO: a future template wizard will let the user pick a
starter route here
+ Path p = SourceFileOps.createFile(currentDir, req.name());
+ if (loadDirectory(currentDir, p.getFileName().toString()))
{
+ openSelectedEntry();
+ }
+ notify("Created " + p.getFileName(), false);
+ }
+ case NEW_FOLDER -> {
+ Path p = SourceFileOps.createFolder(currentDir,
req.name());
+ loadDirectory(currentDir, p.getFileName().toString());
+ notify("Created " + p.getFileName() + "/", false);
+ }
+ case RENAME -> {
+ if (entry == null) {
+ return;
+ }
+ Path p = SourceFileOps.rename(Path.of(entry.path()),
req.name());
+ loadDirectory(currentDir, p.getFileName().toString());
+ notify("Renamed to " + p.getFileName(), false);
+ }
+ case DUPLICATE -> {
+ if (entry == null) {
+ return;
+ }
+ Path p = SourceFileOps.copy(Path.of(entry.path()),
req.name());
+ loadDirectory(currentDir, p.getFileName().toString());
+ notify("Duplicated to " + p.getFileName(), false);
+ }
+ case DELETE -> {
+ if (entry == null) {
+ return;
+ }
+ String name = entry.name();
+ SourceFileOps.delete(Path.of(entry.path()));
+ loadDirectory(currentDir);
+ notify("Deleted " + name, false);
+ }
+ case COPY_PATH -> {
+ if (entry == null) {
+ return;
+ }
+ TuiHelper.copyToClipboard(entry.path());
+ notify("Copied path to clipboard", false);
+ }
+ }
+ } catch (Exception e) {
+ notify(e.getMessage() != null ? e.getMessage() : e.toString(),
true);
+ }
+ }
+
+ private void notify(String msg, boolean error) {
+ if (ctx.notificationCallback != null) {
+ ctx.notificationCallback.accept(msg, error);
+ }
+ }
+
private void openSelectedEntry() {
if (sourceViewer.isEditMode()) {
return;
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 bd40a71105bf..b92554b5e451 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
@@ -3004,7 +3004,8 @@ class SourceViewer {
}
private void renderDiscardPopup(Frame frame, Rect area) {
- int popupW = Math.min(40, area.width() - 4);
+ int popupW = Math.max(40, Math.min(44, area.width() - 4));
+ popupW = Math.min(popupW, area.width() - 2);
int popupH = 6;
int x = area.left() + Math.max(0, (area.width() - popupW) / 2);
int y = area.top() + Math.max(0, (area.height() - popupH) / 2);
@@ -3014,18 +3015,19 @@ class SourceViewer {
Block block = Block.builder()
.borderType(BorderType.ROUNDED).borders(Borders.ALL)
+ .borderStyle(Theme.warning())
.title(Title.from(Line.from(Span.styled(" Discard Changes? ",
Theme.warning().bold()))))
.build();
frame.renderWidget(block, popup);
Rect inner = block.inner(popup);
frame.renderWidget(
- Paragraph.builder().text(Text.from(
+ Paragraph.builder().centered().text(Text.from(
Line.empty(),
- Line.from(Span.raw(" Unsaved changes will be lost.")),
+ Line.from(Span.raw("Unsaved changes will be lost.")),
Line.empty(),
- Line.from(Span.raw(" "),
- Span.styled("Enter", Style.EMPTY.bold()),
Span.raw(" confirm "),
+ Line.from(
+ Span.styled("Enter", Style.EMPTY.bold()),
Span.raw(" confirm "),
Span.styled("Esc", Style.EMPTY.bold()),
Span.raw(" cancel"))))
.build(),
inner);
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SqlQueryTab.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SqlQueryTab.java
index 003597bcd80b..1556e821c752 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SqlQueryTab.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SqlQueryTab.java
@@ -792,7 +792,8 @@ class SqlQueryTab extends AbstractTab {
TextInput input = TextInput.builder()
.cursorStyle(cursorStyle)
.build();
- frame.renderStatefulWidget(input, valArea, editInputs[i]);
+ // renderWithCursor (not renderStatefulWidget) so the caret is
painted on the focused field
+ input.renderWithCursor(valArea, frame.buffer(), editInputs[i],
frame);
} else {
String val = editInputs[i].text();
boolean changed = !val.equals(editOriginalValues[i]);
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiHelper.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiHelper.java
index d062c3f34f40..fb6a4d1ff941 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiHelper.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiHelper.java
@@ -723,7 +723,10 @@ final class TuiHelper {
if ("pom.xml".equals(lower)) {
return detectPomEmoji(path);
}
- if (lower.endsWith(".kamelet.yaml") || lower.endsWith(".kamelet.yml"))
{
+ if (lower.endsWith(".kamelet.yaml") || lower.endsWith(".kamelet.yml")
+ || lower.endsWith(".camel.yaml") ||
lower.endsWith(".camel.yml")) {
+ // the .camel.yaml / .kamelet.yaml naming convention denotes a
Camel file by name, so a
+ // freshly created (still empty) file shows the Camel icon without
needing content
return TuiIcons.CAMEL;
}
if (lower.endsWith(".yaml") || lower.endsWith(".yml")) {
@@ -953,4 +956,29 @@ final class TuiHelper {
t.setName("heap-dump-" + pid);
t.start();
}
+
+ /**
+ * Copies the given text to the system clipboard using the platform's
native clipboard command ({@code pbcopy} on
+ * macOS, {@code clip} on Windows, {@code xclip} on Linux).
+ */
+ static void copyToClipboard(String text) throws IOException {
+ String os = System.getProperty("os.name", "").toLowerCase(Locale.ROOT);
+ String[] cmd;
+ if (os.contains("mac")) {
+ cmd = new String[] { "pbcopy" };
+ } else if (os.contains("win")) {
+ cmd = new String[] { "clip" };
+ } else {
+ cmd = new String[] { "xclip", "-selection", "clipboard" };
+ }
+ Process p = new ProcessBuilder(cmd).start();
+ try (java.io.OutputStream out = p.getOutputStream()) {
+ out.write(text.getBytes(StandardCharsets.UTF_8));
+ }
+ try {
+ p.waitFor(5, java.util.concurrent.TimeUnit.SECONDS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiIcons.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiIcons.java
index e8716ba2d65f..c92e0a5856c0 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiIcons.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiIcons.java
@@ -51,6 +51,16 @@ final class TuiIcons {
static final String DOCUMENT = "📄";
static final String README = "📖";
+ // ---- File actions ----
+ static final String NEW_FILE = "📄";
+ static final String NEW_FOLDER = "📁";
+ // memo (📝) reads as "edit"; the letters glyph reads as changing the name
+ static final String RENAME = "🔤";
+ static final String DUPLICATE = "📑";
+ // NOTE: the wastebasket emoji (🗑) is width-ambiguous and TamboUI does not
align it correctly yet, so use the
+ // cross-mark instead until that is fixed upstream.
+ static final String DELETE = "❌";
+
// ---- Actions menu ----
static final String GO_TO = "🔍";
static final String MESSAGE = "📩";
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceFileOpsTest.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceFileOpsTest.java
new file mode 100644
index 000000000000..9482aea44b08
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceFileOpsTest.java
@@ -0,0 +1,160 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.tui;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class SourceFileOpsTest {
+
+ @TempDir
+ Path dir;
+
+ @Test
+ void validateNameRejectsEmptyDotsAndSeparators() {
+ assertThat(SourceFileOps.validateName("route.yaml")).isNull();
+ assertThat(SourceFileOps.validateName("")).isNotNull();
+ assertThat(SourceFileOps.validateName(" ")).isNotNull();
+ assertThat(SourceFileOps.validateName(".")).isNotNull();
+ assertThat(SourceFileOps.validateName("..")).isNotNull();
+ assertThat(SourceFileOps.validateName("a/b")).isNotNull();
+ assertThat(SourceFileOps.validateName("a\\b")).isNotNull();
+ }
+
+ @Test
+ void suggestDuplicatePreservesCompoundExtension() {
+
assertThat(SourceFileOps.suggestDuplicateName("route.camel.yaml")).isEqualTo("route-copy.camel.yaml");
+
assertThat(SourceFileOps.suggestDuplicateName("notes")).isEqualTo("notes-copy");
+
assertThat(SourceFileOps.suggestDuplicateName(".hidden")).isEqualTo(".hidden-copy");
+ }
+
+ @Test
+ void createFileCreatesEmptyFile() throws IOException {
+ Path p = SourceFileOps.createFile(dir, "new.camel.yaml");
+ assertThat(p).exists().hasFileName("new.camel.yaml");
+ assertThat(Files.readString(p)).isEmpty();
+ }
+
+ @Test
+ void createFileRejectsDuplicate() throws IOException {
+ SourceFileOps.createFile(dir, "dup.yaml");
+ assertThatThrownBy(() -> SourceFileOps.createFile(dir, "dup.yaml"))
+ .isInstanceOf(IOException.class)
+ .hasMessageContaining("Already exists");
+ }
+
+ @Test
+ void createFolderCreatesDirectory() throws IOException {
+ Path p = SourceFileOps.createFolder(dir, "sub");
+ assertThat(p).isDirectory();
+ }
+
+ @Test
+ void renameMovesFile() throws IOException {
+ Path src = Files.writeString(dir.resolve("old.yaml"), "hi",
StandardCharsets.UTF_8);
+ Path p = SourceFileOps.rename(src, "new.yaml");
+ assertThat(src).doesNotExist();
+ assertThat(p).exists().hasFileName("new.yaml");
+ assertThat(Files.readString(p)).isEqualTo("hi");
+ }
+
+ @Test
+ void renameRejectsExistingTarget() throws IOException {
+ Path src = Files.writeString(dir.resolve("a.yaml"), "a",
StandardCharsets.UTF_8);
+ Files.writeString(dir.resolve("b.yaml"), "b", StandardCharsets.UTF_8);
+ assertThatThrownBy(() -> SourceFileOps.rename(src, "b.yaml"))
+ .isInstanceOf(IOException.class)
+ .hasMessageContaining("Already exists");
+ }
+
+ @Test
+ void copyDuplicatesContent() throws IOException {
+ Path src = Files.writeString(dir.resolve("a.yaml"), "body",
StandardCharsets.UTF_8);
+ Path p = SourceFileOps.copy(src, "a-copy.yaml");
+ assertThat(src).exists();
+ assertThat(p).exists();
+ assertThat(Files.readString(p)).isEqualTo("body");
+ }
+
+ @Test
+ void deleteRemovesFile() throws IOException {
+ Path src = Files.writeString(dir.resolve("gone.yaml"), "x",
StandardCharsets.UTF_8);
+ SourceFileOps.delete(src);
+ assertThat(src).doesNotExist();
+ }
+
+ @Test
+ void deleteRemovesEmptyDirectory() throws IOException {
+ Path sub = Files.createDirectory(dir.resolve("empty"));
+ SourceFileOps.delete(sub);
+ assertThat(sub).doesNotExist();
+ }
+
+ @Test
+ void deleteRefusesNonEmptyDirectory() throws IOException {
+ Path sub = Files.createDirectory(dir.resolve("full"));
+ Files.writeString(sub.resolve("child.yaml"), "x",
StandardCharsets.UTF_8);
+ assertThatThrownBy(() -> SourceFileOps.delete(sub))
+ .isInstanceOf(IOException.class)
+ .hasMessageContaining("not empty");
+ assertThat(sub).isDirectory();
+ }
+
+ @Test
+ void createRejectsInvalidName() {
+ assertThatThrownBy(() -> SourceFileOps.createFile(dir, "a/b.yaml"))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ @Test
+ void validateNameRejectsAbsoluteAndTraversalPaths() {
+ assertThat(SourceFileOps.validateName("/usr/evil/xxx")).isNotNull();
+ assertThat(SourceFileOps.validateName("../../etc/passwd")).isNotNull();
+
assertThat(SourceFileOps.validateName("C:\\Windows\\evil")).isNotNull();
+ assertThat(SourceFileOps.validateName("foo:bar")).isNotNull();
+ }
+
+ @Test
+ void validateNameRejectsControlCharsAndOverlongNames() {
+ assertThat(SourceFileOps.validateName("a\u0000b")).isNotNull();
+ assertThat(SourceFileOps.validateName("a\tb")).isNotNull();
+ assertThat(SourceFileOps.validateName("x".repeat(256))).isNotNull();
+ assertThat(SourceFileOps.validateName("x".repeat(255))).isNull();
+ }
+
+ @Test
+ void createFolderRejectsAbsolutePath() {
+ assertThatThrownBy(() -> SourceFileOps.createFolder(dir,
"/usr/evil/xxx"))
+ .isInstanceOf(IllegalArgumentException.class);
+ assertThat(dir.resolve("usr")).doesNotExist();
+ }
+
+ @Test
+ void createFolderStaysInsideDir() throws IOException {
+ Path p = SourceFileOps.createFolder(dir, "sub");
+ assertThat(p.toAbsolutePath().normalize().getParent())
+ .isEqualTo(dir.toAbsolutePath().normalize());
+ }
+}