This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch CAMEL-23637 in repository https://gitbox.apache.org/repos/asf/camel.git
commit 7ff05871b45aad5e11453a65b52f9d97af667aa0 Author: Claus Ibsen <[email protected]> AuthorDate: Thu May 28 16:54:08 2026 +0200 CAMEL-23637: camel-jbang - TUI add Run Infra Service action to F2 menu Co-Authored-By: Claude <[email protected]> --- .../dsl/jbang/core/commands/tui/ActionsPopup.java | 435 ++++++++++++++++++++- 1 file changed, 418 insertions(+), 17 deletions(-) diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopup.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopup.java index e569c49861fc..e448c3cc73f5 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopup.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopup.java @@ -17,11 +17,15 @@ package org.apache.camel.dsl.jbang.core.commands.tui; import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.Iterator; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.concurrent.ScheduledExecutorService; import java.util.function.Supplier; @@ -41,15 +45,21 @@ import dev.tamboui.widgets.Clear; import dev.tamboui.widgets.block.Block; import dev.tamboui.widgets.block.BorderType; 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; +import org.apache.camel.catalog.CamelCatalog; +import org.apache.camel.catalog.DefaultCamelCatalog; import org.apache.camel.dsl.jbang.core.common.ExampleHelper; import org.apache.camel.dsl.jbang.core.common.LauncherHelper; import org.apache.camel.dsl.jbang.core.common.PathUtils; +import org.apache.camel.util.json.JsonArray; import org.apache.camel.util.json.JsonObject; +import org.apache.camel.util.json.Jsoner; import static org.apache.camel.dsl.jbang.core.commands.tui.MonitorContext.hint; import static org.apache.camel.dsl.jbang.core.commands.tui.MonitorContext.hintLast; @@ -59,27 +69,29 @@ class ActionsPopup { // Group 1: User Actions private static final int ACTION_SEND_MESSAGE = 0; private static final int ACTION_RUN_EXAMPLE = 1; - private static final int ACTION_SHOW_DOCS = 2; + private static final int ACTION_RUN_INFRA = 2; + private static final int ACTION_SHOW_DOCS = 3; // Group 2: Diagnostics - private static final int ACTION_DOCTOR = 3; - private static final int ACTION_CLASSPATH = 4; - private static final int ACTION_RESET_STATS = 5; - private static final int ACTION_STOP_ALL = 6; + private static final int ACTION_DOCTOR = 4; + private static final int ACTION_CLASSPATH = 5; + private static final int ACTION_RESET_STATS = 6; + private static final int ACTION_STOP_ALL = 7; // Group 3: Recording & Presentation - private static final int ACTION_SCREENSHOT = 7; - private static final int ACTION_TAPE_RECORDING = 8; - private static final int ACTION_TAPE_INSTRUCTIONS = 9; - private static final int ACTION_CAPTION = 10; - private static final int ACTION_SHOW_KEYSTROKES = 11; + private static final int ACTION_SCREENSHOT = 8; + private static final int ACTION_TAPE_RECORDING = 9; + private static final int ACTION_TAPE_INSTRUCTIONS = 10; + private static final int ACTION_CAPTION = 11; + private static final int ACTION_SHOW_KEYSTROKES = 12; // Group 4: MCP - private static final int ACTION_MCP_INFO = 12; - private static final int ACTION_MCP_LOG = 13; + private static final int ACTION_MCP_INFO = 13; + private static final int ACTION_MCP_LOG = 14; - private static final int[] GROUP_SIZES = { 3, 4, 5 }; + private static final int[] GROUP_SIZES = { 4, 4, 5 }; private static final int MCP_GROUP_SIZE = 2; private final Supplier<Set<String>> runningNames; private final Supplier<List<IntegrationInfo>> integrations; + private final Supplier<List<InfraInfo>> infraServices; private final Runnable screenshotAction; private final Runnable toggleKeystrokes; private final Supplier<Boolean> keystrokesEnabled; @@ -112,6 +124,14 @@ class ActionsPopup { private String docTitle; private int docScroll; + private boolean showInfraBrowser; + private final ListState infraBrowserState = new ListState(); + private List<InfraServiceEntry> infraCatalog; + private boolean showInfraPortDialog; + private InfraServiceEntry selectedInfraService; + private int infraImplIndex; + private TextInputState infraPortState; + private final McpLogPopup mcpLogPopup = new McpLogPopup(); private final DoctorPopup doctorPopup = new DoctorPopup(); @@ -134,6 +154,7 @@ class ActionsPopup { Runnable toggleTapeRecording, Supplier<Boolean> tapeRecordingActive) { this.runningNames = runningNames; this.integrations = integrations; + this.infraServices = infraServices; this.captionOverlay = captionOverlay; this.screenshotAction = screenshotAction; this.toggleKeystrokes = toggleKeystrokes; @@ -170,11 +191,11 @@ class ActionsPopup { private int visualActionCount() { if (mcpEnabled) { - // 3 + 4 + 5 + 2 actions = 14, plus 3 dividers = 17 - return 14 + 3; + // 4 + 4 + 5 + 2 actions = 15, plus 3 dividers = 18 + return 15 + 3; } else { - // 3 + 4 + 5 actions = 12, plus 2 dividers = 14 - return 12 + 2; + // 4 + 4 + 5 actions = 13, plus 2 dividers = 15 + return 13 + 2; } } @@ -227,11 +248,17 @@ class ActionsPopup { boolean isVisible() { return showActionsMenu || showExampleBrowser || runOptionsForm.isVisible() || showDocPicker || showDocViewer + || showInfraBrowser || showInfraPortDialog || mcpLogPopup.isVisible() || doctorPopup.isVisible() || classpathPopup.isVisible() || sendMessagePopup.isVisible() || stopAllPopup.isVisible() || captionOverlay.isInlineMode(); } SelectionContext getSelectionContext() { + if (showInfraBrowser && infraCatalog != null) { + List<String> items = infraCatalog.stream().map(e -> e.alias).collect(Collectors.toList()); + Integer sel = infraBrowserState.selected(); + return new SelectionContext("list", items, sel != null ? sel : -1, infraCatalog.size(), "Infra Services"); + } if (showExampleBrowser && exampleCatalog != null) { List<String> items = new ArrayList<>(); String currentLevel = null; @@ -268,6 +295,7 @@ class ActionsPopup { // Group 1: User Actions labels.add("Send Message"); labels.add("Run an example..."); + labels.add("Run Infra Service..."); labels.add("Show Documentation"); labels.add("───"); // Group 2: Diagnostics @@ -302,6 +330,8 @@ class ActionsPopup { runOptionsForm.close(); showDocPicker = false; showDocViewer = false; + showInfraBrowser = false; + showInfraPortDialog = false; mcpLogPopup.close(); doctorPopup.close(); classpathPopup.close(); @@ -367,6 +397,44 @@ class ActionsPopup { } return true; } + if (showInfraPortDialog) { + if (ke.isCancel()) { + showInfraPortDialog = false; + showInfraBrowser = true; + } else if (ke.isConfirm()) { + launchInfraService(); + } else if (selectedInfraService != null && selectedInfraService.implementations.size() > 1) { + if (ke.isLeft()) { + infraImplIndex = (infraImplIndex - 1 + selectedInfraService.implementations.size()) + % selectedInfraService.implementations.size(); + return true; + } else if (ke.isRight()) { + infraImplIndex = (infraImplIndex + 1) % selectedInfraService.implementations.size(); + return true; + } + } + handlePortInput(ke); + return true; + } + if (showInfraBrowser) { + if (ke.isCancel()) { + showInfraBrowser = false; + showActionsMenu = true; + } else if (ke.isUp()) { + navigateInfraBrowser(-1); + } else if (ke.isDown()) { + navigateInfraBrowser(1); + } else if (ke.isPageUp() || ke.isKey(KeyCode.PAGE_UP)) { + navigateInfraBrowser(-10); + } else if (ke.isPageDown() || ke.isKey(KeyCode.PAGE_DOWN)) { + navigateInfraBrowser(10); + } else if (ke.isConfirm()) { + selectInfraService(); + } else if (ke.code() == KeyCode.CHAR) { + jumpToInfraService(ke.character()); + } + return true; + } if (runOptionsForm.isVisible()) { if (ke.isCancel()) { runOptionsForm.close(); @@ -445,6 +513,8 @@ class ActionsPopup { } else if (action == ACTION_CLASSPATH) { showActionsMenu = false; openClasspath(); + } else if (action == ACTION_RUN_INFRA) { + openInfraBrowser(); } else if (action == ACTION_MCP_INFO) { showActionsMenu = false; openMcpInfo(); @@ -478,6 +548,12 @@ class ActionsPopup { if (showActionsMenu) { renderActionsMenu(frame, area); } + if (showInfraBrowser) { + renderInfraBrowser(frame, area); + } + if (showInfraPortDialog) { + renderInfraPortDialog(frame, area); + } if (showExampleBrowser) { renderExampleBrowser(frame, area); } @@ -546,6 +622,17 @@ class ActionsPopup { runOptionsForm.renderFooter(spans); return; } + if (showInfraPortDialog) { + hint(spans, "Enter", "run"); + hintLast(spans, "Esc", "back"); + return; + } + if (showInfraBrowser) { + hint(spans, "↑↓", "navigate"); + hint(spans, "Enter", "select"); + hintLast(spans, "Esc", "back"); + return; + } if (showExampleBrowser) { hint(spans, "↑↓", "navigate"); hint(spans, "Enter", "run"); @@ -595,6 +682,7 @@ class ActionsPopup { // Group 1: User Actions items.add(ListItem.from(" 📩 Send Message")); items.add(ListItem.from(" 🐪 Run an example...")); + items.add(ListItem.from(" 🔧 Run Infra Service...")); items.add(ListItem.from(" 📖 Show Documentation")); items.add(ListItem.from(divider).style(Style.EMPTY.dim())); // Group 2: Diagnostics @@ -1225,6 +1313,316 @@ class ActionsPopup { return null; } + // ---- Infra Browser ---- + + private void openInfraBrowser() { + showActionsMenu = false; + if (infraCatalog == null) { + infraCatalog = loadInfraCatalog(); + } + if (infraCatalog.isEmpty()) { + setNotification("No infra services found", true); + return; + } + showInfraBrowser = true; + infraBrowserState.select(0); + // skip to first non-running service + if (!infraCatalog.isEmpty() && infraCatalog.get(0).running) { + navigateInfraBrowser(1); + } + } + + private List<InfraServiceEntry> loadInfraCatalog() { + try { + CamelCatalog catalog = new DefaultCamelCatalog(); + try (InputStream is = catalog.loadResource("test-infra", "metadata.json")) { + if (is == null) { + return List.of(); + } + String json = new String(is.readAllBytes(), StandardCharsets.UTF_8); + JsonArray arr = (JsonArray) Jsoner.deserialize(json); + Map<String, InfraServiceEntry> byAlias = new LinkedHashMap<>(); + for (Object obj : arr) { + JsonObject svc = (JsonObject) obj; + String desc = (String) svc.getOrDefault("description", ""); + JsonArray aliases = (JsonArray) svc.get("alias"); + JsonArray impls = (JsonArray) svc.get("aliasImplementation"); + if (aliases != null) { + for (Object a : aliases) { + String alias = a.toString(); + InfraServiceEntry entry = byAlias.get(alias); + if (entry == null) { + entry = new InfraServiceEntry(alias, desc, new ArrayList<>(), false); + byAlias.put(alias, entry); + } + if (impls != null) { + for (Object impl : impls) { + String implStr = impl.toString(); + if (!entry.implementations.contains(implStr)) { + entry.implementations.add(implStr); + } + } + } + } + } + } + // Mark running services + Set<String> runningAliases = infraServices.get().stream() + .filter(i -> i.alive) + .map(i -> i.alias) + .collect(Collectors.toSet()); + List<InfraServiceEntry> result = new ArrayList<>(); + for (InfraServiceEntry entry : byAlias.values()) { + boolean running = runningAliases.contains(entry.alias); + result.add(new InfraServiceEntry(entry.alias, entry.description, entry.implementations, running)); + } + result.sort((a, b) -> a.alias.compareToIgnoreCase(b.alias)); + return result; + } + } catch (Exception e) { + return List.of(); + } + } + + private void refreshInfraRunningState() { + if (infraCatalog == null) { + return; + } + Set<String> runningAliases = infraServices.get().stream() + .filter(i -> i.alive) + .map(i -> i.alias) + .collect(Collectors.toSet()); + List<InfraServiceEntry> refreshed = new ArrayList<>(); + for (InfraServiceEntry entry : infraCatalog) { + boolean running = runningAliases.contains(entry.alias); + refreshed.add(new InfraServiceEntry(entry.alias, entry.description, entry.implementations, running)); + } + infraCatalog = refreshed; + } + + private void navigateInfraBrowser(int direction) { + if (infraCatalog == null || infraCatalog.isEmpty()) { + return; + } + int total = infraCatalog.size(); + Integer current = infraBrowserState.selected(); + int next = (current != null ? current : 0) + direction; + next = Math.max(0, Math.min(next, total - 1)); + // skip running services + while (next >= 0 && next < total && infraCatalog.get(next).running) { + next += direction; + } + next = Math.max(0, Math.min(next, total - 1)); + if (!infraCatalog.get(next).running) { + infraBrowserState.select(next); + } + } + + private void selectInfraService() { + Integer sel = infraBrowserState.selected(); + if (sel == null || sel >= infraCatalog.size()) { + return; + } + InfraServiceEntry entry = infraCatalog.get(sel); + if (entry.running) { + return; + } + selectedInfraService = entry; + infraImplIndex = 0; + infraPortState = new TextInputState(""); + showInfraBrowser = false; + showInfraPortDialog = true; + } + + private void jumpToInfraService(char ch) { + if (infraCatalog == null) { + return; + } + char lower = Character.toLowerCase(ch); + for (int i = 0; i < infraCatalog.size(); i++) { + if (infraCatalog.get(i).alias.toLowerCase().startsWith(String.valueOf(lower))) { + infraBrowserState.select(i); + infraBrowserState.setOffset(Math.max(0, i - 2)); + return; + } + } + } + + private void renderInfraBrowser(Frame frame, Rect area) { + if (infraCatalog == null || infraCatalog.isEmpty()) { + return; + } + refreshInfraRunningState(); + int popupW = Math.min(100, area.width() - 4); + int popupH = Math.min(infraCatalog.size() + 2, Math.min(22, area.height() - 6)); + int x = area.left() + Math.max(0, (area.width() - popupW) / 2); + int y = area.top() + Math.max(0, (area.height() - popupH) / 2); + Rect popup = new Rect(x, y, Math.min(popupW, area.width()), Math.min(popupH, area.height())); + + frame.renderWidget(Clear.INSTANCE, popup); + + int nameCol = 22; + List<ListItem> items = new ArrayList<>(); + for (InfraServiceEntry entry : infraCatalog) { + String padded = String.format("%-" + nameCol + "s", TuiHelper.truncate(entry.alias, nameCol)); + String prefix = " 🔧 " + padded + " "; + if (entry.running) { + items.add(ListItem.from(prefix + "(running)").style(Style.EMPTY.dim())); + } else { + String implStr = entry.implementations.isEmpty() ? "" : String.join(", ", entry.implementations); + String desc = entry.description; + if (!implStr.isEmpty()) { + desc = desc + " [" + implStr + "]"; + } + int descW = Math.max(10, popupW - prefix.length() - 2); + if (desc.length() <= descW) { + items.add(ListItem.from(prefix + desc)); + } else { + String indent = " ".repeat(prefix.length()); + List<Line> lines = new ArrayList<>(); + List<String> wrapped = wrapWords(desc, descW); + lines.add(Line.from(prefix + wrapped.get(0))); + for (int w = 1; w < wrapped.size(); w++) { + lines.add(Line.from(indent + wrapped.get(w))); + } + items.add(ListItem.from(Text.from(lines.toArray(Line[]::new)))); + } + } + } + + long available = infraCatalog.stream().filter(e -> !e.running).count(); + ListWidget list = ListWidget.builder() + .items(items.toArray(ListItem[]::new)) + .highlightStyle(Style.EMPTY.fg(Color.WHITE).bold().onBlue()) + .highlightSymbol("") + .scrollMode(ScrollMode.AUTO_SCROLL) + .block(Block.builder() + .borderType(BorderType.ROUNDED) + .title(" Run Infra Service (" + available + "/" + infraCatalog.size() + ") ") + .titleBottom(Title.from(Line.from( + Span.styled(" Enter", MonitorContext.HINT_KEY_STYLE), Span.raw(" select │"), + Span.styled(" ↑↓", MonitorContext.HINT_KEY_STYLE), Span.raw(" navigate │"), + Span.styled(" Esc", MonitorContext.HINT_KEY_STYLE), Span.raw(" back ")))) + .build()) + .build(); + frame.renderStatefulWidget(list, popup, infraBrowserState); + } + + // ---- Infra Port Dialog ---- + + private void renderInfraPortDialog(Frame frame, Rect area) { + if (selectedInfraService == null) { + return; + } + boolean hasMultiImpl = selectedInfraService.implementations.size() > 1; + int popupW = 42; + int popupH = hasMultiImpl ? 8 : 6; + int x = area.left() + Math.max(0, (area.width() - popupW) / 2); + int y = area.top() + Math.max(0, (area.height() - popupH) / 2); + Rect popup = new Rect(x, y, Math.min(popupW, area.width()), Math.min(popupH, area.height())); + + frame.renderWidget(Clear.INSTANCE, popup); + + Block block = Block.builder() + .borderType(BorderType.ROUNDED) + .title(" Run " + selectedInfraService.alias + " ") + .titleBottom(Title.from(Line.from( + Span.styled(" Enter", MonitorContext.HINT_KEY_STYLE), Span.raw(" run │"), + Span.styled(" Esc", MonitorContext.HINT_KEY_STYLE), Span.raw(" back ")))) + .build(); + frame.renderWidget(block, popup); + Rect inner = block.inner(popup); + + int labelW = 8; + int fieldW = inner.width() - labelW; + int row = inner.top(); + int ix = inner.left(); + + // Implementation selector (if multiple) + if (hasMultiImpl) { + row++; + Rect labelArea = new Rect(ix, row, labelW, 1); + frame.renderWidget(Paragraph.from(Line.from(Span.styled("Impl:", Style.EMPTY.bold()))), labelArea); + String impl = selectedInfraService.implementations.get(infraImplIndex); + Rect implArea = new Rect(ix + labelW, row, fieldW, 1); + frame.renderWidget(Paragraph.from(Line.from( + Span.styled("◀ ", MonitorContext.HINT_KEY_STYLE), + Span.raw(impl), + Span.styled(" ▶", MonitorContext.HINT_KEY_STYLE))), implArea); + row++; + } + + // Port input + row++; + Rect labelArea = new Rect(ix, row, labelW, 1); + frame.renderWidget(Paragraph.from(Line.from(Span.styled("Port:", Style.EMPTY.bold()))), labelArea); + Rect portArea = new Rect(ix + labelW, row, fieldW, 1); + TextInput textInput = TextInput.builder() + .cursorStyle(Style.EMPTY.reversed()) + .placeholder("default") + .build(); + frame.renderStatefulWidget(textInput, portArea, infraPortState); + } + + private void handlePortInput(KeyEvent ke) { + if (infraPortState == null) { + return; + } + if (ke.isDeleteBackward()) { + infraPortState.deleteBackward(); + } else if (ke.isDeleteForward()) { + infraPortState.deleteForward(); + } else if (ke.isLeft()) { + infraPortState.moveCursorLeft(); + } else if (ke.isRight()) { + infraPortState.moveCursorRight(); + } else if (ke.isHome()) { + infraPortState.moveCursorToStart(); + } else if (ke.isEnd()) { + infraPortState.moveCursorToEnd(); + } else if (ke.code() == KeyCode.CHAR && Character.isDigit(ke.character())) { + infraPortState.insert(ke.character()); + } + } + + private void launchInfraService() { + if (selectedInfraService == null) { + return; + } + String alias = selectedInfraService.alias; + String impl = null; + if (!selectedInfraService.implementations.isEmpty()) { + impl = selectedInfraService.implementations.get(infraImplIndex); + } + String portStr = infraPortState != null ? infraPortState.text().trim() : ""; + showInfraPortDialog = false; + try { + List<String> cmd = new ArrayList<>(LauncherHelper.getCamelCommand()); + cmd.add("infra"); + cmd.add("run"); + cmd.add(alias); + if (impl != null) { + cmd.add(impl); + } + cmd.add("--background"); + if (!portStr.isEmpty()) { + cmd.add("--port=" + portStr); + } + Path outputFile = Files.createTempFile("camel-infra-", ".log"); + outputFile.toFile().deleteOnExit(); + ProcessBuilder pb = new ProcessBuilder(cmd); + pb.redirectErrorStream(true); + pb.redirectOutput(outputFile.toFile()); + Process process = pb.start(); + pendingLaunches.add(new PendingLaunch(alias, process, outputFile, System.currentTimeMillis())); + setNotification("Starting infra: " + alias, false); + // force reload next time browser opens + infraCatalog = null; + } catch (Exception e) { + setNotification("Failed to start infra: " + alias + " - " + e.getMessage(), true); + } + } + // ---- Process Launch & Monitoring ---- private void launchSelectedExample() { @@ -1332,6 +1730,9 @@ class ActionsPopup { return lines; } + record InfraServiceEntry(String alias, String description, List<String> implementations, boolean running) { + } + private record PendingLaunch(String name, Process process, Path outputFile, long startTime) { } }
