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 3b121d08806d CAMEL-23648: camel-jbang - TUI improvements: run from
folder, search, syntax highlighting, performance (#23671)
3b121d08806d is described below
commit 3b121d08806db72ab9a66408dabad328df388bff
Author: Claus Ibsen <[email protected]>
AuthorDate: Mon Jun 1 11:25:52 2026 +0200
CAMEL-23648: camel-jbang - TUI improvements: run from folder, search,
syntax highlighting, performance (#23671)
* CAMEL-23648: camel-jbang - TUI Run from folder uses --source-dir and
defaults dev mode on
Co-Authored-By: Claude <[email protected]>
* CAMEL-23648: camel-jbang - TUI Log tab find and highlight
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* CAMEL-23648: camel-jbang - TUI Routes tab improve diagram label and help
text
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* CAMEL-23648: camel-jbang - TUI source code syntax highlighting
Co-Authored-By: Claude <[email protected]>
* CAMEL-23648: camel-jbang - TUI source view auto-scroll past license
headers
Co-Authored-By: Claude <[email protected]>
* CAMEL-23648: camel-jbang - TUI Log tab loading and scroll improvements
Co-Authored-By: Claude <[email protected]>
* CAMEL-23648: camel-jbang - TUI performance: cache PIDs and optimize
refresh
Move log reading before the PID scan so logs appear instantly. Cache the
PID list and only do a full ProcessHandle.allProcesses() scan on the
Overview tab or F3 switch popup. On other tabs reuse cached PIDs with
direct ProcessHandle.of() lookups, eliminating the 3-4 second delay.
Also add stripAnsi fast-path for lines without escape sequences.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* CAMEL-23648: camel-jbang - TUI Log tab avoid unnecessary render rebuilds
Only reassign filteredLogEntries when log content actually changes (new
lines arrived or older lines loaded). Previously a new ArrayList was
created every refresh tick, causing the render to rebuild all styled
Line objects every 100ms even when idle. Also only refresh the selected
integration's status on non-Overview tabs, and skip infra discovery.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* CAMEL-23648: camel-jbang - TUI throttle full PID scan and instant log load
Throttle the full ProcessHandle.allProcesses() scan to every 2 seconds
on the Overview tab instead of every 100ms tick. Between scans, reuse
cached PIDs with fast status-only refreshes. Also load log data directly
from the tick handler when a full refresh is in progress, so the Log tab
appears instantly when switching from Overview instead of waiting 3-4s.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* CAMEL-23648: camel-jbang - TUI load log data immediately on tab switch
Call refreshLogData() synchronously when switching to the Log tab via
ENTER on Overview or the 2 key shortcut, so log lines appear on the
same render frame without waiting for the async refresh cycle.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* CAMEL-23648: camel-jbang - TUI load log data immediately on F3 switch
Call refreshLogData() after F3 integration switch when on the Log tab,
same pattern as the ENTER and tab key handlers.
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* CAMEL-23648: camel-jbang - TUI show Stopped status when all routes are
stopped
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* CAMEL-23648: camel-jbang - TUI add F1 help hint to Overview tab footer
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* CAMEL-23648: camel-jbang - TUI improve SINCE-LAST help text across tabs
Co-Authored-By: Claude Opus 4.6 <[email protected]>
* CAMEL-23648: camel-jbang - TUI clip and right-align property source in
Configuration tab
Co-Authored-By: Claude Opus 4.6 <[email protected]>
---------
Co-authored-by: Claude <[email protected]>
---
.../dsl/jbang/core/commands/tui/ActionsPopup.java | 4 +-
.../dsl/jbang/core/commands/tui/CamelMonitor.java | 210 +++++++----
.../jbang/core/commands/tui/CircuitBreakerTab.java | 2 +-
.../jbang/core/commands/tui/ConfigurationTab.java | 23 +-
.../camel/dsl/jbang/core/commands/tui/LogTab.java | 412 +++++++++++++++++++--
.../dsl/jbang/core/commands/tui/OverviewTab.java | 10 +-
.../dsl/jbang/core/commands/tui/RoutesTab.java | 105 +++++-
.../jbang/core/commands/tui/RunOptionsForm.java | 6 +-
.../jbang/core/commands/tui/SyntaxHighlighter.java | 291 +++++++++++++++
.../dsl/jbang/core/commands/tui/TuiHelper.java | 3 +
10 files changed, 957 insertions(+), 109 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 40fe91b32247..9c660417f9f9 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
@@ -1168,7 +1168,7 @@ class ActionsPopup {
selectedFolder = folder;
showFolderInput = false;
String displayName = Path.of(folder).getFileName().toString();
- runOptionsForm.open(displayName, displayName, false);
+ runOptionsForm.open(displayName, displayName, false, true);
}
private void navigateFolderHistory(int direction) {
@@ -1275,7 +1275,7 @@ class ActionsPopup {
try {
List<String> cmd = new
ArrayList<>(LauncherHelper.getCamelCommand());
cmd.add("run");
- cmd.add(folder);
+ cmd.add("--source-dir=" + folder);
cmd.add("--logging-color=true");
cmd.addAll(extraArgs);
Path outputFile = Files.createTempFile("camel-folder-", ".log");
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 dc8314a71d23..5db7e2fa6d69 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
@@ -197,6 +197,10 @@ public class CamelMonitor extends CamelCommand {
private final Map<String, LoadAvg> cpuLoadAvg = new ConcurrentHashMap<>();
private final Map<String, long[]> prevCpuSample = new
ConcurrentHashMap<>();
+ // Cached PID list — full process scan throttled to every 2 seconds
+ private volatile List<Long> cachedPids = Collections.emptyList();
+ private volatile long lastFullScanTime;
+
// Trace/history data — shared between CamelMonitor and tabs
private final AtomicReference<List<TraceEntry>> traces = new
AtomicReference<>(Collections.emptyList());
private final Map<String, Long> traceFilePositions = new
ConcurrentHashMap<>();
@@ -492,6 +496,9 @@ public class CamelMonitor extends CamelCommand {
ctx.selectedPid = chosen.pid;
ctx.lastSelectedName = chosen.name;
resetIntegrationTabState();
+ if (tabsState.selected() == TAB_LOG) {
+ refreshLogData();
+ }
}
return true;
}
@@ -525,9 +532,11 @@ public class CamelMonitor extends CamelCommand {
}
return true;
}
- // Quit: q or Ctrl+c (skip when probe is editing text)
+ // Quit: q or Ctrl+c (skip when text input is active)
boolean probeEditing = tabsState.selected() == TAB_HTTP &&
httpTab.isProbeMode();
- if (!probeEditing && (ke.isCharIgnoreCase('q') || ke.isCtrlC())) {
+ boolean logSearchActive = tabsState.selected() == TAB_LOG &&
logTab.isSearchInputActive();
+ boolean textEditing = probeEditing || logSearchActive;
+ if (!textEditing && (ke.isCharIgnoreCase('q') || ke.isCtrlC())) {
runner.quit();
return true;
}
@@ -535,9 +544,9 @@ public class CamelMonitor extends CamelCommand {
runner.quit();
return true;
}
- // Tab switching with number keys (skip when probe is editing text)
+ // Tab switching with number keys (skip when text input is active)
// When infra is selected, only Overview (1) and Log (2) are
available
- if (!probeEditing) {
+ if (!textEditing) {
if (ke.isChar('1')) {
return handleTabKey(TAB_OVERVIEW);
}
@@ -573,8 +582,8 @@ public class CamelMonitor extends CamelCommand {
}
// Tab cycling (check Shift+Tab before Tab since Tab binding also
matches Shift+Tab)
- // Skip tab cycling when HTTP probe is active (Tab navigates
fields)
- if (ke.isFocusPrevious() && !(tabsState.selected() == TAB_HTTP &&
httpTab.isProbeMode())) {
+ // Skip tab cycling when text input is active (Tab navigates
fields)
+ if (ke.isFocusPrevious() && !textEditing) {
if (isInfraSelected()) {
// Cycle between Overview and Log only
int prev = tabsState.selected() == TAB_OVERVIEW ? TAB_LOG
: TAB_OVERVIEW;
@@ -588,7 +597,7 @@ public class CamelMonitor extends CamelCommand {
}
return true;
}
- if (ke.isFocusNext() && !(tabsState.selected() == TAB_HTTP &&
httpTab.isProbeMode())) {
+ if (ke.isFocusNext() && !textEditing) {
if (isInfraSelected()) {
int next = tabsState.selected() == TAB_OVERVIEW ? TAB_LOG
: TAB_OVERVIEW;
tabsState.select(next);
@@ -706,6 +715,7 @@ public class CamelMonitor extends CamelCommand {
overviewTab.selectCurrentIntegration();
if (ctx.selectedPid != null) {
tabsState.select(TAB_LOG);
+ refreshLogData();
}
return true;
}
@@ -748,6 +758,10 @@ public class CamelMonitor extends CamelCommand {
httpTab.handlePaste(pe.text());
return true;
}
+ if (logTab.isSearchInputActive()) {
+ logTab.handlePaste(pe.text());
+ return true;
+ }
}
if (event instanceof TickEvent) {
long now = System.currentTimeMillis();
@@ -844,6 +858,7 @@ public class CamelMonitor extends CamelCommand {
overviewTab.selectCurrentIntegration();
}
if (tab == TAB_LOG) {
+ refreshLogData();
logTab.onTabSelected();
}
if (tab == TAB_HISTORY && ctx.selectedPid != null) {
@@ -1667,6 +1682,7 @@ public class CamelMonitor extends CamelCommand {
// Insert F2/F3 after first hint (q) — each hint is 2 spans (key +
label)
int insertPos = Math.min(2, spans.size());
List<Span> fKeySpans = new ArrayList<>();
+ hint(fKeySpans, "F1", "help");
hint(fKeySpans, "F2", "actions");
if (getNonVanishingIntegrations().size() > 1) {
hint(fKeySpans, "F3", "switch");
@@ -1690,6 +1706,71 @@ public class CamelMonitor extends CamelCommand {
// ---- Data Loading ----
+ private void refreshLogData() {
+ if (tabsState.selected() != TAB_LOG) {
+ return;
+ }
+ String logPid = null;
+ String logFileName = null;
+ InfraInfo selInfra = findSelectedInfra();
+ if (selInfra != null) {
+ logPid = selInfra.pid;
+ logFileName = "infra-" + selInfra.alias + "-" + selInfra.pid +
".log";
+ } else {
+ IntegrationInfo selected = findSelectedIntegration();
+ if (selected != null) {
+ logPid = selected.pid;
+ logFileName = selected.pid + ".log";
+ }
+ }
+ if (logPid == null) {
+ return;
+ }
+ if (!logPid.equals(logTab.logFilePid)) {
+ logTab.mutableFilteredEntries.clear();
+ logTab.logFilePos = -1;
+ logTab.logTotalLinesRead = 0;
+ logTab.logLineBuffer.setLength(0);
+ logTab.logLoading = true;
+ }
+ // Load older lines when scrolled to the top or Home pressed
+ boolean changed = false;
+ boolean loadAll = logTab.loadAllRequested;
+ if (logTab.logFileStartPos > 0
+ && (loadAll || (!logTab.followMode && logTab.scroll == 0))) {
+ logTab.loadAllRequested = false;
+ List<String> olderLines = new ArrayList<>();
+ logTab.readOlderLogLines(logFileName, loadAll, olderLines);
+ if (!olderLines.isEmpty()) {
+ changed = true;
+ List<LogEntry> olderEntries = new ArrayList<>();
+ for (String line : olderLines) {
+ olderEntries.add(LogTab.parseLogLine(line));
+ }
+ logTab.mutableFilteredEntries.addAll(0, olderEntries);
+ logTab.logTotalLinesRead += olderLines.size();
+ logTab.scroll = olderEntries.size();
+ }
+ }
+ List<String> newRawLines = new ArrayList<>();
+ logTab.readNewLogLinesFromFile(logPid, logFileName, newRawLines);
+ changed |= !newRawLines.isEmpty();
+ if (changed) {
+ logTab.logTotalLinesRead += newRawLines.size();
+ for (String line : newRawLines) {
+ logTab.mutableFilteredEntries.add(LogTab.parseLogLine(line));
+ }
+ if (logTab.mutableFilteredEntries.size() > MAX_LOG_LINES) {
+ logTab.mutableFilteredEntries.subList(0,
logTab.mutableFilteredEntries.size() - MAX_LOG_LINES)
+ .clear();
+ }
+ }
+ if (changed || logTab.logLoading) {
+ logTab.filteredLogEntries = new
ArrayList<>(logTab.mutableFilteredEntries);
+ }
+ logTab.logLoading = false;
+ }
+
private void refreshData() {
if (runner == null) {
refreshDataSync();
@@ -1712,24 +1793,60 @@ public class CamelMonitor extends CamelCommand {
private void refreshDataSync() {
lastRefresh = System.currentTimeMillis();
try {
+ // Read log data early — before the heavy PID/status scan
+ refreshLogData();
+
List<IntegrationInfo> infos = new ArrayList<>();
- List<Long> pids = findPids(name);
- ProcessHandle.allProcesses()
- .filter(ph -> pids.contains(ph.pid()))
- .forEach(ph -> {
- JsonObject root = loadStatus(ph.pid());
- if (root != null) {
- IntegrationInfo info =
StatusParser.parseIntegration(ph, root);
- if (info != null) {
- infos.add(info);
- updateThroughputHistory(info);
- updateEndpointHistory(info);
- updateCbHistory(info);
- updateHeapHistory(info);
- updateLoadMetrics(ph, info);
- }
- }
- });
+ long now = System.currentTimeMillis();
+ boolean wantFullScan = tabsState.selected() == TAB_OVERVIEW ||
showSwitchPopup || cachedPids.isEmpty();
+ boolean fullScan = wantFullScan && (now - lastFullScanTime >=
2000);
+ List<Long> pids;
+ if (fullScan) {
+ pids = findPids(name);
+ cachedPids = pids;
+ lastFullScanTime = now;
+ } else {
+ pids = cachedPids;
+ }
+
+ // On non-Overview tabs, only refresh the selected integration for
speed
+ List<Long> refreshPids;
+ if (!fullScan && ctx.selectedPid != null) {
+ try {
+ refreshPids = List.of(Long.parseLong(ctx.selectedPid));
+ } catch (NumberFormatException e) {
+ refreshPids = pids;
+ }
+ } else {
+ refreshPids = pids;
+ }
+ for (Long pid : refreshPids) {
+ JsonObject root = loadStatus(pid);
+ if (root != null) {
+ ProcessHandle ph = ProcessHandle.of(pid).orElse(null);
+ if (ph == null) {
+ continue;
+ }
+ IntegrationInfo info = StatusParser.parseIntegration(ph,
root);
+ if (info != null) {
+ infos.add(info);
+ updateThroughputHistory(info);
+ updateEndpointHistory(info);
+ updateCbHistory(info);
+ updateHeapHistory(info);
+ updateLoadMetrics(ph, info);
+ }
+ }
+ }
+ // Carry forward non-selected integrations from previous data so
they don't vanish
+ if (!fullScan && ctx.selectedPid != null) {
+ List<IntegrationInfo> previous = data.get();
+ for (IntegrationInfo prev : previous) {
+ if (!prev.vanishing && !ctx.selectedPid.equals(prev.pid)) {
+ infos.add(prev);
+ }
+ }
+ }
// Detect disappeared integrations and start vanishing
Set<String> livePids = infos.stream().map(i ->
i.pid).collect(Collectors.toSet());
@@ -1741,7 +1858,6 @@ public class CamelMonitor extends CamelCommand {
}
// Expire old vanishing entries
- long now = System.currentTimeMillis();
Iterator<Map.Entry<String, VanishingInfo>> it =
vanishing.entrySet().iterator();
while (it.hasNext()) {
Map.Entry<String, VanishingInfo> entry = it.next();
@@ -1831,8 +1947,10 @@ public class CamelMonitor extends CamelCommand {
}
}
- // Discover running infra services
- refreshInfraData();
+ // Discover running infra services (only on Overview or switch
popup)
+ if (fullScan) {
+ refreshInfraData();
+ }
// Auto-select first infra service when no active integrations
exist
if (ctx.selectedPid == null && !infraData.get().isEmpty()
@@ -1845,43 +1963,7 @@ public class CamelMonitor extends CamelCommand {
}
}
- // Refresh log data only when the Log tab is visible
- if (tabsState.selected() == TAB_LOG) {
- String logPid = null;
- String logFileName = null;
- InfraInfo selInfra = findSelectedInfra();
- if (selInfra != null) {
- logPid = selInfra.pid;
- logFileName = "infra-" + selInfra.alias + "-" +
selInfra.pid + ".log";
- } else {
- IntegrationInfo selected = findSelectedIntegration();
- if (selected != null) {
- logPid = selected.pid;
- logFileName = selected.pid + ".log";
- }
- }
- if (logPid != null) {
- if (!logPid.equals(logTab.logFilePid)) {
- logTab.mutableFilteredEntries.clear();
- logTab.logFilePos = -1;
- logTab.logTotalLinesRead = 0;
- logTab.logLineBuffer.setLength(0);
- }
- List<String> newRawLines = new ArrayList<>();
- logTab.readNewLogLinesFromFile(logPid, logFileName,
newRawLines);
- if (!newRawLines.isEmpty()) {
- logTab.logTotalLinesRead += newRawLines.size();
- for (String line : newRawLines) {
-
logTab.mutableFilteredEntries.add(LogTab.parseLogLine(line));
- }
- if (logTab.mutableFilteredEntries.size() >
MAX_LOG_LINES) {
- logTab.mutableFilteredEntries.subList(0,
logTab.mutableFilteredEntries.size() - MAX_LOG_LINES)
- .clear();
- }
- logTab.filteredLogEntries = new
ArrayList<>(logTab.mutableFilteredEntries);
- }
- }
- }
+ // Log data is now refreshed at the top of refreshDataSync() via
refreshLogData()
// Scope history/error/trace refresh to the selected integration
only
List<Long> selectedPids = selectedPidAsList();
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CircuitBreakerTab.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CircuitBreakerTab.java
index 822e2001b1ee..d33f39a76617 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CircuitBreakerTab.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CircuitBreakerTab.java
@@ -476,7 +476,7 @@ class CircuitBreakerTab implements MonitorTab {
- **FAIL** — Total number of failed calls (exceptions thrown
by the protected code)
- **RATE%** — Current failure rate percentage in the sliding
window. When this exceeds the configured threshold, the circuit trips to OPEN
- **REJECT** — Calls rejected because the circuit is OPEN.
These calls never reach the downstream service — they fail fast with a fallback
- - **SINCE-LAST** — Time since last started/success/fail events
+ - **SINCE-LAST** — Time since the last circuit breaker
activity, shown as up to two values separated by `/`: success/failed (e.g.,
`3s/1m14s`). Values are omitted when there is no activity of that type
## Example Screen
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ConfigurationTab.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ConfigurationTab.java
index 867ed7921492..5bab0d7e6d57 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ConfigurationTab.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ConfigurationTab.java
@@ -35,6 +35,7 @@ import dev.tamboui.widgets.block.BorderType;
import dev.tamboui.widgets.paragraph.Paragraph;
import dev.tamboui.widgets.scrollbar.Scrollbar;
import dev.tamboui.widgets.scrollbar.ScrollbarState;
+import org.apache.camel.util.FileUtil;
import static org.apache.camel.dsl.jbang.core.commands.tui.MonitorContext.*;
@@ -152,6 +153,11 @@ class ConfigurationTab implements MonitorTab {
}
int keyWidth = Math.min(maxKeyLen, inner.width() / 2);
+ List<Rect> hChunks = Layout.horizontal()
+ .constraints(Constraint.fill(), Constraint.length(1))
+ .split(inner);
+ int lineWidth = hChunks.get(0).width();
+
// Build visible lines, inserting divider at the right display position
List<Line> lines = new ArrayList<>();
int displayRow = 0;
@@ -167,15 +173,11 @@ class ConfigurationTab implements MonitorTab {
}
}
if (displayRow >= scrollOffset) {
- lines.add(renderProperty(props.get(i), keyWidth));
+ lines.add(renderProperty(props.get(i), keyWidth, lineWidth));
}
displayRow++;
}
- List<Rect> hChunks = Layout.horizontal()
- .constraints(Constraint.fill(), Constraint.length(1))
- .split(inner);
-
frame.renderWidget(Paragraph.builder().text(Text.from(lines)).build(),
hChunks.get(0));
if (totalLines > visibleLines) {
@@ -187,7 +189,7 @@ class ConfigurationTab implements MonitorTab {
}
}
- private Line renderProperty(ConfigProperty prop, int keyWidth) {
+ private Line renderProperty(ConfigProperty prop, int keyWidth, int
lineWidth) {
String key = prop.key;
if (key.length() > keyWidth) {
key = key.substring(0, keyWidth - 1) + "…";
@@ -200,10 +202,15 @@ class ConfigurationTab implements MonitorTab {
String value = prop.value != null ? prop.value : "";
List<Span> spans = new ArrayList<>();
- spans.add(Span.styled(" " + key + " ", KEY_STYLE));
+ String keyPart = " " + key + " ";
+ spans.add(Span.styled(keyPart, KEY_STYLE));
spans.add(Span.styled(value, valStyle));
if (prop.source != null && !prop.source.isEmpty()) {
- spans.add(Span.styled(" [" + prop.source + "]", SOURCE_STYLE));
+ String displaySource = FileUtil.stripPath(prop.source);
+ String sourceText = "[" + displaySource + "]";
+ int used = keyPart.length() + value.length();
+ int gap = Math.max(2, lineWidth - used - sourceText.length());
+ spans.add(Span.styled(" ".repeat(gap) + sourceText, SOURCE_STYLE));
}
return Line.from(spans);
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LogTab.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LogTab.java
index c2ccdf95e6e1..bb2bb659dfb9 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LogTab.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LogTab.java
@@ -38,10 +38,13 @@ import dev.tamboui.text.CharWidth;
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.Title;
+import dev.tamboui.widgets.input.TextInputState;
import dev.tamboui.widgets.list.ListItem;
import dev.tamboui.widgets.list.ListState;
import dev.tamboui.widgets.list.ListWidget;
@@ -70,7 +73,10 @@ class LogTab implements MonitorTab {
private final ListState logLevelListState = new ListState();
volatile List<LogEntry> filteredLogEntries = new ArrayList<>();
+ volatile boolean logLoading;
+ volatile boolean loadAllRequested;
long logFilePos = -1;
+ long logFileStartPos = -1;
long logTotalLinesRead;
String logFilePid;
final StringBuilder logLineBuffer = new StringBuilder();
@@ -80,19 +86,55 @@ class LogTab implements MonitorTab {
private int cachedLogHSkip = -1;
private int cachedLogMaxWidth;
private List<Line> cachedLogLines = Collections.emptyList();
- private int scroll;
+ int scroll;
private long evictedSeen;
- private boolean followMode = true;
+ boolean followMode = true;
private boolean wordWrap = true;
private int hScroll;
private boolean showLogLevelPopup;
+ // Highlight mode: persistent keyword highlighting
+ private String highlightTerm;
+ private Pattern highlightPattern;
+
+ // Find mode: search with next/prev navigation
+ private boolean findInputActive;
+ private boolean highlightInputActive;
+ private TextInputState searchInputState = new TextInputState("");
+ private String findTerm;
+ private Pattern findPattern;
+ private int findMatchIndex = -1;
+ private List<Integer> findMatches = Collections.emptyList();
+
+ private static final Style HIGHLIGHT_STYLE =
Style.EMPTY.fg(Color.BLACK).bg(Color.YELLOW);
+ private static final Style FIND_MATCH_STYLE =
Style.EMPTY.fg(Color.BLACK).bg(Color.YELLOW);
+ private static final Style FIND_CURRENT_STYLE =
Style.EMPTY.fg(Color.BLACK).bg(Color.LIGHT_GREEN);
+
LogTab(MonitorContext ctx) {
this.ctx = ctx;
}
+ @Override
+ public void onIntegrationChanged() {
+ filteredLogEntries = new ArrayList<>();
+ logLoading = true;
+ loadAllRequested = false;
+ logFilePid = null;
+ logFilePos = -1;
+ logFileStartPos = -1;
+ logTotalLinesRead = 0;
+ logLineBuffer.setLength(0);
+ mutableFilteredEntries.clear();
+ cachedLogEntries = null;
+ cachedLogLines = Collections.emptyList();
+ }
+
@Override
public boolean handleKeyEvent(KeyEvent ke) {
+ if (findInputActive || highlightInputActive) {
+ return handleSearchInput(ke);
+ }
+
if (showLogLevelPopup) {
if (ke.isUp()) {
logLevelListState.selectPrevious();
@@ -113,6 +155,24 @@ class LogTab implements MonitorTab {
return true;
}
+ if (ke.isChar('/')) {
+ findInputActive = true;
+ searchInputState = new TextInputState("");
+ return true;
+ }
+ if (ke.isChar('h')) {
+ highlightInputActive = true;
+ searchInputState = new TextInputState("");
+ return true;
+ }
+ if (ke.isChar('n') && findTerm != null) {
+ navigateToNextMatch();
+ return true;
+ }
+ if (ke.isChar('N') && findTerm != null) {
+ navigateToPrevMatch();
+ return true;
+ }
if (ke.isChar('l') && !ctx.isInfraSelected()) {
showLogLevelPopup = true;
logLevelListState.select(2);
@@ -151,6 +211,7 @@ class LogTab implements MonitorTab {
followMode = false;
scroll = 0;
hScroll = 0;
+ loadAllRequested = true;
return true;
}
if (ke.isEnd()) {
@@ -160,12 +221,61 @@ class LogTab implements MonitorTab {
return false;
}
+ private boolean handleSearchInput(KeyEvent ke) {
+ if (ke.isKey(KeyCode.ESCAPE)) {
+ findInputActive = false;
+ highlightInputActive = false;
+ return true;
+ }
+ if (ke.isConfirm()) {
+ String text = searchInputState.text().trim();
+ if (findInputActive) {
+ if (text.isEmpty()) {
+ findTerm = null;
+ findPattern = null;
+ findMatches = Collections.emptyList();
+ findMatchIndex = -1;
+ } else {
+ findTerm = text;
+ findPattern = Pattern.compile(Pattern.quote(text),
Pattern.CASE_INSENSITIVE);
+ buildFindMatches();
+ jumpToNearestMatch();
+ }
+ findInputActive = false;
+ } else if (highlightInputActive) {
+ if (text.isEmpty()) {
+ highlightTerm = null;
+ highlightPattern = null;
+ } else {
+ highlightTerm = text;
+ highlightPattern = Pattern.compile(Pattern.quote(text),
Pattern.CASE_INSENSITIVE);
+ }
+ highlightInputActive = false;
+ }
+ return true;
+ }
+ FormHelper.handleTextInput(ke, searchInputState);
+ return true;
+ }
+
@Override
public boolean handleEscape() {
+ if (findInputActive || highlightInputActive) {
+ findInputActive = false;
+ highlightInputActive = false;
+ return true;
+ }
if (showLogLevelPopup) {
showLogLevelPopup = false;
return true;
}
+ if (findTerm != null) {
+ findTerm = null;
+ findPattern = null;
+ findMatches = Collections.emptyList();
+ findMatchIndex = -1;
+ return true;
+ }
return false;
}
@@ -202,24 +312,44 @@ class LogTab implements MonitorTab {
return;
}
+ if (logLoading && filteredLogEntries.isEmpty()) {
+ Block loadingBlock = Block.builder()
+ .borderType(BorderType.ROUNDED)
+ .title(" Log ")
+ .build();
+ frame.renderWidget(loadingBlock, area);
+ Rect loadingInner = loadingBlock.inner(area);
+ frame.renderWidget(
+
Paragraph.builder().text(Text.from(Line.from(Span.raw("(Loading logs...)"))))
+ .build(),
+ loadingInner);
+ return;
+ }
+
List<LogEntry> entries = filteredLogEntries;
int contentHeight = entries.size();
- long totalRead = logTotalLinesRead;
- String chunkSuffix = totalRead > entries.size()
- ? " #" + (totalRead - entries.size() + 1) + "-" + totalRead
- : "";
- String logTitle;
+ boolean hasNew = !followMode && scroll < contentHeight - Math.max(1,
area.height() - 2);
+ String logLabel;
if (infraSel != null) {
- logTitle = " Log [" + infraSel.alias + "]" + chunkSuffix + " ";
+ logLabel = " Log [" + infraSel.alias + "]";
} else if (info != null && info.rootLogLevel != null) {
- logTitle = " Log level:" + info.rootLogLevel + chunkSuffix + " ";
+ logLabel = " Log level:" + info.rootLogLevel;
} else {
- logTitle = " Log" + chunkSuffix + " ";
+ logLabel = " Log";
+ }
+ Line titleLine;
+ if (hasNew) {
+ titleLine = Line.from(
+ Span.raw(logLabel + " "),
+ Span.styled("(*)", Style.EMPTY.fg(Color.YELLOW).bold()),
+ Span.raw(" "));
+ } else {
+ titleLine = Line.from(Span.raw(logLabel + " "));
}
Block block = Block.builder()
.borderType(BorderType.ROUNDED)
- .title(logTitle)
+ .title(Title.from(titleLine))
.build();
frame.renderWidget(block, area);
@@ -239,13 +369,14 @@ class LogTab implements MonitorTab {
int hSkip = wordWrap ? 0 : hScroll;
- if (entries != cachedLogEntries || hSkip != cachedLogHSkip) {
+ boolean entriesChanged = entries != cachedLogEntries;
+ if (entriesChanged || hSkip != cachedLogHSkip) {
cachedLogEntries = entries;
cachedLogHSkip = hSkip;
List<Line> built = new ArrayList<>(entries.size());
int maxW = 0;
- for (LogEntry entry : entries) {
- String raw = entry.raw != null ? entry.raw : "";
+ for (int i = 0; i < entries.size(); i++) {
+ String raw = entries.get(i).raw != null ? entries.get(i).raw :
"";
if (!wordWrap) {
maxW = Math.max(maxW,
CharWidth.of(TuiHelper.stripAnsi(raw)));
}
@@ -260,10 +391,25 @@ class LogTab implements MonitorTab {
hScroll = Math.min(hScroll, Math.max(0, cachedLogMaxWidth -
visibleWidth));
}
+ if (findPattern != null && entriesChanged) {
+ buildFindMatches();
+ }
+
List<Line> allLines = cachedLogLines;
int start = Math.min(scroll, Math.max(0, allLines.size() -
visibleHeight));
List<Line> visibleLines = allLines.subList(start,
Math.min(allLines.size(), start + visibleHeight));
+ // Apply highlights only to visible lines
+ if (highlightPattern != null || findPattern != null) {
+ int currentMatchLine = findMatchIndex >= 0 && findMatchIndex <
findMatches.size()
+ ? findMatches.get(findMatchIndex) : -1;
+ List<Line> highlighted = new ArrayList<>(visibleLines.size());
+ for (int i = 0; i < visibleLines.size(); i++) {
+ highlighted.add(applyHighlights(visibleLines.get(i), start +
i, currentMatchLine));
+ }
+ visibleLines = highlighted;
+ }
+
List<Rect> hChunks = Layout.horizontal()
.constraints(Constraint.fill(), Constraint.length(1))
.split(inner);
@@ -289,20 +435,43 @@ class LogTab implements MonitorTab {
@Override
public void renderFooter(List<Span> spans) {
+ if (findInputActive) {
+ spans.add(Span.styled(" /", HINT_KEY_STYLE));
+ spans.add(Span.raw(searchInputState.text() + "█ "));
+ hint(spans, "Enter", "search");
+ hintLast(spans, "Esc", "cancel");
+ return;
+ }
+ if (highlightInputActive) {
+ spans.add(Span.styled(" h:", HINT_KEY_STYLE));
+ spans.add(Span.raw(searchInputState.text() + "█ "));
+ hint(spans, "Enter", "set");
+ hintLast(spans, "Esc", "cancel");
+ return;
+ }
if (showLogLevelPopup) {
hint(spans, "↑↓", "navigate");
hint(spans, "Enter", "set level");
hintLast(spans, "Esc", "cancel");
return;
}
- hint(spans, "Esc", "back");
+
+ if (findTerm != null) {
+ hint(spans, "Esc", "clear find");
+ hint(spans, "n", "next");
+ hint(spans, "N", "prev");
+ String pos = findMatches.isEmpty()
+ ? "0/0"
+ : (findMatchIndex + 1) + "/" + findMatches.size();
+ spans.add(Span.styled(" /", HINT_KEY_STYLE));
+ spans.add(Span.raw("\"" + findTerm + "\" [" + pos + "] "));
+ } else {
+ hint(spans, "Esc", "back");
+ }
hint(spans, "↑↓", "scroll");
- hint(spans, "PgUp/PgDn", "page");
- hint(spans, "Home/End", "top/end");
+ hint(spans, "/", "find");
+ hint(spans, "h", "highlight" + (highlightTerm != null ? " [" +
highlightTerm + "]" : ""));
hint(spans, "w", "wrap" + (wordWrap ? " [on]" : " [off]"));
- if (!wordWrap) {
- hint(spans, "←→", "h-scroll");
- }
if (!ctx.isInfraSelected()) {
hint(spans, "l", "level");
}
@@ -347,6 +516,130 @@ class LogTab implements MonitorTab {
org.apache.camel.dsl.jbang.core.common.PathUtils.writeTextSafely(root.toJson(),
actionFile);
}
+ private void buildFindMatches() {
+ List<Integer> matches = new ArrayList<>();
+ List<LogEntry> entries = filteredLogEntries;
+ for (int i = 0; i < entries.size(); i++) {
+ String plain = TuiHelper.stripAnsi(entries.get(i).raw != null ?
entries.get(i).raw : "");
+ if (findPattern.matcher(plain).find()) {
+ matches.add(i);
+ }
+ }
+ findMatches = matches;
+ }
+
+ private void jumpToNearestMatch() {
+ if (findMatches.isEmpty()) {
+ findMatchIndex = -1;
+ return;
+ }
+ for (int i = 0; i < findMatches.size(); i++) {
+ if (findMatches.get(i) >= scroll) {
+ findMatchIndex = i;
+ scrollToMatch();
+ return;
+ }
+ }
+ findMatchIndex = 0;
+ scrollToMatch();
+ }
+
+ private void navigateToNextMatch() {
+ if (findMatches.isEmpty()) {
+ return;
+ }
+ findMatchIndex = (findMatchIndex + 1) % findMatches.size();
+ scrollToMatch();
+ }
+
+ private void navigateToPrevMatch() {
+ if (findMatches.isEmpty()) {
+ return;
+ }
+ findMatchIndex = findMatchIndex <= 0 ? findMatches.size() - 1 :
findMatchIndex - 1;
+ scrollToMatch();
+ }
+
+ private void scrollToMatch() {
+ if (findMatchIndex >= 0 && findMatchIndex < findMatches.size()) {
+ followMode = false;
+ scroll = findMatches.get(findMatchIndex);
+ }
+ }
+
+ boolean isSearchInputActive() {
+ return findInputActive || highlightInputActive;
+ }
+
+ void handlePaste(String text) {
+ if (findInputActive || highlightInputActive) {
+ FormHelper.handlePaste(text, searchInputState);
+ }
+ }
+
+ private Line applyHighlights(Line line, int entryIndex, int
currentMatchLine) {
+ String fullText = line.rawContent();
+ if (fullText.isEmpty()) {
+ return line;
+ }
+
+ // Collect all match ranges with their styles
+ List<int[]> ranges = new ArrayList<>();
+ List<Style> styles = new ArrayList<>();
+ if (highlightPattern != null) {
+ Matcher m = highlightPattern.matcher(fullText);
+ while (m.find()) {
+ ranges.add(new int[] { m.start(), m.end() });
+ styles.add(HIGHLIGHT_STYLE);
+ }
+ }
+ if (findPattern != null) {
+ boolean isCurrentLine = entryIndex == currentMatchLine;
+ Matcher m = findPattern.matcher(fullText);
+ while (m.find()) {
+ ranges.add(new int[] { m.start(), m.end() });
+ styles.add(isCurrentLine ? FIND_CURRENT_STYLE :
FIND_MATCH_STYLE);
+ }
+ }
+ if (ranges.isEmpty()) {
+ return line;
+ }
+
+ // Rebuild spans with highlights applied
+ List<Span> original = line.spans();
+ List<Span> result = new ArrayList<>();
+ int charPos = 0;
+
+ for (Span span : original) {
+ String content = span.content();
+ Style baseStyle = span.style();
+ int spanStart = charPos;
+ int spanEnd = charPos + content.length();
+ int cursor = 0;
+
+ for (int r = 0; r < ranges.size(); r++) {
+ int matchStart = ranges.get(r)[0];
+ int matchEnd = ranges.get(r)[1];
+ if (matchEnd <= spanStart || matchStart >= spanEnd) {
+ continue;
+ }
+ int localStart = Math.max(0, matchStart - spanStart);
+ int localEnd = Math.min(content.length(), matchEnd -
spanStart);
+
+ if (localStart > cursor) {
+ result.add(Span.styled(content.substring(cursor,
localStart), baseStyle));
+ }
+ result.add(Span.styled(content.substring(localStart,
localEnd), styles.get(r)));
+ cursor = localEnd;
+ }
+ if (cursor < content.length()) {
+ result.add(Span.styled(content.substring(cursor), baseStyle));
+ }
+ charPos = spanEnd;
+ }
+ return Line.from(result);
+ }
+
void readNewLogLines(String pid, List<String> newLines) {
readNewLogLinesFromFile(pid, pid + ".log", newLines);
}
@@ -362,14 +655,17 @@ class LogTab implements MonitorTab {
try (RandomAccessFile raf = new RandomAccessFile(logFile.toFile(),
"r")) {
long length = raf.length();
if (logFilePos < 0 || logFilePos > length) {
- logFilePos = Math.max(0, length - 1024 * 1024);
+ // Initial load: only read last 8KB for fast first render
+ logFilePos = Math.max(0, length - 8 * 1024);
+ logFileStartPos = logFilePos;
logLineBuffer.setLength(0);
}
if (logFilePos >= length) {
return;
}
raf.seek(logFilePos);
- byte[] buf = new byte[(int) Math.min(length - logFilePos, 4 * 1024
* 1024)];
+ // Cap per-tick read to 64KB to avoid blocking the refresh cycle
+ byte[] buf = new byte[(int) Math.min(length - logFilePos, 64 *
1024)];
raf.readFully(buf);
logFilePos += buf.length;
@@ -392,6 +688,53 @@ class LogTab implements MonitorTab {
}
}
+ void readOlderLogLines(String fileName, boolean loadAll, List<String>
olderLines) {
+ if (logFilePid == null || logFileStartPos <= 0) {
+ return;
+ }
+ Path logFile = CommandLineHelper.getCamelDir().resolve(fileName);
+ if (!Files.exists(logFile)) {
+ return;
+ }
+ try (RandomAccessFile raf = new RandomAccessFile(logFile.toFile(),
"r")) {
+ long readEnd = logFileStartPos;
+ // Load all: read from start of file (cap at 2MB), otherwise 64KB
chunk
+ long readStart;
+ if (loadAll) {
+ readStart = Math.max(0, readEnd - 2 * 1024 * 1024);
+ } else {
+ readStart = Math.max(0, readEnd - 64 * 1024);
+ }
+ if (readStart >= readEnd) {
+ return;
+ }
+ raf.seek(readStart);
+ byte[] buf = new byte[(int) (readEnd - readStart)];
+ raf.readFully(buf);
+ logFileStartPos = readStart;
+
+ String chunk = new String(buf, StandardCharsets.UTF_8);
+ // If we didn't read from the start of the file, skip the first
partial line
+ int start = 0;
+ if (readStart > 0) {
+ int firstNewline = chunk.indexOf('\n');
+ if (firstNewline >= 0) {
+ start = firstNewline + 1;
+ }
+ }
+ int end;
+ while ((end = chunk.indexOf('\n', start)) >= 0) {
+ String line = TuiHelper.fixControlChars(chunk.substring(start,
end));
+ if (!line.isEmpty()) {
+ olderLines.add(line);
+ }
+ start = end + 1;
+ }
+ } catch (IOException e) {
+ // ignore
+ }
+ }
+
static LogEntry parseLogLine(String line) {
LogEntry entry = new LogEntry();
entry.raw = line;
@@ -466,6 +809,23 @@ class LogTab implements MonitorTab {
Filtering is useful when the log is noisy with INFO messages
and you
want to focus on warnings and errors.
+ ## Find and Highlight
+
+ **Find** (`/`) — search for text in the log. Type a search
term and
+ press Enter to jump to the first match. Use `n` to go to the
next
+ match and `N` for the previous match. The current match is
shown
+ with a green background, other matches with yellow. Press `Esc`
+ to clear the search.
+
+ **Highlight** (`h`) — persistently highlight all occurrences
of a
+ word in the log. Type a word and press Enter — all occurrences
+ are highlighted with a yellow background while the log
continues
+ scrolling in follow mode. Press `h` again and submit an empty
+ term to clear the highlight. Both find and highlight can be
+ active at the same time.
+
+ Both find and highlight are case-insensitive.
+
## Thread Names
The thread name in square brackets (e.g., `[Camel (camel-demo)
thread #2]`)
@@ -477,8 +837,14 @@ class LogTab implements MonitorTab {
- `Up/Down` — scroll log
- `PgUp/PgDn` — scroll by page
- `Home/End` — jump to beginning/end of log
+ - `/` — find (search for text)
+ - `n` — next match
+ - `N` — previous match
+ - `h` — highlight a word
- `l` — change log level filter
- - `Esc` — back
+ - `f` — toggle follow mode
+ - `w` — toggle word wrap
+ - `Esc` — clear find / back
""";
}
}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OverviewTab.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OverviewTab.java
index 4d6bd5f034ca..505627de6223 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OverviewTab.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OverviewTab.java
@@ -190,7 +190,11 @@ class OverviewTab implements MonitorTab {
Cell.from(Span.styled("", dimStyle)),
Cell.from(Span.styled("", dimStyle))));
} else {
- Style statusStyle = switch (extractState(info.state)) {
+ String stateText = extractState(info.state);
+ if ("Running".equals(stateText) && info.routeStarted == 0 &&
info.routeTotal > 0) {
+ stateText = "Stopped";
+ }
+ Style statusStyle = switch (stateText) {
case "Started", "Running" -> Style.EMPTY.fg(Color.GREEN);
case "Stopped" -> Style.EMPTY.fg(Color.LIGHT_RED);
default -> Style.EMPTY.fg(Color.YELLOW);
@@ -211,7 +215,7 @@ class OverviewTab implements MonitorTab {
Cell.from(nameLine),
Cell.from(info.camelVersion != null ?
info.camelVersion : ""),
centerCell(info.ready != null ? info.ready : "", 5),
- Cell.from(Span.styled(extractState(info.state),
statusStyle)),
+ Cell.from(Span.styled(stateText, statusStyle)),
Cell.from(info.ago != null ? info.ago : ""),
rightCell(info.routeStarted + "/" + info.routeTotal,
7),
rightCell(info.throughput != null ? info.throughput :
"", 8),
@@ -724,7 +728,7 @@ class OverviewTab implements MonitorTab {
- **TOTAL** — Total number of exchanges (messages) processed
since the integration started
- **FAIL** — Number of exchanges that ended with an unhandled
error
- **INFLIGHT** — Exchanges currently being processed right
now. A consistently high inflight count may indicate slow downstream services
- - **SINCE-LAST** — Time elapsed since the last exchange was
processed. A long idle time might indicate that consumers have stopped
receiving data
+ - **SINCE-LAST** — Time since the last exchange activity,
shown as up to three values separated by `/`: started/completed/failed. For
example, `1s/3s/1m14s` means the last exchange started 1s ago, the last
completed 3s ago, and the last failure was 1m14s ago. Values are omitted when
there is no activity of that type
## Example Screen
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RoutesTab.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RoutesTab.java
index 21584f5e1d80..766c43e966a8 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RoutesTab.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RoutesTab.java
@@ -44,6 +44,8 @@ import dev.tamboui.widgets.table.Row;
import dev.tamboui.widgets.table.Table;
import dev.tamboui.widgets.table.TableState;
import org.apache.camel.dsl.jbang.core.common.PathUtils;
+import org.apache.camel.support.LoggerHelper;
+import org.apache.camel.util.FileUtil;
import org.apache.camel.util.json.JsonArray;
import org.apache.camel.util.json.JsonObject;
import org.apache.camel.util.json.Jsoner;
@@ -81,6 +83,7 @@ class RoutesTab implements MonitorTab {
private boolean showSource;
private List<String> sourceLines = Collections.emptyList();
private String sourceTitle;
+ private SyntaxHighlighter.Language sourceLanguage =
SyntaxHighlighter.Language.PLAIN;
private int sourceScroll;
private int sourceScrollX;
private final ScrollbarState sourceVScrollState = new ScrollbarState();
@@ -480,7 +483,7 @@ class RoutesTab implements MonitorTab {
hint(spans, "c", "source");
hint(spans, "d", "diagram");
hint(spans, "D", "text diagram");
- hint(spans, "a", diagramAllRoutes ? "all [on]" : "all [off]");
+ hint(spans, "a", "diagram " + (diagramAllRoutes ? "[all]" :
"[single]"));
String routeState = selectedRouteState();
boolean supSus = selectedRouteSupportsSuspension();
if ("Started".equals(routeState)) {
@@ -732,7 +735,7 @@ class RoutesTab implements MonitorTab {
List<Line> visible = new ArrayList<>();
for (int i = sourceScroll; i < end; i++) {
String raw = sourceLines.get(i);
- visible.add(TuiHelper.ansiToLine(raw, sourceScrollX));
+ visible.add(highlightSourceLine(raw, sourceScrollX));
}
frame.renderWidget(Paragraph.builder().text(Text.from(visible)).build(), inner);
@@ -748,6 +751,48 @@ class RoutesTab implements MonitorTab {
}
}
+ private Line highlightSourceLine(String raw, int hSkip) {
+ // Split line number prefix from code content
+ int prefixEnd = 0;
+ while (prefixEnd < raw.length() && (raw.charAt(prefixEnd) == ' ' ||
Character.isDigit(raw.charAt(prefixEnd)))) {
+ prefixEnd++;
+ }
+
+ String prefix = raw.substring(0, prefixEnd);
+ String code = raw.substring(prefixEnd);
+
+ Line highlighted = SyntaxHighlighter.highlightLine(code,
sourceLanguage);
+
+ // Prepend dim line-number prefix
+ List<Span> spans = new ArrayList<>();
+ if (!prefix.isEmpty()) {
+ spans.add(Span.styled(prefix, Style.EMPTY.dim()));
+ }
+ spans.addAll(highlighted.spans());
+
+ Line full = Line.from(spans);
+
+ // Apply horizontal scroll by skipping characters from spans
+ if (hSkip <= 0) {
+ return full;
+ }
+ List<Span> scrolled = new ArrayList<>();
+ int skipped = 0;
+ for (Span span : full.spans()) {
+ String content = span.content();
+ if (skipped >= hSkip) {
+ scrolled.add(span);
+ } else if (skipped + content.length() > hSkip) {
+ int offset = hSkip - skipped;
+ scrolled.add(Span.styled(content.substring(offset),
span.style()));
+ skipped = hSkip;
+ } else {
+ skipped += content.length();
+ }
+ }
+ return scrolled.isEmpty() ? Line.from(List.of(Span.raw(""))) :
Line.from(scrolled);
+ }
+
// ---- Sorting ----
private int sortRoute(RouteInfo a, RouteInfo b) {
@@ -1065,7 +1110,12 @@ class RoutesTab implements MonitorTab {
idx++;
}
- int scrollTo = matchLine > 0 ? Math.max(0, matchLine - 2) : 0;
+ int scrollTo;
+ if (matchLine > 0) {
+ scrollTo = Math.max(0, matchLine - 2);
+ } else {
+ scrollTo = findLicenseHeaderEnd(codeLines);
+ }
applySourceResult(routeId, sourceLocation, lines, scrollTo);
}
@@ -1081,12 +1131,48 @@ class RoutesTab implements MonitorTab {
if (!showSource) {
return;
}
- sourceTitle = location != null ? routeId + " " + location :
routeId;
+ String displayLoc = location != null ?
FileUtil.stripPath(LoggerHelper.sourceNameOnly(location)) : null;
+ sourceTitle = displayLoc != null ? routeId + " " + displayLoc :
routeId;
+ sourceLanguage = SyntaxHighlighter.detectLanguage(location);
sourceLines = lines;
sourceScroll = scrollTo;
});
}
+ private static int findLicenseHeaderEnd(List<JsonObject> codeLines) {
+ // Auto-scroll past leading license/comment headers
+ boolean inBlock = false;
+ int lastCommentLine = -1;
+ for (int i = 0; i < codeLines.size(); i++) {
+ String code = objToString(codeLines.get(i).get("code")).trim();
+ if (i == 0 && code.isEmpty()) {
+ continue;
+ }
+ if (!inBlock && code.startsWith("/*")) {
+ inBlock = true;
+ }
+ if (inBlock) {
+ lastCommentLine = i;
+ if (code.contains("*/")) {
+ inBlock = false;
+ }
+ continue;
+ }
+ // YAML/shell comment lines or XML comment lines at the top
+ if (code.startsWith("#") || code.startsWith("##") ||
code.startsWith("<!--")) {
+ lastCommentLine = i;
+ continue;
+ }
+ // Empty line right after comment block
+ if (lastCommentLine >= 0 && code.isEmpty()) {
+ lastCommentLine = i;
+ continue;
+ }
+ break;
+ }
+ return lastCommentLine >= 0 ? lastCommentLine + 1 : 0;
+ }
+
private static String objToString(Object o) {
return o != null ? o.toString() : "";
}
@@ -1126,7 +1212,7 @@ class RoutesTab implements MonitorTab {
- **MIN** — Fastest exchange processing time in milliseconds.
This is the time from when the exchange entered the route until it completed
- **MEAN** — Average exchange processing time in milliseconds.
A rising MEAN may indicate a downstream service getting slower
- **MAX** — Slowest exchange processing time in milliseconds.
A very high MAX compared to MEAN suggests occasional slow outliers
- - **SINCE-LAST** — Time since the last exchange was processed
by this route
+ - **SINCE-LAST** — Time since the last exchange activity on
this route, shown as up to three values separated by `/`:
started/completed/failed (e.g., `1s/3s/1m14s`). Values are omitted when there
is no activity of that type
## Example Screen
@@ -1182,11 +1268,16 @@ class RoutesTab implements MonitorTab {
## Keys
- `Up/Down` — select route
+ - `p` — start/stop selected route
+ - `P` — suspend/resume selected route
- `d` — show route diagram
- - `s` — show route source / cycle sort column
(context-dependent)
+ - `D` — show text diagram
+ - `a` — toggle diagram scope (single route or all routes)
+ - `c` — show route source code
+ - `m` — toggle metrics in diagram
+ - `s` — cycle sort column
- `S` — reverse sort order
- `t` — toggle Top mode
- - `Enter` — view detailed route info
- `Esc` — back to route list
""";
}
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 62e12ecdbc20..c481f6b030cd 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
@@ -81,11 +81,15 @@ class RunOptionsForm {
}
void open(String defaultName, String exampleName, boolean bundled) {
+ open(defaultName, exampleName, bundled, false);
+ }
+
+ void open(String defaultName, String exampleName, boolean bundled, boolean
dev) {
nameInput = new TextInputState(defaultName != null ? defaultName : "");
portInput = new TextInputState("");
maxInput = new TextInputState("");
maxMode = 0;
- devMode = false;
+ devMode = dev;
observe = false;
backlogTrace = false;
selectedRow = ROW_NAME;
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SyntaxHighlighter.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SyntaxHighlighter.java
new file mode 100644
index 000000000000..dbd3b68ccdd2
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SyntaxHighlighter.java
@@ -0,0 +1,291 @@
+/*
+ * 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 java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import dev.tamboui.style.Color;
+import dev.tamboui.style.Style;
+import dev.tamboui.text.Line;
+import dev.tamboui.text.Span;
+import org.apache.camel.util.FileUtil;
+
+class SyntaxHighlighter {
+
+ enum Language {
+ JAVA,
+ YAML,
+ XML,
+ PLAIN
+ }
+
+ // Java patterns (ordered by priority — comments first)
+ private static final Pattern JAVA_LINE_COMMENT = Pattern.compile("//.*");
+ private static final Pattern JAVA_STRING =
Pattern.compile("\"(?:[^\"\\\\]|\\\\.)*\"");
+ private static final Pattern JAVA_ANNOTATION = Pattern.compile("@\\w+");
+ private static final Pattern JAVA_MODIFIER = Pattern.compile(
+
"\\b(abstract|class|extends|final|implements|import|instanceof|interface|native|package|private|protected|public|static|strictfp|super|synchronized|throws|volatile|enum|record|sealed|permits|non-sealed)\\b");
+ private static final Pattern JAVA_KEYWORD = Pattern.compile(
+
"\\b(break|case|catch|continue|default|do|else|finally|for|if|return|switch|throw|try|while|yield|var)\\b");
+ private static final Pattern JAVA_TYPE = Pattern.compile(
+
"\\b(boolean|byte|char|double|float|int|long|new|short|this|transient|void)\\b");
+ private static final Pattern JAVA_BOOLEAN_NULL =
Pattern.compile("\\b(true|false|null)\\b");
+ private static final Pattern JAVA_NUMBER =
Pattern.compile("\\b\\d+\\.?\\d*[fFdDlL]?\\b");
+
+ // YAML patterns
+ private static final Pattern YAML_COMMENT = Pattern.compile("(^|\\s)#.*$");
+ private static final Pattern YAML_KEY =
Pattern.compile("^(\\s*-?\\s*)([\\w./${}\\-]+)\\s*:");
+ private static final Pattern YAML_BOOLEAN_NULL =
Pattern.compile(":\\s+(true|false|null)\\s*$");
+ private static final Pattern YAML_NUMBER =
Pattern.compile(":\\s+(\\d+\\.?\\d*)\\s*$");
+ private static final Pattern YAML_STRING_VALUE =
Pattern.compile("\"(?:[^\"\\\\]|\\\\.)*\"|'[^']*'");
+
+ // XML patterns
+ private static final Pattern XML_COMMENT = Pattern.compile("<!--.*?-->");
+ private static final Pattern XML_OPEN_TAG =
Pattern.compile("</?[\\w:.-]+");
+ private static final Pattern XML_CLOSE_BRACKET = Pattern.compile("/?>|>");
+ private static final Pattern XML_ATTR_VALUE =
Pattern.compile("=\"[^\"]*\"");
+ private static final Pattern XML_ATTR_NAME =
Pattern.compile("\\s([\\w:.-]+)=");
+ private static final Pattern XML_ENTITY = Pattern.compile("&[^;]+;");
+
+ // Java styles
+ private static final Style JAVA_COMMENT_STYLE =
Style.EMPTY.fg(Color.LIGHT_BLUE);
+ private static final Style JAVA_STRING_STYLE = Style.EMPTY.fg(Color.RED);
+ private static final Style JAVA_ANNOTATION_STYLE =
Style.EMPTY.fg(Color.MAGENTA);
+ private static final Style JAVA_MODIFIER_STYLE =
Style.EMPTY.fg(Color.CYAN);
+ private static final Style JAVA_KEYWORD_STYLE = Style.EMPTY.fg(Color.RED);
+ private static final Style JAVA_TYPE_STYLE = Style.EMPTY.fg(Color.GREEN);
+ private static final Style JAVA_BOOLEAN_STYLE =
Style.EMPTY.fg(Color.YELLOW);
+ private static final Style JAVA_NUMBER_STYLE =
Style.EMPTY.fg(Color.YELLOW);
+
+ // YAML styles
+ private static final Style YAML_COMMENT_STYLE =
Style.EMPTY.fg(Color.LIGHT_BLUE);
+ private static final Style YAML_KEY_STYLE = Style.EMPTY.fg(Color.RED);
+ private static final Style YAML_VALUE_STYLE = Style.EMPTY.fg(Color.GREEN);
+ private static final Style YAML_SPECIAL_STYLE =
Style.EMPTY.fg(Color.YELLOW);
+ private static final Style YAML_SEPARATOR_STYLE =
Style.EMPTY.fg(Color.WHITE).bold();
+
+ // XML styles
+ private static final Style XML_COMMENT_STYLE =
Style.EMPTY.fg(Color.YELLOW);
+ private static final Style XML_TAG_STYLE = Style.EMPTY.fg(Color.CYAN);
+ private static final Style XML_ATTR_NAME_STYLE =
Style.EMPTY.fg(Color.MAGENTA);
+ private static final Style XML_ATTR_VALUE_STYLE =
Style.EMPTY.fg(Color.GREEN);
+ private static final Style XML_ENTITY_STYLE = Style.EMPTY.fg(Color.RED);
+
+ private SyntaxHighlighter() {
+ }
+
+ static Language detectLanguage(String filename) {
+ if (filename == null || filename.isEmpty()) {
+ return Language.PLAIN;
+ }
+ // Strip line number suffixes (e.g., "MyRoute.java:42")
+ String name = filename;
+ int colon = name.lastIndexOf(':');
+ if (colon > 0) {
+ String after = name.substring(colon + 1);
+ if (!after.isEmpty() &&
after.chars().allMatch(Character::isDigit)) {
+ name = name.substring(0, colon);
+ }
+ }
+ String ext = FileUtil.onlyExt(name);
+ if (ext == null) {
+ return Language.PLAIN;
+ }
+ ext = ext.toLowerCase();
+ return switch (ext) {
+ case "java" -> Language.JAVA;
+ case "yaml", "yml", "camel.yaml", "camel.yml" -> Language.YAML;
+ case "xml", "camel.xml" -> Language.XML;
+ default -> Language.PLAIN;
+ };
+ }
+
+ static Line highlightLine(String text, Language lang) {
+ if (text == null || text.isEmpty() || lang == Language.PLAIN) {
+ return Line.from(List.of(Span.raw(text != null ? text : "")));
+ }
+
+ return switch (lang) {
+ case JAVA -> highlightJava(text);
+ case YAML -> highlightYaml(text);
+ case XML -> highlightXml(text);
+ default -> Line.from(List.of(Span.raw(text)));
+ };
+ }
+
+ private static Line highlightJava(String text) {
+ int len = text.length();
+ Style[] charStyles = new Style[len];
+
+ // Priority order: comments > strings > annotations > keywords >
numbers
+ applyPattern(charStyles, text, JAVA_LINE_COMMENT, JAVA_COMMENT_STYLE);
+ applyPattern(charStyles, text, JAVA_STRING, JAVA_STRING_STYLE);
+ applyPattern(charStyles, text, JAVA_ANNOTATION, JAVA_ANNOTATION_STYLE);
+ applyPattern(charStyles, text, JAVA_MODIFIER, JAVA_MODIFIER_STYLE);
+ applyPattern(charStyles, text, JAVA_KEYWORD, JAVA_KEYWORD_STYLE);
+ applyPattern(charStyles, text, JAVA_TYPE, JAVA_TYPE_STYLE);
+ applyPattern(charStyles, text, JAVA_BOOLEAN_NULL, JAVA_BOOLEAN_STYLE);
+ applyPattern(charStyles, text, JAVA_NUMBER, JAVA_NUMBER_STYLE);
+
+ return buildLine(text, charStyles);
+ }
+
+ private static Line highlightYaml(String text) {
+ int len = text.length();
+ Style[] charStyles = new Style[len];
+
+ // Comments have highest priority
+ applyPattern(charStyles, text, YAML_COMMENT, YAML_COMMENT_STYLE);
+
+ // Key portion (before colon)
+ Matcher keyMatcher = YAML_KEY.matcher(text);
+ if (keyMatcher.find()) {
+ int keyStart = keyMatcher.start(2);
+ int keyEnd = keyMatcher.end(2);
+ for (int i = keyStart; i < keyEnd && i < len; i++) {
+ if (charStyles[i] == null) {
+ charStyles[i] = YAML_KEY_STYLE;
+ }
+ }
+ // Colon separator
+ int colonIdx = text.indexOf(':', keyEnd);
+ if (colonIdx >= 0 && colonIdx < len && charStyles[colonIdx] ==
null) {
+ charStyles[colonIdx] = YAML_SEPARATOR_STYLE;
+ }
+ }
+
+ // String values
+ applyPattern(charStyles, text, YAML_STRING_VALUE, YAML_VALUE_STYLE);
+
+ // Special values (boolean, null, numbers) after colon
+ applyPatternGroup(charStyles, text, YAML_BOOLEAN_NULL, 1,
YAML_SPECIAL_STYLE);
+ applyPatternGroup(charStyles, text, YAML_NUMBER, 1,
YAML_SPECIAL_STYLE);
+
+ // List markers
+ Matcher listMarker = Pattern.compile("^(\\s*)(-)(\\s)").matcher(text);
+ if (listMarker.find()) {
+ int dashIdx = listMarker.start(2);
+ if (dashIdx < len && charStyles[dashIdx] == null) {
+ charStyles[dashIdx] = YAML_SEPARATOR_STYLE;
+ }
+ }
+
+ // Value text (after colon+space, non-special, non-quoted)
+ int colonPos = text.indexOf(':');
+ if (colonPos >= 0 && colonPos + 1 < len) {
+ int valueStart = colonPos + 1;
+ while (valueStart < len && text.charAt(valueStart) == ' ') {
+ valueStart++;
+ }
+ if (valueStart < len) {
+ boolean hasSpecial = false;
+ for (int i = valueStart; i < len; i++) {
+ if (charStyles[i] != null) {
+ hasSpecial = true;
+ break;
+ }
+ }
+ if (!hasSpecial) {
+ for (int i = valueStart; i < len; i++) {
+ charStyles[i] = YAML_VALUE_STYLE;
+ }
+ }
+ }
+ }
+
+ return buildLine(text, charStyles);
+ }
+
+ private static Line highlightXml(String text) {
+ int len = text.length();
+ Style[] charStyles = new Style[len];
+
+ // Comments highest priority
+ applyPattern(charStyles, text, XML_COMMENT, XML_COMMENT_STYLE);
+
+ // Attribute values (before tag names so tags don't override)
+ applyPattern(charStyles, text, XML_ATTR_VALUE, XML_ATTR_VALUE_STYLE);
+
+ // Attribute names
+ Matcher attrMatcher = XML_ATTR_NAME.matcher(text);
+ while (attrMatcher.find()) {
+ int start = attrMatcher.start(1);
+ int end = attrMatcher.end(1);
+ for (int i = start; i < end; i++) {
+ if (charStyles[i] == null) {
+ charStyles[i] = XML_ATTR_NAME_STYLE;
+ }
+ }
+ }
+
+ // Tag names
+ applyPattern(charStyles, text, XML_OPEN_TAG, XML_TAG_STYLE);
+ applyPattern(charStyles, text, XML_CLOSE_BRACKET, XML_TAG_STYLE);
+
+ // Entity references
+ applyPattern(charStyles, text, XML_ENTITY, XML_ENTITY_STYLE);
+
+ return buildLine(text, charStyles);
+ }
+
+ private static void applyPattern(Style[] charStyles, String text, Pattern
pattern, Style style) {
+ Matcher m = pattern.matcher(text);
+ while (m.find()) {
+ for (int i = m.start(); i < m.end(); i++) {
+ if (charStyles[i] == null) {
+ charStyles[i] = style;
+ }
+ }
+ }
+ }
+
+ private static void applyPatternGroup(Style[] charStyles, String text,
Pattern pattern, int group, Style style) {
+ Matcher m = pattern.matcher(text);
+ while (m.find()) {
+ for (int i = m.start(group); i < m.end(group); i++) {
+ if (charStyles[i] == null) {
+ charStyles[i] = style;
+ }
+ }
+ }
+ }
+
+ private static Line buildLine(String text, Style[] charStyles) {
+ List<Span> spans = new ArrayList<>();
+ int len = text.length();
+ int i = 0;
+
+ while (i < len) {
+ Style current = charStyles[i];
+ int start = i;
+ while (i < len && charStyles[i] == current) {
+ i++;
+ }
+ String segment = text.substring(start, i);
+ if (current != null) {
+ spans.add(Span.styled(segment, current));
+ } else {
+ spans.add(Span.raw(segment));
+ }
+ }
+
+ return spans.isEmpty() ? Line.from(List.of(Span.raw(text))) :
Line.from(spans);
+ }
+}
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 ea967b02d759..c8ab629ca13c 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
@@ -183,6 +183,9 @@ final class TuiHelper {
if (line == null || line.isEmpty()) {
return line;
}
+ if (line.indexOf('\u001B') < 0 && line.indexOf('\r') < 0 &&
line.indexOf('\t') < 0) {
+ return line;
+ }
StringBuilder sb = new StringBuilder(line.length());
int i = 0;
while (i < line.length()) {