This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch worktree-happy-tickling-stream in repository https://gitbox.apache.org/repos/asf/camel.git
commit 2b642c0a89a303fdcbff18d252f30bdacc609962 Author: Claus Ibsen <[email protected]> AuthorDate: Fri Jun 5 23:05:25 2026 +0200 CAMEL-23672: TUI - Add files popup and source viewer enhancements Co-Authored-By: Claude Opus 4.6 <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../dsl/jbang/core/commands/tui/ActionsPopup.java | 31 +- .../dsl/jbang/core/commands/tui/CamelMonitor.java | 277 ++++++++++++++++- .../dsl/jbang/core/commands/tui/SourceViewer.java | 342 ++++++++++++++++++++- 3 files changed, 631 insertions(+), 19 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 486327e3cb5d..afed843f228b 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 @@ -73,6 +73,7 @@ class ActionsPopup { RUN_EXAMPLE, RUN_FOLDER, RUN_INFRA, + BROWSE_FILES, DOCTOR, RESET_STATS, RESET_SCREEN, @@ -86,7 +87,7 @@ class ActionsPopup { MCP_LOG } - private static final int[] GROUP_SIZES = { 4, 4, 5 }; + private static final int[] GROUP_SIZES = { 5, 4, 5 }; private static final int MCP_GROUP_SIZE = 2; private final Supplier<Set<String>> runningNames; @@ -99,6 +100,7 @@ class ActionsPopup { private final Runnable burstCallback; private Runnable resetStatsAction; private Runnable resetScreenAction; + private Runnable browseFilesAction; private final Supplier<Boolean> tapeRecordingActive; private MonitorContext ctx; private boolean mcpEnabled; @@ -194,6 +196,10 @@ class ActionsPopup { this.resetScreenAction = resetScreenAction; } + void setBrowseFilesAction(Runnable browseFilesAction) { + this.browseFilesAction = browseFilesAction; + } + void setMcpEnabled( boolean enabled, int port, Supplier<String> connectedClient, Supplier<List<TuiMcpServer.LogEntry>> activityLog) { this.mcpEnabled = enabled; @@ -315,6 +321,7 @@ class ActionsPopup { labels.add("Run an example..."); labels.add("Run from folder..."); labels.add("Run Dev/Infra Service..."); + labels.add("Browse Files..."); labels.add("───"); // Group 2: Diagnostics labels.add("Run Doctor"); @@ -545,6 +552,13 @@ class ActionsPopup { } else if (action == Action.TAPE_INSTRUCTIONS) { showActionsMenu = false; openTapeInstructions(); + } else if (action == Action.BROWSE_FILES) { + if (ctx != null && ctx.selectedPid != null && !ctx.isInfraSelected()) { + showActionsMenu = false; + if (browseFilesAction != null) { + browseFilesAction.run(); + } + } } else if (action == Action.DOCTOR) { showActionsMenu = false; doctorPopup.open(); @@ -733,6 +747,10 @@ class ActionsPopup { items.add(ListItem.from(" 🐪 Run an example...")); items.add(ListItem.from(" 📂 Run from folder...")); items.add(ListItem.from(" 🔧 Run Dev/Infra Service...")); + boolean hasSelection = ctx != null && ctx.selectedPid != null && !ctx.isInfraSelected(); + items.add(hasSelection + ? ListItem.from(" 📁 Browse Files...") + : ListItem.from(" 📁 Browse Files...").style(Style.EMPTY.dim())); items.add(ListItem.from(divider).style(Style.EMPTY.dim())); // Group 2: Diagnostics items.add(ListItem.from(" 🩺 Run Doctor")); @@ -1177,6 +1195,15 @@ class ActionsPopup { if (folder.isEmpty()) { return; } + // resolve ~ to home directory + if (folder.startsWith("~")) { + folder = System.getProperty("user.home") + folder.substring(1); + } + Path dirPath = Path.of(folder); + if (!Files.isDirectory(dirPath)) { + setNotification("Directory does not exist: " + folder, true); + return; + } folderHistory.remove(folder); folderHistory.add(0, folder); if (folderHistory.size() > 20) { @@ -1184,7 +1211,7 @@ class ActionsPopup { } selectedFolder = folder; showFolderInput = false; - String displayName = Path.of(folder).getFileName().toString(); + String displayName = dirPath.getFileName().toString(); runOptionsForm.open(displayName, displayName, false, true); } 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 f7a36b6d47f9..410d1b69a774 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 @@ -279,6 +279,16 @@ public class CamelMonitor extends CamelCommand { private boolean showSwitchPopup; private final ListState switchPopupState = new ListState(); + // "Files" popup state + record FileEntry(String emoji, String name, long size, String path) { + } + + private boolean showFilesPopup; + private String filesPopupTitle; + private final ListState filesPopupState = new ListState(); + private List<FileEntry> fileEntries = Collections.emptyList(); + private final SourceViewer overviewSourceViewer = new SourceViewer(); + // "More" dropdown state private boolean showMorePopup; private final ListState morePopupState = new ListState(); @@ -319,6 +329,7 @@ public class CamelMonitor extends CamelCommand { ctx = new MonitorContext(data, infraData); actionsPopup.setContext(ctx); actionsPopup.setResetStatsAction(this::resetStats); + actionsPopup.setBrowseFilesAction(this::openFilesPopup); logTab = new LogTab(ctx); diagramTab = new DiagramTab(ctx); routesTab = new RoutesTab(ctx); @@ -439,6 +450,41 @@ public class CamelMonitor extends CamelCommand { if (actionsPopup.isVisible()) { return actionsPopup.handleKeyEvent(ke); } + // "Files" popup + if (showFilesPopup) { + if (overviewSourceViewer.isVisible()) { + if (overviewSourceViewer.handleKeyEvent(ke)) { + return true; + } + } + if (ke.isCancel()) { + if (overviewSourceViewer.isVisible()) { + overviewSourceViewer.hide(); + } else { + showFilesPopup = false; + } + return true; + } + if (!overviewSourceViewer.isVisible()) { + if (ke.isUp()) { + filesPopupState.selectPrevious(); + return true; + } + if (ke.isDown()) { + filesPopupState.selectNext(fileEntries.size()); + return true; + } + if (ke.isConfirm()) { + Integer sel = filesPopupState.selected(); + if (sel != null && sel < fileEntries.size()) { + FileEntry entry = fileEntries.get(sel); + overviewSourceViewer.loadFile(Path.of(entry.path())); + } + return true; + } + } + return true; + } // "More" tab popup if (showMorePopup) { if (ke.isCancel()) { @@ -765,6 +811,10 @@ public class CamelMonitor extends CamelCommand { return true; } } + if (tab == TAB_OVERVIEW && ke.isChar('f') && ctx.selectedPid != null && !isInfraSelected()) { + openFilesPopup(); + return true; + } // Delegate remaining keys to active tab if (activeTab != null && activeTab.handleKeyEvent(ke)) { return true; @@ -783,6 +833,10 @@ public class CamelMonitor extends CamelCommand { logTab.handlePaste(pe.text()); return true; } + if (overviewSourceViewer.isSearchInputActive()) { + overviewSourceViewer.handlePaste(pe.text()); + return true; + } } if (event instanceof TickEvent) { long now = System.currentTimeMillis(); @@ -950,6 +1004,10 @@ public class CamelMonitor extends CamelCommand { circuitBreakerTab.onIntegrationChanged(); inflightTab.onIntegrationChanged(); + showFilesPopup = false; + fileEntries = Collections.emptyList(); + overviewSourceViewer.reset(); + // Preload diagram data in background so it's ready when the user switches tabs routesTab.preloadDiagram(); diagramTab.preloadDiagram(); @@ -1221,6 +1279,10 @@ public class CamelMonitor extends CamelCommand { if (showSwitchPopup) { renderSwitchPopup(frame, area); } + // Render "Files" popup overlay when visible + if (showFilesPopup) { + renderFilesPopup(frame, area); + } } private void renderMorePopup(Frame frame, Rect area) { @@ -1320,6 +1382,208 @@ public class CamelMonitor extends CamelCommand { frame.renderStatefulWidget(list, popup, switchPopupState); } + private void openFilesPopup() { + IntegrationInfo info = findSelectedIntegration(); + if (info == null) { + return; + } + Path dir = resolveSourceDirectory(info); + if (dir == null || !Files.isDirectory(dir)) { + return; + } + List<FileEntry> entries = new ArrayList<>(); + try (var stream = Files.list(dir)) { + stream.filter(Files::isRegularFile) + .limit(99) + .forEach(p -> { + String name = p.getFileName().toString(); + String emoji = fileEmoji(p); + long size = 0; + try { + size = Files.size(p); + } catch (IOException e) { + // ignore + } + entries.add(new FileEntry(emoji, name, size, p.toString())); + }); + } catch (IOException e) { + return; + } + if (entries.isEmpty()) { + return; + } + entries.sort(Comparator.comparing(FileEntry::name, String.CASE_INSENSITIVE_ORDER)); + fileEntries = entries; + filesPopupTitle = info.name != null ? info.name : "?"; + filesPopupState.select(0); + showFilesPopup = true; + overviewSourceViewer.reset(); + } + + private void renderFilesPopup(Frame frame, Rect area) { + if (overviewSourceViewer.isVisible()) { + frame.renderWidget(Clear.INSTANCE, area); + overviewSourceViewer.render(frame, area); + return; + } + if (fileEntries.isEmpty()) { + showFilesPopup = false; + return; + } + + int nameWidth = fileEntries.stream().mapToInt(e -> e.name().length()).max().orElse(10); + int sizeWidth = fileEntries.stream().mapToInt(e -> formatFileSize(e.size()).length()).max().orElse(4); + int itemWidth = 4 + nameWidth + 2 + sizeWidth + 2; + int popupW = Math.min(area.width() - 4, Math.max(30, itemWidth + 4)); + int popupH = Math.min(area.height() - 4, fileEntries.size() + 2); + + int x = area.left() + Math.max(0, (area.width() - popupW) / 2); + int y = area.top() + 2; + Rect popup = new Rect(x, y, Math.min(popupW, area.width()), Math.min(popupH, area.height() - 2)); + + frame.renderWidget(Clear.INSTANCE, popup); + + ListItem[] items = new ListItem[fileEntries.size()]; + for (int i = 0; i < fileEntries.size(); i++) { + FileEntry entry = fileEntries.get(i); + String sizeStr = formatFileSize(entry.size()); + String label = String.format(" %s %-" + nameWidth + "s %s", entry.emoji(), entry.name(), sizeStr); + items[i] = ListItem.from(label); + } + + ListWidget list = ListWidget.builder() + .items(items) + .highlightStyle(Style.EMPTY.fg(Color.WHITE).bold().onBlue()) + .highlightSymbol("") + .scrollMode(ScrollMode.NONE) + .block(Block.builder() + .borderType(BorderType.ROUNDED) + .title(Title.from(Line + .from(Span.styled(" Files: " + filesPopupTitle + " ", Style.EMPTY.fg(Color.YELLOW).bold())))) + .build()) + .build(); + frame.renderStatefulWidget(list, popup, filesPopupState); + } + + private static final String[] CAMEL_YAML_MARKERS = { + "- from:", "- route:", + "- routeTemplate:", "- route-template:", + "- templatedRoute:", "- templated-route:", + "- routeConfiguration:", "- route-configuration:", + "- rest:", "- beans:" + }; + + private static final String[] CAMEL_XML_MARKERS = { + "<route", "<routes", "<routeTemplate", "<routeTemplates", + "<templatedRoute", "<templatedRoutes", + "<rest", "<rests", "<routeConfiguration", + "<beans", "<blueprint", "<camel" + }; + + private static String fileEmoji(Path path) { + String name = path.getFileName().toString(); + String lower = name.toLowerCase(Locale.ROOT); + if (lower.endsWith(".yaml") || lower.endsWith(".yml")) { + return isCamelYaml(path) ? "🐪" : "📋"; + } + if (lower.endsWith(".xml")) { + return isCamelXml(path) ? "🐪" : "📋"; + } + if (lower.endsWith(".java")) { + return isCamelJava(path) ? "🐪" : "☕"; + } + if (lower.endsWith(".properties") || lower.endsWith(".cfg")) { + return "📄"; + } + if (lower.startsWith("readme")) { + return "📖"; + } + return "📋"; + } + + private static boolean isCamelYaml(Path path) { + try { + String content = Files.readString(path, StandardCharsets.UTF_8); + for (String marker : CAMEL_YAML_MARKERS) { + if (content.contains(marker)) { + return true; + } + } + } catch (IOException e) { + // ignore + } + return false; + } + + private static boolean isCamelXml(Path path) { + try { + String content = Files.readString(path, StandardCharsets.UTF_8); + for (String marker : CAMEL_XML_MARKERS) { + if (content.contains(marker)) { + return true; + } + } + } catch (IOException e) { + // ignore + } + return false; + } + + private static boolean isCamelJava(Path path) { + try { + String content = Files.readString(path, StandardCharsets.UTF_8); + return content.contains("RouteBuilder") + || content.contains("EndpointRouteBuilder"); + } catch (IOException e) { + // ignore + } + return false; + } + + private static Path resolveSourceDirectory(IntegrationInfo info) { + for (ConfigurationTab.ConfigProperty cp : info.configProperties) { + if ("camel.main.routesIncludePattern".equals(cp.key) && cp.value != null) { + for (String part : cp.value.split(",")) { + part = part.trim(); + if (part.startsWith("file:")) { + String filePath = part.substring("file:".length()); + // strip query params like ?optional=true + int q = filePath.indexOf('?'); + if (q > 0) { + filePath = filePath.substring(0, q); + } + // source-dir pattern: file:/path/to/folder/** → use /path/to/folder directly + if (filePath.endsWith("/**")) { + Path dir = Path.of(filePath.substring(0, filePath.length() - 3)); + if (Files.isDirectory(dir)) { + return dir; + } + } + // individual file: file:/tmp/example/foo.yaml → use parent dir + Path parent = Path.of(filePath).getParent(); + if (parent != null && Files.isDirectory(parent)) { + return parent; + } + } + } + } + } + if (info.directory != null && !info.directory.isEmpty()) { + return Path.of(info.directory); + } + return null; + } + + private static String formatFileSize(long bytes) { + if (bytes < 1024) { + return bytes + " B"; + } + if (bytes < 1024 * 1024) { + return String.format("%.1f KB", bytes / 1024.0); + } + return String.format("%.1f MB", bytes / (1024.0 * 1024)); + } + private List<IntegrationInfo> getNonVanishingIntegrations() { return data.get().stream() .filter(i -> !i.vanishing && i.name != null) @@ -1658,7 +1922,15 @@ public class CamelMonitor extends CamelCommand { return; } - if (showSwitchPopup) { + if (showFilesPopup) { + if (overviewSourceViewer.isVisible()) { + overviewSourceViewer.renderFooter(spans); + } else { + hint(spans, "Up/Down", "navigate"); + hint(spans, "Enter", "open"); + hint(spans, "Esc", "close"); + } + } else if (showSwitchPopup) { hint(spans, "Up/Down", "select"); hint(spans, "Enter", "switch"); hint(spans, "Esc", "close"); @@ -1758,6 +2030,9 @@ public class CamelMonitor extends CamelCommand { if (selInfo.readmeFiles != null && !selInfo.readmeFiles.isEmpty()) { hint(spans, "d", "docs"); } + if (selInfo.directory != null && !selInfo.directory.isEmpty()) { + hint(spans, "f", "files"); + } hint(spans, "p", selInfo.routeStarted > 0 ? "stop routes" : "start routes"); } } 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 dd22626eba87..273a0a0d5c17 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 @@ -24,9 +24,14 @@ import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.IntConsumer; +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.Color; +import dev.tamboui.style.Overflow; import dev.tamboui.style.Style; import dev.tamboui.terminal.Frame; import dev.tamboui.text.Line; @@ -36,6 +41,7 @@ import dev.tamboui.tui.event.KeyCode; import dev.tamboui.tui.event.KeyEvent; import dev.tamboui.widgets.block.Block; import dev.tamboui.widgets.block.BorderType; +import dev.tamboui.widgets.input.TextInputState; import dev.tamboui.widgets.paragraph.Paragraph; import dev.tamboui.widgets.scrollbar.Scrollbar; import dev.tamboui.widgets.scrollbar.ScrollbarState; @@ -69,6 +75,24 @@ class SourceViewer { private final AtomicBoolean loading = new AtomicBoolean(false); private IntConsumer onLineSelected; private final Map<String, CachedSource> sourceCache = new ConcurrentHashMap<>(); + private boolean wordWrap; + + // Find mode + 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(); + + // Highlight mode + private String highlightTerm; + private Pattern highlightPattern; + + 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); private record CachedSource( List<String> lines, List<JsonObject> codeData, @@ -95,6 +119,15 @@ class SourceViewer { pendingScroll = false; onLineSelected = null; sourceCache.clear(); + wordWrap = false; + findInputActive = false; + highlightInputActive = false; + findTerm = null; + findPattern = null; + findMatchIndex = -1; + findMatches = Collections.emptyList(); + highlightTerm = null; + highlightPattern = null; } void setOnLineSelected(IntConsumer callback) { @@ -105,11 +138,49 @@ class SourceViewer { if (!visible) { return false; } - if (ke.isChar('c') || ke.isCancel()) { + if (findInputActive || highlightInputActive) { + return handleSearchInput(ke); + } + if (ke.isCancel()) { + if (findTerm != null) { + findTerm = null; + findPattern = null; + findMatches = Collections.emptyList(); + findMatchIndex = -1; + return true; + } visible = false; onLineSelected = null; return true; } + if (ke.isChar('c')) { + visible = false; + onLineSelected = null; + 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('w')) { + wordWrap = !wordWrap; + scrollX = 0; + return true; + } if (ke.isKey(KeyCode.UP) && ke.hasCtrl()) { scrollY = Math.max(0, scrollY - 1); } else if (ke.isKey(KeyCode.DOWN) && ke.hasCtrl()) { @@ -128,9 +199,9 @@ class SourceViewer { if (!lines.isEmpty()) { selectedLine = Math.min(lines.size() - 1, selectedLine + page); } - } else if (ke.isLeft()) { + } else if (!wordWrap && ke.isLeft()) { scrollX = Math.max(0, scrollX - 1); - } else if (ke.isRight()) { + } else if (!wordWrap && ke.isRight()) { scrollX++; } else if (ke.isHome()) { selectedLine = 0; @@ -153,6 +224,53 @@ class SourceViewer { return true; } + 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; + } + + boolean isSearchInputActive() { + return findInputActive || highlightInputActive; + } + + void handlePaste(String text) { + if (findInputActive || highlightInputActive) { + FormHelper.handlePaste(text, searchInputState); + } + } + void render(Frame frame, Rect area) { Block block = Block.builder() .borderType(BorderType.ROUNDED) @@ -186,41 +304,127 @@ class SourceViewer { } scrollY = Math.min(scrollY, maxScroll); - int cursorWidth = 3; - int maxLineWidth = lines.stream().mapToInt(String::length).max().orElse(0) + cursorWidth; - int maxHScroll = Math.max(0, maxLineWidth - inner.width()); - scrollX = Math.min(scrollX, maxHScroll); + int hSkip = wordWrap ? 0 : scrollX; + if (!wordWrap) { + int cursorWidth = 3; + int maxLineWidth = lines.stream().mapToInt(String::length).max().orElse(0) + cursorWidth; + int maxHScroll = Math.max(0, maxLineWidth - inner.width()); + scrollX = Math.min(scrollX, maxHScroll); + } + + int currentMatchLine = findMatchIndex >= 0 && findMatchIndex < findMatches.size() + ? findMatches.get(findMatchIndex) : -1; int end = Math.min(scrollY + visibleLines, lines.size()); List<Line> visible = new ArrayList<>(); for (int i = scrollY; i < end; i++) { String raw = lines.get(i); boolean isSelected = (i == selectedLine); - visible.add(highlightSourceLine(raw, scrollX, isSelected, inner.width())); + Line line = highlightSourceLine(raw, hSkip, isSelected, inner.width()); + if (highlightPattern != null || findPattern != null) { + line = applySearchHighlights(line, i, currentMatchLine); + } + visible.add(line); } - frame.renderWidget(Paragraph.builder().text(Text.from(visible)).build(), inner); + + List<Rect> hChunks = Layout.horizontal() + .constraints(Constraint.fill(), Constraint.length(1)) + .split(inner); + + Overflow overflow = wordWrap ? Overflow.WRAP_WORD : Overflow.CLIP; + frame.renderWidget(Paragraph.builder().text(Text.from(visible)).overflow(overflow).build(), hChunks.get(0)); if (lines.size() > visibleLines) { vScrollState.contentLength(lines.size()).viewportContentLength(visibleLines).position(scrollY); - frame.renderStatefulWidget(Scrollbar.builder().build(), inner, vScrollState); + frame.renderStatefulWidget(Scrollbar.builder().build(), hChunks.get(1), vScrollState); } - if (maxHScroll > 0) { - hScrollState.contentLength(maxLineWidth).viewportContentLength(inner.width()).position(scrollX); - frame.renderStatefulWidget(Scrollbar.horizontal(), inner, hScrollState); + if (!wordWrap) { + int cursorWidth = 3; + int maxLineWidth = lines.stream().mapToInt(String::length).max().orElse(0) + cursorWidth; + int maxHScroll = Math.max(0, maxLineWidth - inner.width()); + if (maxHScroll > 0) { + hScrollState.contentLength(maxLineWidth).viewportContentLength(inner.width()).position(scrollX); + frame.renderStatefulWidget(Scrollbar.horizontal(), inner, hScrollState); + } } } void renderFooter(List<Span> spans) { - MonitorContext.hint(spans, "Esc/c", "close"); + if (findInputActive) { + spans.add(Span.styled(" /", MonitorContext.HINT_KEY_STYLE)); + spans.add(Span.raw(searchInputState.text() + "█ ")); + MonitorContext.hint(spans, "Enter", "search"); + MonitorContext.hintLast(spans, "Esc", "cancel"); + return; + } + if (highlightInputActive) { + spans.add(Span.styled(" h:", MonitorContext.HINT_KEY_STYLE)); + spans.add(Span.raw(searchInputState.text() + "█ ")); + MonitorContext.hint(spans, "Enter", "set"); + MonitorContext.hintLast(spans, "Esc", "cancel"); + return; + } + if (findTerm != null) { + MonitorContext.hint(spans, "Esc", "clear find"); + MonitorContext.hint(spans, "n", "next"); + MonitorContext.hint(spans, "N", "prev"); + String pos = findMatches.isEmpty() + ? "0/0" + : (findMatchIndex + 1) + "/" + findMatches.size(); + spans.add(Span.styled(" /", MonitorContext.HINT_KEY_STYLE)); + spans.add(Span.raw("\"" + findTerm + "\" [" + pos + "] ")); + } else { + MonitorContext.hint(spans, "Esc/c", "close"); + } MonitorContext.hint(spans, "↑↓", "navigate"); - MonitorContext.hint(spans, "Ctrl+↑↓", "scroll"); - MonitorContext.hint(spans, "←→", "horizontal"); + MonitorContext.hint(spans, "/", "find"); + MonitorContext.hint(spans, "h", "highlight" + (highlightTerm != null ? " [" + highlightTerm + "]" : "")); + MonitorContext.hint(spans, "w", "wrap" + (wordWrap ? " [on]" : " [off]")); + if (!wordWrap) { + MonitorContext.hint(spans, "←→", "horizontal"); + } MonitorContext.hint(spans, "PgUp/PgDn", "page"); if (onLineSelected != null) { MonitorContext.hint(spans, "Enter", "select node"); } } + /** + * Load source for a route, scrolling to the given source line number. + */ + void loadFile(Path filePath) { + String fileName = filePath.getFileName().toString(); + try { + List<String> rawLines = java.nio.file.Files.readAllLines(filePath, java.nio.charset.StandardCharsets.UTF_8); + int lineNumWidth = String.valueOf(rawLines.size()).length(); + List<String> result = new ArrayList<>(); + List<JsonObject> codeLines = new ArrayList<>(); + for (int i = 0; i < rawLines.size(); i++) { + int lineNum = i + 1; + String code = rawLines.get(i); + result.add(String.format("%" + lineNumWidth + "d %s", lineNum, code)); + JsonObject jo = new JsonObject(); + jo.put("line", lineNum); + jo.put("code", code); + codeLines.add(jo); + } + title = fileName; + language = SyntaxHighlighter.detectLanguage(fileName); + lines = result; + codeData = codeLines; + selectedLine = findLicenseHeaderEnd(codeLines); + scrollY = 0; + scrollX = 0; + pendingScroll = true; + visible = true; + } catch (java.io.IOException e) { + title = fileName; + lines = List.of("(Failed to read file: " + e.getMessage() + ")"); + codeData = Collections.emptyList(); + visible = true; + } + } + /** * Load source for a route, scrolling to the given source line number. */ @@ -467,6 +671,112 @@ class SourceViewer { return full; } + private void buildFindMatches() { + List<Integer> matches = new ArrayList<>(); + for (int i = 0; i < lines.size(); i++) { + if (findPattern.matcher(lines.get(i)).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) >= selectedLine) { + 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()) { + selectedLine = findMatches.get(findMatchIndex); + } + } + + private Line applySearchHighlights(Line line, int lineIndex, int currentMatchLine) { + String fullText = line.rawContent(); + if (fullText.isEmpty()) { + return line; + } + + List<int[]> ranges = new ArrayList<>(); + List<Style> rangeStyles = new ArrayList<>(); + if (highlightPattern != null) { + Matcher m = highlightPattern.matcher(fullText); + while (m.find()) { + ranges.add(new int[] { m.start(), m.end() }); + rangeStyles.add(HIGHLIGHT_STYLE); + } + } + if (findPattern != null) { + boolean isCurrentLine = lineIndex == currentMatchLine; + Matcher m = findPattern.matcher(fullText); + while (m.find()) { + ranges.add(new int[] { m.start(), m.end() }); + rangeStyles.add(isCurrentLine ? FIND_CURRENT_STYLE : FIND_MATCH_STYLE); + } + } + if (ranges.isEmpty()) { + return line; + } + + 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), rangeStyles.get(r))); + cursor = localEnd; + } + if (cursor < content.length()) { + result.add(Span.styled(content.substring(cursor), baseStyle)); + } + charPos = spanEnd; + } + return Line.from(result); + } + static int findLicenseHeaderEnd(List<JsonObject> codeLines) { boolean inBlock = false; int lastCommentLine = -1;
