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;


Reply via email to