This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch feature/CAMEL-24259-tui-tabs-and-inline-docs in repository https://gitbox.apache.org/repos/asf/camel.git
commit a27c7bea02a3a07cc87396e085623473672f5f4d Author: Claus Ibsen <[email protected]> AuthorDate: Sun Jul 26 15:58:16 2026 +0200 CAMEL-24259: camel-jbang - TUI add Source tab with file explorer and inline docs Replace HTTP as a primary tab with a new Source tab (key 2) featuring a left-right split layout with file explorer and source viewer. HTTP moves to the More submenu with cycling duplicate mnemonic support. - File explorer with directory navigation, info panel showing size/modified/type - Source viewer with syntax highlighting and Space to cycle format (YAML/Java/XML) - Tab key toggles focus between panels with dimmed selection on unfocused panel - Inline Camel documentation via i toggle for Camel source files - F1 help text and enriched JSON data for AI agents - Remove Tab/Shift+Tab cycling between primary tabs (use number keys instead) Co-Authored-By: Claude Opus 4.6 <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../dsl/jbang/core/commands/tui/CamelMonitor.java | 66 +- .../dsl/jbang/core/commands/tui/McpFacade.java | 4 +- .../dsl/jbang/core/commands/tui/PopupManager.java | 33 +- .../dsl/jbang/core/commands/tui/SourceTab.java | 679 +++++++++++++++++++++ .../dsl/jbang/core/commands/tui/SourceViewer.java | 37 +- .../dsl/jbang/core/commands/tui/TabRegistry.java | 35 +- .../dsl/jbang/core/commands/tui/TuiHelper.java | 4 +- .../dsl/jbang/core/commands/tui/TuiIcons.java | 5 +- .../jbang/core/commands/tui/TabBarRenderTest.java | 18 +- .../jbang/core/commands/tui/TabRegistryTest.java | 40 +- .../dsl/jbang/core/commands/tui/TuiIconsTest.java | 2 +- 11 files changed, 816 insertions(+), 107 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 403d094ff55b..14f4418c8838 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 @@ -691,8 +691,11 @@ public class CamelMonitor extends CamelCommand { } return true; } - boolean probeEditing = tabRegistry.selectedTabIndex() == TAB_HTTP + boolean probeEditing = tabRegistry.selectedTabIndex() == TAB_MORE + && tabRegistry.getActiveMoreTab() == tabRegistry.httpTab() && tabRegistry.httpTab().isProbeMode(); + boolean sourceSearchActive = tabRegistry.selectedTabIndex() == TAB_SOURCE + && tabRegistry.sourceTab().isSourceViewerSearchActive(); boolean logSearchActive = tabRegistry.selectedTabIndex() == TAB_LOG && tabRegistry.logTab().isSearchInputActive(); boolean spanFilterActive = tabRegistry.selectedTabIndex() == TAB_MORE @@ -713,8 +716,9 @@ public class CamelMonitor extends CamelCommand { boolean catalogFilterActive = tabRegistry.selectedTabIndex() == TAB_MORE && tabRegistry.getActiveMoreTab() == tabRegistry.catalogTab() && tabRegistry.catalogTab().isFilterInputActive(); - boolean textEditing = probeEditing || logSearchActive || spanFilterActive || beanFilterActive - || classpathFilterActive || mavenDepsFilterActive || sqlInputActive || catalogFilterActive; + boolean textEditing = probeEditing || sourceSearchActive || logSearchActive || spanFilterActive + || beanFilterActive || classpathFilterActive || mavenDepsFilterActive || sqlInputActive + || catalogFilterActive; if (!textEditing && (ke.isCharIgnoreCase('q') || ke.isCtrlC())) { runner.quit(); return true; @@ -728,23 +732,23 @@ public class CamelMonitor extends CamelCommand { return tabRegistry.handleTabKey(TAB_OVERVIEW, ctx, dataService); } if (ke.isChar('2')) { + return tabRegistry.handleTabKey(TAB_SOURCE, ctx, dataService); + } + if (ke.isChar('3')) { return tabRegistry.handleTabKey(TAB_LOG, ctx, dataService); } if (!isInfraSelected()) { - if (ke.isChar('3')) { - return tabRegistry.handleTabKey(TAB_ACTIVITY, ctx, dataService); - } if (ke.isChar('4')) { - return tabRegistry.handleTabKey(TAB_DIAGRAM, ctx, dataService); + return tabRegistry.handleTabKey(TAB_ACTIVITY, ctx, dataService); } if (ke.isChar('5')) { - return tabRegistry.handleTabKey(TAB_ROUTES, ctx, dataService); + return tabRegistry.handleTabKey(TAB_DIAGRAM, ctx, dataService); } if (ke.isChar('6')) { - return tabRegistry.handleTabKey(TAB_ENDPOINTS, ctx, dataService); + return tabRegistry.handleTabKey(TAB_ROUTES, ctx, dataService); } if (ke.isChar('7')) { - return tabRegistry.handleTabKey(TAB_HTTP, ctx, dataService); + return tabRegistry.handleTabKey(TAB_ENDPOINTS, ctx, dataService); } if (ke.isChar('8')) { return tabRegistry.handleTabKey(TAB_HISTORY, ctx, dataService); @@ -759,26 +763,6 @@ public class CamelMonitor extends CamelCommand { } MonitorTab activeMonitorTab = tabRegistry.activeTab(); boolean overlayActive = activeMonitorTab != null && activeMonitorTab.isOverlayActive(); - if (ke.isFocusPrevious() && !textEditing && !overlayActive) { - if (isInfraSelected()) { - int prev = tabRegistry.selectedTabIndex() == TAB_OVERVIEW ? TAB_LOG : TAB_OVERVIEW; - tabsState.select(prev); - } else { - int prev = (tabRegistry.selectedTabIndex() - 1 + NUM_TABS) % NUM_TABS; - tabRegistry.handleTabKey(prev, ctx, dataService); - } - return true; - } - if (ke.isFocusNext() && !textEditing && !overlayActive) { - if (isInfraSelected()) { - int next = tabRegistry.selectedTabIndex() == TAB_OVERVIEW ? TAB_LOG : TAB_OVERVIEW; - tabsState.select(next); - } else { - int next = (tabRegistry.selectedTabIndex() + 1) % NUM_TABS; - tabRegistry.handleTabKey(next, ctx, dataService); - } - return true; - } if (ke.isKey(KeyCode.F5) && ke.hasShift()) { recordingManager.takeScreenshot(); return true; @@ -1164,6 +1148,10 @@ public class CamelMonitor extends CamelCommand { filesBrowser.handlePaste(pe.text()); return true; } + if (tabRegistry.sourceTab().isSourceViewerSearchActive()) { + tabRegistry.sourceTab().handlePaste(pe.text()); + return true; + } if (tabRegistry.getActiveMoreTab() == tabRegistry.sqlQueryTab() && tabRegistry.sqlQueryTab().isInputActive()) { tabRegistry.sqlQueryTab().handlePaste(pe.text()); @@ -1474,7 +1462,7 @@ public class CamelMonitor extends CamelCommand { // Infra mode: only Overview and Log tabs Line[] labels = { Line.from(TuiIcons.primaryTabHeader(TuiIcons.TAB_OVERVIEW, "1", "Overview")), - Line.from(TuiIcons.primaryTabHeader(TuiIcons.TAB_LOG, "2", "Log")) + Line.from(TuiIcons.primaryTabHeader(TuiIcons.TAB_LOG, "3", "Log")) }; // Map real tab index to infra tab index for highlight @@ -1498,12 +1486,12 @@ public class CamelMonitor extends CamelCommand { Line[] labels = { Line.from(TuiIcons.primaryTabHeader(TuiIcons.TAB_OVERVIEW, "1", "Overview")), - Line.from(TuiIcons.primaryTabHeader(TuiIcons.TAB_LOG, "2", "Log")), - Line.from(TuiIcons.primaryTabHeader(TuiIcons.TAB_ACTIVITY, "3", "Activity")), - Line.from(TuiIcons.primaryTabHeader(TuiIcons.TAB_DIAGRAM, "4", "Diagram")), - Line.from(TuiIcons.primaryTabHeader(TuiIcons.TAB_ROUTES, "5", "Route")), - Line.from(TuiIcons.primaryTabHeader(TuiIcons.TAB_ENDPOINTS, "6", "Endpoint")), - Line.from(TuiIcons.primaryTabHeader(TuiIcons.TAB_HTTP, "7", "HTTP")), + Line.from(TuiIcons.primaryTabHeader(TuiIcons.TAB_SOURCE, "2", "Source")), + Line.from(TuiIcons.primaryTabHeader(TuiIcons.TAB_LOG, "3", "Log")), + Line.from(TuiIcons.primaryTabHeader(TuiIcons.TAB_ACTIVITY, "4", "Activity")), + Line.from(TuiIcons.primaryTabHeader(TuiIcons.TAB_DIAGRAM, "5", "Diagram")), + Line.from(TuiIcons.primaryTabHeader(TuiIcons.TAB_ROUTES, "6", "Route")), + Line.from(TuiIcons.primaryTabHeader(TuiIcons.TAB_ENDPOINTS, "7", "Endpoint")), Line.from(TuiIcons.primaryTabHeader(TuiIcons.TAB_INSPECT, "8", "Inspect")), Line.from(TuiIcons.primaryTabHeader(TuiIcons.TAB_ERRORS, "9", "Errors")), Line.from(TuiIcons.primaryTabHeader(TuiIcons.TAB_MORE, "0", TuiIcons.moreTabLabel())), @@ -1615,10 +1603,6 @@ public class CamelMonitor extends CamelCommand { if (endpointCount > 0) { badgeTexts[TAB_ENDPOINTS] = "(" + endpointCount + ")"; } - int httpCount = hasSelection ? sel.httpEndpoints.size() : 0; - if (httpCount > 0) { - badgeTexts[TAB_HTTP] = "(" + httpCount + ")"; - } boolean hasTraces = hasSelection && !dataService.traces().get().isEmpty(); if (hasTraces) { badgeTexts[TAB_HISTORY] = "(*)"; diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java index 1aef9c262ee7..3f7151ea6c89 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java @@ -83,8 +83,8 @@ class McpFacade { // Tab name constants static final String[] TAB_NAMES = { - "Overview", "Log", "Activity", "Diagram", "Routes", - "Endpoints", "HTTP", "Inspect", "Errors", "More" + "Overview", "Source", "Log", "Activity", "Diagram", + "Routes", "Endpoints", "Inspect", "Errors", "More" }; private final MonitorContext ctx; 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 a880e3cbf78c..3992063df94f 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 @@ -77,6 +77,8 @@ class PopupManager { private final ListState morePopupState = new ListState(); private final ScrollbarState moreScrollbarState = new ScrollbarState(); private int lastMoreSelection; + private char lastShortcutLetter; + private int lastShortcutIndex = -1; private Line[] currentTabLabels; // Kill confirm @@ -654,18 +656,43 @@ class PopupManager { int morePopupShortcut(KeyEvent ke) { List<TabRegistry.MoreTab> tabs = moreTabsSupplier.get(); + List<Integer> matches = new ArrayList<>(); + char pressedUpper = 0; + for (int i = 0; i < tabs.size(); i++) { int idx = tabs.get(i).mnemonicIndex(); if (idx < 0) { continue; } char letter = tabs.get(i).displayName().charAt(idx); - // trigger on either case so Shift+letter works too if (ke.isChar(Character.toLowerCase(letter)) || ke.isChar(Character.toUpperCase(letter))) { - return i; + if (pressedUpper == 0) { + pressedUpper = Character.toUpperCase(letter); + } + matches.add(i); } } - return -1; + + if (matches.isEmpty()) { + return -1; + } + if (matches.size() == 1) { + lastShortcutLetter = pressedUpper; + lastShortcutIndex = matches.get(0); + return matches.get(0); + } + + // cycle through duplicate mnemonics + if (pressedUpper == lastShortcutLetter && lastShortcutIndex >= 0) { + int pos = matches.indexOf(lastShortcutIndex); + int next = (pos + 1) % matches.size(); + lastShortcutIndex = matches.get(next); + return matches.get(next); + } + + lastShortcutLetter = pressedUpper; + lastShortcutIndex = matches.get(0); + return matches.get(0); } List<IntegrationInfo> getNonVanishingIntegrations() { 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 new file mode 100644 index 000000000000..e71a8d621e58 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceTab.java @@ -0,0 +1,679 @@ +/* + * 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; +import java.nio.file.attribute.BasicFileAttributes; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import dev.tamboui.layout.Constraint; +import dev.tamboui.layout.Layout; +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.KeyEvent; +import dev.tamboui.tui.event.MouseEvent; +import dev.tamboui.tui.event.MouseEventKind; +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.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 dev.tamboui.widgets.scrollbar.Scrollbar; +import dev.tamboui.widgets.scrollbar.ScrollbarState; +import org.apache.camel.catalog.CamelCatalog; +import org.apache.camel.dsl.jbang.core.common.CatalogLoader; +import org.apache.camel.util.json.JsonArray; +import org.apache.camel.util.json.JsonObject; + +/** + * Source tab showing a file browser in the top panel and a source code viewer in the bottom panel. Replaces the HTTP + * tab as primary tab 7. + */ +class SourceTab extends AbstractTab { + + private Path rootDir; + private Path currentDir; + private final ListState listState = new ListState(); + private final ScrollbarState listScrollState = new ScrollbarState(); + private List<FilesBrowser.FileEntry> entries = Collections.emptyList(); + private final SourceViewer sourceViewer = new SourceViewer(); + private boolean focusOnViewer; + + private int leftPanelWidth = -1; + private final DragSplit hSplit = new DragSplit(); + private Rect leftArea; + private Rect rightArea; + + private final Map<String, CamelCatalog> catalogCache = new HashMap<>(); + + private static final Pattern YAML_URI_PATTERN = Pattern.compile( + "^\\s*-?\\s*(?:uri|from|to|toD|wireTap|enrich|pollEnrich|deadLetterChannel):\\s*\"?([a-zA-Z][a-zA-Z0-9+.-]*:[^\"\\s]*)"); + private static final Pattern YAML_EIP_PATTERN = Pattern.compile( + "^\\s*-?\\s*(aggregate|bean|choice|circuitBreaker|claim[Cc]heck|convertBodyTo|convertHeaderTo|" + + "delay|dynamicRouter|enrich|filter|idempotentConsumer|" + + "intercept|kamelet|loadBalance|log|loop|marshal|multicast|" + + "onException|otherwise|pipeline|pollEnrich|process|" + + "recipientList|removeHeader|removeHeaders|removeProperty|" + + "resequence|rollback|routingSlip|saga|sample|" + + "script|serviceCall|setBody|setHeader|setProperty|" + + "sort|split|step|stop|threads|throttle|" + + "to|toD|transform|unmarshal|validate|when|wireTap)\\s*:"); + + SourceTab(MonitorContext ctx) { + super(ctx); + } + + boolean isSourceViewerSearchActive() { + return sourceViewer.isSearchInputActive(); + } + + void handlePaste(String text) { + sourceViewer.handlePaste(text); + } + + // ---- MonitorTab ---- + + @Override + public void onTabSelected() { + refreshFiles(); + } + + @Override + public void onIntegrationChanged() { + rootDir = null; + currentDir = null; + entries = Collections.emptyList(); + sourceViewer.reset(); + focusOnViewer = false; + leftPanelWidth = -1; + refreshFiles(); + } + + @Override + public boolean handleKeyEvent(KeyEvent ke) { + if (ke.isKey(dev.tamboui.tui.event.KeyCode.TAB)) { + if (sourceViewer.isVisible()) { + focusOnViewer = !focusOnViewer; + } + return true; + } + + if (focusOnViewer && sourceViewer.isVisible()) { + boolean wasVisible = sourceViewer.isVisible(); + if (sourceViewer.handleKeyEvent(ke)) { + if (wasVisible && !sourceViewer.isVisible()) { + focusOnViewer = false; + } + return true; + } + if (ke.isCancel()) { + focusOnViewer = false; + return true; + } + } + + if (!focusOnViewer) { + return handleFileListKey(ke); + } + + return false; + } + + @Override + public boolean handleMouseEvent(MouseEvent me, Rect area) { + if (hSplit.handleMouse(me, me.x())) { + if (hSplit.isDragging() && me.kind() == MouseEventKind.DRAG) { + leftPanelWidth = Math.max(15, Math.min(me.x() - area.x(), area.width() - 20)); + } + return true; + } + + if (leftArea != null && TuiHelper.contains(leftArea, me.x(), me.y())) { + focusOnViewer = false; + if (me.kind() == MouseEventKind.SCROLL_UP) { + listState.selectPrevious(); + return true; + } + if (me.kind() == MouseEventKind.SCROLL_DOWN) { + listState.selectNext(entries.size()); + return true; + } + if (me.isClick()) { + int innerTop = leftArea.top() + 1; + int clicked = listState.offset() + (me.y() - innerTop); + if (clicked >= 0 && clicked < entries.size()) { + listState.select(clicked); + } + return true; + } + return true; + } + + if (rightArea != null && TuiHelper.contains(rightArea, me.x(), me.y())) { + if (sourceViewer.isVisible()) { + focusOnViewer = true; + return sourceViewer.handleMouseEvent(me); + } + return true; + } + + return false; + } + + @Override + public boolean handleEscape() { + if (sourceViewer.isSearchInputActive()) { + return false; + } + if (focusOnViewer) { + focusOnViewer = false; + return true; + } + return false; + } + + @Override + public void navigateUp() { + listState.selectPrevious(); + } + + @Override + public void navigateDown() { + listState.selectNext(entries.size()); + } + + @Override + public void render(Frame frame, Rect area) { + if (ctx.selectedPid == null) { + renderNoSelection(frame, area); + return; + } + + if (leftPanelWidth < 0) { + leftPanelWidth = Math.max(25, Math.min(35, area.width() * 25 / 100)); + } + leftPanelWidth = Math.max(15, Math.min(leftPanelWidth, area.width() - 20)); + + List<Rect> chunks = Layout.horizontal() + .constraints(Constraint.length(leftPanelWidth), Constraint.fill()) + .split(area); + + leftArea = chunks.get(0); + rightArea = chunks.get(1); + + int infoHeight = 6; + List<Rect> leftChunks = Layout.vertical() + .constraints(Constraint.fill(), Constraint.length(infoHeight)) + .split(leftArea); + + renderFileList(frame, leftChunks.get(0)); + renderInfoPanel(frame, leftChunks.get(1)); + hSplit.setBorderPos(rightArea.x()); + renderSourcePanel(frame, rightArea); + } + + @Override + public void renderFooter(List<Span> spans) { + if (focusOnViewer && sourceViewer.isVisible()) { + sourceViewer.renderFooter(spans); + TuiHelper.hint(spans, "Tab", "files"); + } else { + TuiHelper.hint(spans, TuiIcons.HINT_SCROLL, "navigate"); + TuiHelper.hint(spans, "Enter", "open"); + if (currentDir != null && rootDir != null && !currentDir.equals(rootDir)) { + TuiHelper.hint(spans, "Bksp", "parent"); + } + if (sourceViewer.isVisible()) { + TuiHelper.hint(spans, "Tab", "viewer"); + } + } + } + + @Override + public String description() { + return "Browse and view source files of the integration"; + } + + @Override + public String getHelpText() { + return """ + # Source + + Browse the source files of the selected integration and view their content + with syntax highlighting. The tab has a left-right split layout with a file + explorer on the left and a source viewer on the right. + + ## File List (left panel) + - **Up/Down** — navigate files + - **Enter** — open file or directory + - **Backspace** — go to parent directory + + ## Source Viewer (right panel) + - **Up/Down** — scroll through source code + - **Space** — cycle format (YAML/Java/XML) for Camel routes + - **i** — toggle inline Camel documentation for Camel source files + - **/** — search in source + - **h** — highlight text + - **n/N** — next/previous match + - **w** — toggle word wrap + - **Esc/c** — close source viewer + + ## General + - **Tab** — toggle focus between file list and source viewer + - The focused panel title is highlighted; the unfocused panel dims + - Drag the split border with the mouse to resize panels + """; + } + + @Override + public boolean isOverlayActive() { + return focusOnViewer && sourceViewer.isSearchInputActive(); + } + + @Override + public JsonObject getTableDataAsJson() { + JsonObject json = new JsonObject(); + json.put("currentDir", currentDir != null ? currentDir.toString() : null); + + var filesArray = new JsonArray(); + for (FilesBrowser.FileEntry entry : entries) { + JsonObject file = new JsonObject(); + file.put("name", entry.name()); + file.put("directory", entry.directory()); + if (!entry.directory()) { + file.put("size", entry.size()); + file.put("sizeFormatted", FilesBrowser.formatFileSize(entry.size())); + } + file.put("path", entry.path()); + filesArray.add(file); + } + json.put("files", filesArray); + + Integer sel = listState.selected(); + if (sel != null && sel < entries.size()) { + FilesBrowser.FileEntry selected = entries.get(sel); + json.put("selectedFile", selected.name()); + json.put("selectedIndex", sel); + Path path = Path.of(selected.path()); + json.put("isCamelSource", isCamelSourceFile(path)); + try { + BasicFileAttributes attrs = Files.readAttributes(path, BasicFileAttributes.class); + if (!selected.directory()) { + json.put("fileSize", attrs.size()); + json.put("fileSizeFormatted", FilesBrowser.formatFileSize(attrs.size())); + int dot = selected.name().lastIndexOf('.'); + if (dot > 0) { + json.put("fileType", selected.name().substring(dot + 1)); + } + } + json.put("lastModified", attrs.lastModifiedTime().toInstant() + .atZone(ZoneId.systemDefault()).format(DATE_FMT)); + } catch (IOException e) { + // ignore + } + } + json.put("totalFiles", entries.size()); + json.put("focusOnViewer", focusOnViewer); + json.put("viewerOpen", sourceViewer.isVisible()); + return json; + } + + // ---- Internals ---- + + private void refreshFiles() { + IntegrationInfo info = ctx.findSelectedIntegration(); + if (info == null) { + return; + } + Path dir = FilesBrowser.resolveSourceDirectory(info); + if (dir == null || !Files.isDirectory(dir)) { + return; + } + if (rootDir == null) { + rootDir = dir; + currentDir = dir; + } + if (currentDir != null) { + loadDirectory(currentDir); + } + } + + private boolean loadDirectory(Path dir) { + List<FilesBrowser.FileEntry> dirs = new ArrayList<>(); + List<FilesBrowser.FileEntry> files = new ArrayList<>(); + try (var stream = Files.list(dir)) { + stream.limit(200) + .forEach(p -> { + String name = p.getFileName().toString(); + if (Files.isDirectory(p) && !name.startsWith(".")) { + dirs.add(new FilesBrowser.FileEntry(TuiIcons.FOLDER, name, -1, p.toString(), true)); + } else if (Files.isRegularFile(p)) { + String emoji = TuiHelper.fileEmoji(p); + long size = 0; + try { + size = Files.size(p); + } catch (IOException e) { + // ignore + } + files.add(new FilesBrowser.FileEntry(emoji, name, size, p.toString(), false)); + } + }); + } catch (IOException e) { + return false; + } + dirs.sort(Comparator.comparing(FilesBrowser.FileEntry::name, String.CASE_INSENSITIVE_ORDER)); + files.sort(Comparator.comparing(FilesBrowser.FileEntry::name, String.CASE_INSENSITIVE_ORDER)); + + List<FilesBrowser.FileEntry> found = new ArrayList<>(); + if (!dir.equals(rootDir)) { + found.add(new FilesBrowser.FileEntry(TuiIcons.FOLDER, "..", -1, dir.getParent().toString(), true)); + } + found.addAll(dirs); + found.addAll(files); + + if (found.isEmpty()) { + return false; + } + entries = found; + listState.select(0); + currentDir = dir; + return true; + } + + private boolean handleFileListKey(KeyEvent ke) { + if (ke.isUp()) { + listState.selectPrevious(); + return true; + } + if (ke.isDown()) { + listState.selectNext(entries.size()); + return true; + } + if (ke.isPageUp()) { + for (int i = 0; i < 20; i++) { + listState.selectPrevious(); + } + return true; + } + if (ke.isPageDown()) { + for (int i = 0; i < 20; i++) { + listState.selectNext(entries.size()); + } + return true; + } + if (ke.isHome()) { + listState.select(0); + return true; + } + if (ke.isEnd()) { + listState.select(entries.size() - 1); + return true; + } + if (ke.isDeleteBackward()) { + if (currentDir != null && !currentDir.equals(rootDir)) { + loadDirectory(currentDir.getParent()); + } + return true; + } + if (ke.isConfirm()) { + openSelectedEntry(); + return true; + } + return false; + } + + private void openSelectedEntry() { + Integer sel = listState.selected(); + if (sel != null && sel < entries.size()) { + FilesBrowser.FileEntry entry = entries.get(sel); + if (entry.directory()) { + loadDirectory(Path.of(entry.path())); + } else { + Path filePath = Path.of(entry.path()); + sourceViewer.loadFile(filePath); + if (isCamelSourceFile(filePath)) { + sourceViewer.setQuickDocProvider(this::provideCamelQuickDocs); + } + focusOnViewer = true; + } + } + } + + private boolean isCamelSourceFile(Path path) { + String name = path.getFileName().toString().toLowerCase(); + if (name.endsWith(".camel.yaml") || name.endsWith(".camel.yml") + || name.endsWith(".kamelet.yaml") || name.endsWith(".kamelet.yml")) { + return true; + } + if (name.endsWith(".yaml") || name.endsWith(".yml")) { + return TuiHelper.isCamelYaml(path); + } + if (name.endsWith(".xml")) { + return TuiHelper.isCamelXml(path); + } + return false; + } + + private CamelCatalog getCatalog() { + IntegrationInfo info = ctx.findSelectedIntegration(); + if (info == null || info.camelVersion == null) { + return null; + } + String version = info.camelVersion; + CamelCatalog cached = catalogCache.get(version); + if (cached != null) { + return cached; + } + try { + cached = CatalogLoader.loadCatalog(null, version, true); + if (cached != null) { + catalogCache.put(version, cached); + } + return cached; + } catch (Exception e) { + return null; + } + } + + private Map<Integer, List<String>> provideCamelQuickDocs(List<JsonObject> codeData) { + CamelCatalog catalog = getCatalog(); + if (catalog == null || codeData.isEmpty()) { + return Map.of(); + } + + Map<Integer, List<String>> result = new LinkedHashMap<>(); + for (int i = 0; i < codeData.size(); i++) { + String code = codeData.get(i).getString("code"); + if (code == null) { + continue; + } + + Matcher uriMatcher = YAML_URI_PATTERN.matcher(code); + if (uriMatcher.find()) { + String uri = uriMatcher.group(1); + if (uri.endsWith("\"")) { + uri = uri.substring(0, uri.length() - 1); + } + RoutesTab.buildEndpointInlineDoc(result, codeData, catalog, uri, i); + continue; + } + + Matcher eipMatcher = YAML_EIP_PATTERN.matcher(code); + if (eipMatcher.find()) { + String eipName = eipMatcher.group(1); + RoutesTab.buildEipInlineDoc(result, codeData, catalog, eipName, null, i); + } + } + return result; + } + + private void renderFileList(Frame frame, Rect area) { + if (entries.isEmpty()) { + String noFilesMsg = rootDir == null ? "No source directory found" : "No files found"; + frame.renderWidget( + Paragraph.builder() + .text(Text.from(Line.from(Span.styled(" " + noFilesMsg, Style.EMPTY.dim())))) + .block(Block.builder() + .borderType(BorderType.ROUNDED).borders(Borders.ALL) + .title(Title.from(Line.from( + Span.styled(" Files ", + focusOnViewer ? Style.EMPTY.fg(Theme.accent()) : Theme.title())))) + .build()) + .build(), + area); + return; + } + + String panelTitle = " Files"; + if (rootDir != null && currentDir != null && !currentDir.equals(rootDir)) { + panelTitle += ": " + rootDir.relativize(currentDir); + } + panelTitle += " "; + + ListItem[] items = new ListItem[entries.size()]; + for (int i = 0; i < entries.size(); i++) { + FilesBrowser.FileEntry entry = entries.get(i); + String label = " " + entry.emoji() + " " + entry.name(); + if (entry.directory()) { + boolean dimDir = entry.name().startsWith(".") || "target".equals(entry.name()); + Style dirStyle = dimDir ? Style.EMPTY.fg(Theme.accent()).dim() : Style.EMPTY.fg(Theme.accent()); + items[i] = ListItem.from(Line.from(Span.styled(label, dirStyle))); + } else { + items[i] = ListItem.from(Line.from(Span.raw(label))); + } + } + + Style titleStyle = focusOnViewer ? Style.EMPTY.fg(Theme.accent()) : Theme.title(); + ListWidget list = ListWidget.builder() + .items(items) + .highlightStyle(focusOnViewer ? Theme.selectionBg().dim() : Theme.selectionBg()) + .highlightSymbol("") + .scrollMode(ScrollMode.AUTO_SCROLL) + .block(Block.builder() + .borderType(BorderType.ROUNDED).borders(Borders.ALL) + .title(Title.from(Line.from(Span.styled(panelTitle, titleStyle)))) + .build()) + .build(); + frame.renderStatefulWidget(list, area, listState); + + int visibleRows = area.height() - 2; + if (entries.size() > visibleRows) { + Rect scrollRect = new Rect( + area.x() + area.width() - 1, + area.y() + 1, + 1, + visibleRows); + listScrollState.contentLength(entries.size()); + listScrollState.viewportContentLength(visibleRows); + listScrollState.position(listState.offset()); + frame.renderStatefulWidget(Scrollbar.builder().build(), scrollRect, listScrollState); + } + } + + private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + private void renderInfoPanel(Frame frame, Rect area) { + List<Line> lines = new ArrayList<>(); + Integer sel = listState.selected(); + if (sel != null && sel < entries.size()) { + FilesBrowser.FileEntry entry = entries.get(sel); + Path path = Path.of(entry.path()); + try { + BasicFileAttributes attrs = Files.readAttributes(path, BasicFileAttributes.class); + if (!entry.directory()) { + lines.add(Line.from( + Span.styled(" Size: ", Style.EMPTY.dim()), + Span.raw(FilesBrowser.formatFileSize(attrs.size())))); + } + String modified = attrs.lastModifiedTime().toInstant() + .atZone(ZoneId.systemDefault()).format(DATE_FMT); + lines.add(Line.from( + Span.styled(" Modified: ", Style.EMPTY.dim()), + Span.raw(modified))); + if (!entry.directory()) { + String ext = ""; + int dot = entry.name().lastIndexOf('.'); + if (dot > 0) { + ext = entry.name().substring(dot + 1); + } + if (!ext.isEmpty()) { + lines.add(Line.from( + Span.styled(" Type: ", Style.EMPTY.dim()), + Span.raw(ext))); + } + } + } catch (IOException e) { + // ignore + } + } + + frame.renderWidget( + Paragraph.builder() + .text(Text.from(lines)) + .block(Block.builder() + .borderType(BorderType.ROUNDED).borders(Borders.ALL) + .title(Title.from(Line.from( + Span.styled(" Info ", focusOnViewer ? Style.EMPTY.fg(Theme.accent()) : Theme.title())))) + .build()) + .build(), + area); + } + + private void renderSourcePanel(Frame frame, Rect area) { + Style sourceTitleStyle = focusOnViewer ? Theme.title() : Style.EMPTY.fg(Theme.accent()); + if (sourceViewer.isVisible()) { + sourceViewer.setTitleStyle(sourceTitleStyle); + sourceViewer.setFocused(focusOnViewer); + sourceViewer.render(frame, area); + } else { + List<Line> lines = new ArrayList<>(); + lines.add(Line.from(Span.raw(""))); + lines.add(Line.from(Span.styled(" Select a file and press Enter to view source", Style.EMPTY.dim()))); + + frame.renderWidget( + Paragraph.builder() + .text(Text.from(lines)) + .block(Block.builder() + .borderType(BorderType.ROUNDED).borders(Borders.ALL) + .title(Title.from(Line.from( + Span.styled(" Source ", sourceTitleStyle)))) + .build()) + .build(), + area); + } + } +} 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 83bf0e67da04..cc8aadae919e 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 @@ -93,6 +93,8 @@ class SourceViewer { private QuickDocProvider quickDocProvider; private boolean quickDocEnabled; private Map<Integer, List<String>> quickDocEntries = Collections.emptyMap(); + private Style titleStyle; + private boolean focused = true; private record CachedSource( List<String> lines, List<JsonObject> codeData, @@ -103,6 +105,14 @@ class SourceViewer { return visible; } + void setTitleStyle(Style style) { + this.titleStyle = style; + } + + void setFocused(boolean focused) { + this.focused = focused; + } + void hide() { visible = false; onLineSelected = null; @@ -193,7 +203,7 @@ class SourceViewer { onLineSelected = null; return true; } - if (isMarkdownFile && ke.isKey(KeyCode.TAB)) { + if (isMarkdownFile && ke.isChar(' ')) { markdownMode = !markdownMode; return true; } @@ -213,7 +223,7 @@ class SourceViewer { } return true; } - if (currentRouteId != null && ke.isKey(KeyCode.TAB)) { + if (currentRouteId != null && ke.isChar(' ')) { String[] formats = { "yaml", "java", "xml" }; int idx = 0; for (int i = 0; i < formats.length; i++) { @@ -222,11 +232,7 @@ class SourceViewer { break; } } - if (ke.hasShift()) { - idx = (idx - 1 + formats.length) % formats.length; - } else { - idx = (idx + 1) % formats.length; - } + idx = (idx + 1) % formats.length; quickDocEntries = Collections.emptyMap(); switchFormat(formats[idx]); return true; @@ -475,7 +481,7 @@ class SourceViewer { if (markdownMode) { TuiHelper.hint(spans, "Esc/c", "close"); TuiHelper.hint(spans, TuiIcons.HINT_SCROLL, "scroll"); - TuiHelper.hint(spans, "Tab", "format"); + TuiHelper.hint(spans, "Space", "format"); TuiHelper.hint(spans, "PgUp/PgDn", "page"); return; } @@ -493,7 +499,7 @@ class SourceViewer { } TuiHelper.hint(spans, TuiIcons.HINT_SCROLL, "navigate"); if (isMarkdownFile || currentRouteId != null) { - TuiHelper.hint(spans, "Tab", "format"); + TuiHelper.hint(spans, "Space", "format"); } search.renderSearchHints(spans); TuiHelper.hint(spans, "w", "wrap" + (wordWrap ? " [on]" : " [off]")); @@ -755,9 +761,10 @@ class SourceViewer { private Title buildTitle() { String info = title != null ? title : ""; + Style ts = titleStyle != null ? titleStyle : Style.EMPTY; if (isMarkdownFile) { List<Span> spans = new ArrayList<>(); - spans.add(Span.raw(" Source [" + info + "] ")); + spans.add(Span.styled(" Source [" + info + "] ", ts)); String mdLabel = "Markdown*"; if (markdownMode) { spans.add(Span.styled(mdLabel, Style.EMPTY.bold())); @@ -774,11 +781,11 @@ class SourceViewer { return Title.from(Line.from(spans)); } if (currentRouteId == null) { - return Title.from(Line.from(List.of(Span.raw(" Source [" + info + "] ")))); + return Title.from(Line.from(List.of(Span.styled(" Source [" + info + "] ", ts)))); } List<Span> spans = new ArrayList<>(); - spans.add(Span.raw(" Source [" + info + "] ")); + spans.add(Span.styled(" Source [" + info + "] ", ts)); String[] formats = { "yaml", "java", "xml" }; String[] labels = { "YAML", "Java", "XML" }; @@ -946,11 +953,11 @@ class SourceViewer { Line highlighted = SyntaxHighlighter.highlightLine(code, language); List<Span> spans = new ArrayList<>(); - Style selBg = Theme.selectionBg(); + Style selBg = focused ? Theme.selectionBg() : Theme.selectionBg().dim(); if (isSelected) { - spans.add(Span.styled(">> ", Theme.label().bold())); + spans.add(Span.styled(">> ", focused ? Theme.label().bold() : Theme.label().dim())); if (!prefix.isEmpty()) { - spans.add(Span.styled(prefix, Theme.label().bold().patch(selBg))); + spans.add(Span.styled(prefix, (focused ? Theme.label().bold() : Theme.label().dim()).patch(selBg))); } for (Span s : highlighted.spans()) { spans.add(Span.styled(s.content(), s.style().patch(selBg))); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TabRegistry.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TabRegistry.java index 0fdc0a31873c..5f52269fb948 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TabRegistry.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TabRegistry.java @@ -29,12 +29,12 @@ class TabRegistry { // Tab indices static final int TAB_OVERVIEW = 0; - static final int TAB_LOG = 1; - static final int TAB_ACTIVITY = 2; - static final int TAB_DIAGRAM = 3; - static final int TAB_ROUTES = 4; - static final int TAB_ENDPOINTS = 5; - static final int TAB_HTTP = 6; + static final int TAB_SOURCE = 1; + static final int TAB_LOG = 2; + static final int TAB_ACTIVITY = 3; + static final int TAB_DIAGRAM = 4; + static final int TAB_ROUTES = 5; + static final int TAB_ENDPOINTS = 6; static final int TAB_HISTORY = 7; static final int TAB_ERRORS = 8; static final int TAB_MORE = 9; @@ -75,6 +75,7 @@ class TabRegistry { private RouteControllerTab routeControllerTab; private EndpointsTab endpointsTab; private HttpTab httpTab; + private SourceTab sourceTab; private HealthTab healthTab; private HistoryTab historyTab; private CircuitBreakerTab circuitBreakerTab; @@ -129,6 +130,7 @@ class TabRegistry { sqlTraceTab = new SqlTraceTab(ctx); endpointsTab = new EndpointsTab(ctx, dataService.metrics()); httpTab = new HttpTab(ctx); + sourceTab = new SourceTab(ctx); healthTab = new HealthTab(ctx); historyTab = new HistoryTab(ctx, dataService.traces(), dataService.traceFilePositions()); circuitBreakerTab = new CircuitBreakerTab(ctx, dataService.metrics()); @@ -165,6 +167,7 @@ class TabRegistry { new MoreTab(TuiIcons.TAB_BROWSE, "Browse", "Bro&wse", browseTab, "Routing"), new MoreTab(TuiIcons.TAB_CIRCUIT_BREAKER, "Circuit Breaker", "&Circuit Breaker", circuitBreakerTab, "Routing"), new MoreTab(TuiIcons.TAB_CONSUMERS, "Consumers", "Co&nsumers", consumersTab, "Routing"), + new MoreTab(TuiIcons.TAB_HTTP, "HTTP", "&HTTP", httpTab, "Routing"), new MoreTab(TuiIcons.TAB_INFLIGHT, "Inflight", "In&flight", inflightTab, "Routing"), new MoreTab(TuiIcons.TAB_PRODUCERS, "Producers", "Prod&ucers", producersTab, "Routing"), new MoreTab( @@ -208,7 +211,7 @@ class TabRegistry { case TAB_DIAGRAM -> diagramTab; case TAB_ROUTES -> routesTab; case TAB_ENDPOINTS -> endpointsTab; - case TAB_HTTP -> httpTab; + case TAB_SOURCE -> sourceTab; case TAB_HISTORY -> historyTab; case TAB_ERRORS -> errorsTab; case TAB_MORE -> activeMoreTab; @@ -301,7 +304,7 @@ class TabRegistry { void resetIntegrationTabState(DataRefreshService dataService, FilesBrowser filesBrowser) { diagramTab.onIntegrationChanged(); routesTab.onIntegrationChanged(); - httpTab.onIntegrationChanged(); + sourceTab.onIntegrationChanged(); logTab.onIntegrationChanged(); historyTab.onIntegrationChanged(); for (MoreTab mt : moreTabs) { @@ -338,6 +341,10 @@ class TabRegistry { return httpTab; } + SourceTab sourceTab() { + return sourceTab; + } + HealthTab healthTab() { return healthTab; } @@ -447,14 +454,14 @@ class TabRegistry { List<TabEntry> entries = new ArrayList<>(); entries.add( new TabEntry(icon(TAB_OVERVIEW), "Overview", overviewTab.description(), "1", TAB_OVERVIEW, -1, overviewTab)); - entries.add(new TabEntry(icon(TAB_LOG), "Log", logTab.description(), "2", TAB_LOG, -1, logTab)); + entries.add(new TabEntry(icon(TAB_SOURCE), "Source", sourceTab.description(), "2", TAB_SOURCE, -1, sourceTab)); + entries.add(new TabEntry(icon(TAB_LOG), "Log", logTab.description(), "3", TAB_LOG, -1, logTab)); entries.add( - new TabEntry(icon(TAB_ACTIVITY), "Activity", activityTab.description(), "3", TAB_ACTIVITY, -1, activityTab)); - entries.add(new TabEntry(icon(TAB_DIAGRAM), "Diagram", diagramTab.description(), "4", TAB_DIAGRAM, -1, diagramTab)); - entries.add(new TabEntry(icon(TAB_ROUTES), "Routes", routesTab.description(), "5", TAB_ROUTES, -1, routesTab)); + new TabEntry(icon(TAB_ACTIVITY), "Activity", activityTab.description(), "4", TAB_ACTIVITY, -1, activityTab)); + entries.add(new TabEntry(icon(TAB_DIAGRAM), "Diagram", diagramTab.description(), "5", TAB_DIAGRAM, -1, diagramTab)); + entries.add(new TabEntry(icon(TAB_ROUTES), "Routes", routesTab.description(), "6", TAB_ROUTES, -1, routesTab)); entries.add(new TabEntry( - icon(TAB_ENDPOINTS), "Endpoints", endpointsTab.description(), "6", TAB_ENDPOINTS, -1, endpointsTab)); - entries.add(new TabEntry(icon(TAB_HTTP), "HTTP", httpTab.description(), "7", TAB_HTTP, -1, httpTab)); + icon(TAB_ENDPOINTS), "Endpoints", endpointsTab.description(), "7", TAB_ENDPOINTS, -1, endpointsTab)); entries.add(new TabEntry(icon(TAB_HISTORY), "Inspect", historyTab.description(), "8", TAB_HISTORY, -1, historyTab)); entries.add(new TabEntry(icon(TAB_ERRORS), "Errors", errorsTab.description(), "9", TAB_ERRORS, -1, errorsTab)); for (int i = 0; i < moreTabs.size(); 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 16c5a4c624c4..d062c3f34f40 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 @@ -818,7 +818,7 @@ final class TuiHelper { return TuiIcons.CLIPBOARD; } - private static boolean isCamelYaml(Path path) { + static boolean isCamelYaml(Path path) { try { String content = Files.readString(path, StandardCharsets.UTF_8); for (String marker : CAMEL_YAML_MARKERS) { @@ -832,7 +832,7 @@ final class TuiHelper { return false; } - private static boolean isCamelXml(Path path) { + static boolean isCamelXml(Path path) { try { String content = Files.readString(path, StandardCharsets.UTF_8); for (String marker : CAMEL_XML_MARKERS) { 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 ad41009894f1..3e96996b5b6c 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 @@ -117,6 +117,7 @@ final class TuiIcons { static final String TAB_ROUTES = "🔀"; static final String TAB_ENDPOINTS = ENDPOINT; static final String TAB_HTTP = ONLINE; + static final String TAB_SOURCE = COMPUTER; static final String TAB_HEALTH = DOCTOR; static final String TAB_INSPECT = GO_TO; static final String TAB_ERRORS = FAIL; @@ -152,8 +153,8 @@ final class TuiIcons { /** Icons for {@link TabRegistry#TAB_OVERVIEW}..{@link TabRegistry#TAB_MORE} (in order). */ static final List<String> PRIMARY_TAB_ICONS = List.of( - TAB_OVERVIEW, TAB_LOG, TAB_ACTIVITY, TAB_DIAGRAM, TAB_ROUTES, - TAB_ENDPOINTS, TAB_HTTP, TAB_INSPECT, TAB_ERRORS, TAB_MORE); + TAB_OVERVIEW, TAB_SOURCE, TAB_LOG, TAB_ACTIVITY, TAB_DIAGRAM, + TAB_ROUTES, TAB_ENDPOINTS, TAB_INSPECT, TAB_ERRORS, TAB_MORE); /** Marker placed immediately before a label's keyboard-shortcut letter (Windows-style mnemonic). */ static final char MNEMONIC_MARKER = '&'; diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TabBarRenderTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TabBarRenderTest.java index 9f149007bae8..07b0e153bbd2 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TabBarRenderTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TabBarRenderTest.java @@ -33,7 +33,7 @@ class TabBarRenderTest { @Test void primaryTabHeadersStartWithIconAndTwoSpaces() { - String[] names = { "Overview", "Log", "Diagram", "Route", "Endpoint", "HTTP", "Health", "Inspect", "Errors" }; + String[] names = { "Overview", "Source", "Log", "Activity", "Diagram", "Route", "Endpoint", "Inspect", "Errors" }; for (int i = 0; i < names.length; i++) { String header = Line.from(TuiIcons.primaryTabHeader(TuiIcons.PRIMARY_TAB_ICONS.get(i), String.valueOf(i + 1), names[i])) @@ -53,8 +53,8 @@ class TabBarRenderTest { @Test void routeAndTopLabelsHaveEqualWidth() { // toggling Top mode must not shift the tab bar - int route = Line.from(TuiIcons.primaryTabHeader(TuiIcons.TAB_ROUTES, "4", "Route")).width(); - int top = Line.from(TuiIcons.primaryTabHeader(TuiIcons.TAB_ROUTES, "4", " Top ")).width(); + int route = Line.from(TuiIcons.primaryTabHeader(TuiIcons.TAB_ROUTES, "6", "Route")).width(); + int top = Line.from(TuiIcons.primaryTabHeader(TuiIcons.TAB_ROUTES, "6", " Top ")).width(); assertEquals(route, top, "Route and Top tab cells must be the same width"); } @@ -79,12 +79,12 @@ class TabBarRenderTest { private static Line[] fullTabLabels(String routesLabel) { return new Line[] { Line.from(TuiIcons.primaryTabHeader(TuiIcons.TAB_OVERVIEW, "1", "Overview")), - Line.from(TuiIcons.primaryTabHeader(TuiIcons.TAB_LOG, "2", "Log")), - Line.from(TuiIcons.primaryTabHeader(TuiIcons.TAB_DIAGRAM, "3", "Diagram")), - Line.from(TuiIcons.primaryTabHeader(TuiIcons.TAB_ROUTES, "4", routesLabel)), - Line.from(TuiIcons.primaryTabHeader(TuiIcons.TAB_ENDPOINTS, "5", "Endpoint")), - Line.from(TuiIcons.primaryTabHeader(TuiIcons.TAB_HTTP, "6", "HTTP")), - Line.from(TuiIcons.primaryTabHeader(TuiIcons.TAB_HEALTH, "7", "Health")), + Line.from(TuiIcons.primaryTabHeader(TuiIcons.TAB_SOURCE, "2", "Source")), + Line.from(TuiIcons.primaryTabHeader(TuiIcons.TAB_LOG, "3", "Log")), + Line.from(TuiIcons.primaryTabHeader(TuiIcons.TAB_ACTIVITY, "4", "Activity")), + Line.from(TuiIcons.primaryTabHeader(TuiIcons.TAB_DIAGRAM, "5", "Diagram")), + Line.from(TuiIcons.primaryTabHeader(TuiIcons.TAB_ROUTES, "6", routesLabel)), + Line.from(TuiIcons.primaryTabHeader(TuiIcons.TAB_ENDPOINTS, "7", "Endpoint")), Line.from(TuiIcons.primaryTabHeader(TuiIcons.TAB_INSPECT, "8", "Inspect")), Line.from(TuiIcons.primaryTabHeader(TuiIcons.TAB_ERRORS, "9", "Errors")), Line.from(TuiIcons.primaryTabHeader(TuiIcons.TAB_MORE, "0", TuiIcons.moreTabLabel())), diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TabRegistryTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TabRegistryTest.java index 86d153cac9c6..6b384a904836 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TabRegistryTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TabRegistryTest.java @@ -18,7 +18,6 @@ package org.apache.camel.dsl.jbang.core.commands.tui; import java.nio.file.Path; import java.util.List; -import java.util.Set; import java.util.concurrent.atomic.AtomicReference; import dev.tamboui.text.CharWidth; @@ -82,8 +81,8 @@ class TabRegistryTest { } @Test - void moreTabsHasTwentySixEntries() { - assertEquals(26, registry.moreTabs().size()); + void moreTabsHasTwentySevenEntries() { + assertEquals(27, registry.moreTabs().size()); } @Test @@ -109,17 +108,22 @@ class TabRegistryTest { // MORE_SHORTCUTS array carried before the MoreTab refactor. A label edit that repoints a key must fail here. List<Character> shortcuts = registry.moreTabs().stream().map(TabRegistry.MoreTab::shortcut).toList(); assertEquals( - List.of('W', 'C', 'N', 'F', 'U', 'Z', 'E', 'I', 'X', 'O', 'J', 'K', 'Q', 'R', 'A', 'H', 'M', 'Y', 'P', 'S', - 'T', 'B', 'L', 'G', 'V', 'D'), + List.of('W', 'C', 'N', 'H', 'F', 'U', 'Z', 'E', 'I', 'X', 'O', 'J', 'K', 'Q', 'R', 'A', 'H', 'M', 'Y', + 'P', 'S', 'T', 'B', 'L', 'G', 'V', 'D'), shortcuts, "More tab shortcut letters must match the historical sequence"); } @Test - void moreTabShortcutsAreUnique() { - // morePopupShortcut() returns the first matching tab, so a duplicated letter would silently shadow a later tab. + void moreTabShortcutsHaveAtMostTwoDuplicates() { + // Duplicate mnemonics are allowed; the popup cycles through them on repeated key press. List<Character> shortcuts = registry.moreTabs().stream().map(TabRegistry.MoreTab::shortcut).toList(); - assertEquals(shortcuts.size(), Set.copyOf(shortcuts).size(), - "More tab shortcut letters must be unique: " + shortcuts); + java.util.Map<Character, Long> counts = shortcuts.stream() + .collect(java.util.stream.Collectors.groupingBy(c -> c, java.util.stream.Collectors.counting())); + for (var entry : counts.entrySet()) { + assertTrue(entry.getValue() <= 2, + "More tab shortcut '" + entry.getKey() + "' appears " + entry.getValue() + + " times (max 2): " + shortcuts); + } } @Test @@ -196,12 +200,12 @@ class TabRegistryTest { @Test void tabConstantsAreSequential() { assertEquals(0, TabRegistry.TAB_OVERVIEW, "TAB_OVERVIEW should be 0"); - assertEquals(1, TabRegistry.TAB_LOG, "TAB_LOG should be 1"); - assertEquals(2, TabRegistry.TAB_ACTIVITY, "TAB_ACTIVITY should be 2"); - assertEquals(3, TabRegistry.TAB_DIAGRAM, "TAB_DIAGRAM should be 3"); - assertEquals(4, TabRegistry.TAB_ROUTES, "TAB_ROUTES should be 4"); - assertEquals(5, TabRegistry.TAB_ENDPOINTS, "TAB_ENDPOINTS should be 5"); - assertEquals(6, TabRegistry.TAB_HTTP, "TAB_HTTP should be 6"); + assertEquals(1, TabRegistry.TAB_SOURCE, "TAB_SOURCE should be 1"); + assertEquals(2, TabRegistry.TAB_LOG, "TAB_LOG should be 2"); + assertEquals(3, TabRegistry.TAB_ACTIVITY, "TAB_ACTIVITY should be 3"); + assertEquals(4, TabRegistry.TAB_DIAGRAM, "TAB_DIAGRAM should be 4"); + assertEquals(5, TabRegistry.TAB_ROUTES, "TAB_ROUTES should be 5"); + assertEquals(6, TabRegistry.TAB_ENDPOINTS, "TAB_ENDPOINTS should be 6"); assertEquals(7, TabRegistry.TAB_HISTORY, "TAB_HISTORY should be 7"); assertEquals(8, TabRegistry.TAB_ERRORS, "TAB_ERRORS should be 8"); assertEquals(9, TabRegistry.TAB_MORE, "TAB_MORE should be 9"); @@ -223,7 +227,7 @@ class TabRegistryTest { int[] tabs = { TabRegistry.TAB_OVERVIEW, TabRegistry.TAB_LOG, TabRegistry.TAB_ACTIVITY, TabRegistry.TAB_DIAGRAM, TabRegistry.TAB_ROUTES, TabRegistry.TAB_ENDPOINTS, - TabRegistry.TAB_HTTP, TabRegistry.TAB_HISTORY, TabRegistry.TAB_ERRORS, + TabRegistry.TAB_SOURCE, TabRegistry.TAB_HISTORY, TabRegistry.TAB_ERRORS, TabRegistry.TAB_MORE }; for (int i = 0; i < tabs.length; i++) { @@ -242,7 +246,7 @@ class TabRegistryTest { assertTrue(TabRegistry.TAB_DIAGRAM >= 0, "TAB_DIAGRAM should be non-negative"); assertTrue(TabRegistry.TAB_ROUTES >= 0, "TAB_ROUTES should be non-negative"); assertTrue(TabRegistry.TAB_ENDPOINTS >= 0, "TAB_ENDPOINTS should be non-negative"); - assertTrue(TabRegistry.TAB_HTTP >= 0, "TAB_HTTP should be non-negative"); + assertTrue(TabRegistry.TAB_SOURCE >= 0, "TAB_SOURCE should be non-negative"); assertTrue(TabRegistry.TAB_HISTORY >= 0, "TAB_HISTORY should be non-negative"); assertTrue(TabRegistry.TAB_ERRORS >= 0, "TAB_ERRORS should be non-negative"); assertTrue(TabRegistry.TAB_MORE >= 0, "TAB_MORE should be non-negative"); @@ -256,7 +260,7 @@ class TabRegistryTest { assertTrue(TabRegistry.TAB_DIAGRAM < TabRegistry.NUM_TABS); assertTrue(TabRegistry.TAB_ROUTES < TabRegistry.NUM_TABS); assertTrue(TabRegistry.TAB_ENDPOINTS < TabRegistry.NUM_TABS); - assertTrue(TabRegistry.TAB_HTTP < TabRegistry.NUM_TABS); + assertTrue(TabRegistry.TAB_SOURCE < TabRegistry.NUM_TABS); assertTrue(TabRegistry.TAB_HISTORY < TabRegistry.NUM_TABS); assertTrue(TabRegistry.TAB_ERRORS < TabRegistry.NUM_TABS); assertTrue(TabRegistry.TAB_MORE < TabRegistry.NUM_TABS); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiIconsTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiIconsTest.java index 87564bfae514..6ed452a76918 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiIconsTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiIconsTest.java @@ -40,12 +40,12 @@ class TuiIconsTest { // Guards that PRIMARY_TAB_ICONS is indexed by the TAB_* constants: reordering the list must break this, // otherwise TabRegistry.icon(index) would attach the wrong emoji to Go-to entries. assertEquals(TuiIcons.TAB_OVERVIEW, TuiIcons.PRIMARY_TAB_ICONS.get(TabRegistry.TAB_OVERVIEW)); + assertEquals(TuiIcons.TAB_SOURCE, TuiIcons.PRIMARY_TAB_ICONS.get(TabRegistry.TAB_SOURCE)); assertEquals(TuiIcons.TAB_LOG, TuiIcons.PRIMARY_TAB_ICONS.get(TabRegistry.TAB_LOG)); assertEquals(TuiIcons.TAB_ACTIVITY, TuiIcons.PRIMARY_TAB_ICONS.get(TabRegistry.TAB_ACTIVITY)); assertEquals(TuiIcons.TAB_DIAGRAM, TuiIcons.PRIMARY_TAB_ICONS.get(TabRegistry.TAB_DIAGRAM)); assertEquals(TuiIcons.TAB_ROUTES, TuiIcons.PRIMARY_TAB_ICONS.get(TabRegistry.TAB_ROUTES)); assertEquals(TuiIcons.TAB_ENDPOINTS, TuiIcons.PRIMARY_TAB_ICONS.get(TabRegistry.TAB_ENDPOINTS)); - assertEquals(TuiIcons.TAB_HTTP, TuiIcons.PRIMARY_TAB_ICONS.get(TabRegistry.TAB_HTTP)); assertEquals(TuiIcons.TAB_INSPECT, TuiIcons.PRIMARY_TAB_ICONS.get(TabRegistry.TAB_HISTORY)); assertEquals(TuiIcons.TAB_ERRORS, TuiIcons.PRIMARY_TAB_ICONS.get(TabRegistry.TAB_ERRORS)); assertEquals(TuiIcons.TAB_MORE, TuiIcons.PRIMARY_TAB_ICONS.get(TabRegistry.TAB_MORE));
