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 19039e26a578 chore: camel-jbang - an opened Maven project stays listed 
while it runs, and shows its routes before it runs (#27223)
19039e26a578 is described below

commit 19039e26a5783c0b306e10305aacc7375adbd151
Author: Claus Ibsen <[email protected]>
AuthorDate: Thu Oct 1 19:48:04 2026 +0200

    chore: camel-jbang - an opened Maven project stays listed while it runs, 
and shows its routes before it runs (#27223)
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
---
 .../modules/ROOT/pages/camel-jbang-tui.adoc        |   5 +
 .../dsl/jbang/core/commands/tui/ActionsPopup.java  |  12 +-
 .../core/commands/tui/DataRefreshService.java      |  30 +++-
 .../jbang/core/commands/tui/DocViewerPopup.java    |  22 +++
 .../jbang/core/commands/tui/IntegrationInfo.java   |   6 +
 .../jbang/core/commands/tui/MonitorContext.java    |   7 +
 .../dsl/jbang/core/commands/tui/OverviewTab.java   |  89 +++++++++++-
 .../dsl/jbang/core/commands/tui/SourceTab.java     |  53 +++++++-
 .../commands/tui/OpenedProjectLifecycleTest.java   | 151 +++++++++++++++++++++
 .../core/commands/tui/OpenedProjectViewsTest.java  |  84 ++++++++++++
 10 files changed, 445 insertions(+), 14 deletions(-)

diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
index c124ccdd6e76..2bb536b6a25d 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
@@ -102,6 +102,11 @@ them with the appropriate goal (`spring-boot:run`, 
`quarkus:dev`, or `camel:run`
 directories are run with `camel run`. Either way the application logs to 
`~/.camel` so the Log tab
 shows its logs.
 
+Until it runs, the project is listed as *Stopped*: the Overview says what it 
is (runtime, Maven or route files, its
+folder) and the Source pane lists the routes it found and where they are, so 
*g* opens one. While it runs, the app
+takes its place under the name it gives itself, with the project's name next 
to it; when the run ends, the project is
+back as Stopped and still selected.
+
 This is a quick way to explore and run any Camel project without starting it 
separately first.
 
 image::jbang/camel-tui-main-quarkus-source.png[A Quarkus project in the Source 
tab: its Java route and the bean of the line]
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 e648baf0beba..0c27ae5b40e9 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
@@ -170,6 +170,7 @@ class ActionsPopup {
 
     void setContext(MonitorContext ctx) {
         this.ctx = ctx;
+        ctx.onProjectOpened = docViewerPopup::closeFailureLog;
         docViewerPopup.setContext(ctx);
         folderInputPopup.setContext(ctx);
     }
@@ -1322,7 +1323,8 @@ class ActionsPopup {
         List<String> extraArgs = runOptionsForm.buildArgs();
         runOptionsForm.close();
 
-        ctx.removePhantom(phantom.pid);
+        // the project stays listed (Starting) until its app shows up and 
stands in for it
+        phantom.startingSince = System.currentTimeMillis();
 
         if (phantom.projectType != null) {
             launchManager.launchMavenProject(phantom.sourceDir, 
phantom.projectType, displayName, extraArgs);
@@ -1392,6 +1394,14 @@ class ActionsPopup {
     }
 
     private void showFailureLog(String name, Path logFile) {
+        if (ctx != null) {
+            // an opened project whose run failed is Stopped again
+            for (IntegrationInfo project : ctx.phantomIntegrations) {
+                if (project.startingSince > 0 && name != null && 
name.equalsIgnoreCase(project.name)) {
+                    project.startingSince = 0;
+                }
+            }
+        }
         if (!docViewerPopup.hasFailureContent(logFile)) {
             setNotification("Failed: " + name + " (no output)", true);
             return;
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DataRefreshService.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DataRefreshService.java
index c02262e51021..789b2f7ad14b 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DataRefreshService.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DataRefreshService.java
@@ -398,24 +398,40 @@ class DataRefreshService {
         }
     }
 
-    private void mergePhantoms(List<IntegrationInfo> infos) {
+    /**
+     * An opened project shows as Stopped until it runs; then its running app 
stands in for it (under the name the app
+     * gives itself), and when the run ends the project is back as Stopped, 
still selected.
+     */
+    static void mergePhantoms(List<IntegrationInfo> infos, 
List<IntegrationInfo> phantoms, MonitorContext ctx) {
+        // a run that is just ending (vanishing) still stands in for its 
project, so it is not shown twice
         Map<String, IntegrationInfo> liveDirs = infos.stream()
-                .filter(i -> !i.vanishing && !i.phantom && i.directory != null)
-                .collect(Collectors.toMap(i -> i.directory, i -> i, (a, b) -> 
a));
-        for (IntegrationInfo phantom : ctx.phantomIntegrations) {
+                .filter(i -> !i.phantom && i.directory != null)
+                .collect(Collectors.toMap(i -> i.directory, i -> i, (a, b) -> 
a.vanishing ? b : a));
+        for (IntegrationInfo phantom : phantoms) {
             IntegrationInfo live = phantom.sourceDir != null ? 
liveDirs.get(phantom.sourceDir) : null;
             if (live != null) {
-                // Phantom's project is now running — switch selection to the 
live integration
-                if (phantom.pid.equals(ctx.selectedPid)) {
+                if (phantom.pid.equals(ctx.selectedPid) && !live.vanishing) {
                     ctx.selectedPid = live.pid;
                 }
-                ctx.removePhantom(phantom.pid);
+                phantom.linkedPid = live.pid;
+                phantom.startingSince = 0;
+                live.openedAs = phantom.name;
             } else {
+                if (phantom.linkedPid != null
+                        && (ctx.selectedPid == null || 
ctx.selectedPid.equals(phantom.linkedPid))) {
+                    // the run of the selected project ended: select the 
project again
+                    ctx.selectedPid = phantom.pid;
+                }
+                phantom.linkedPid = null;
                 infos.add(phantom);
             }
         }
     }
 
+    private void mergePhantoms(List<IntegrationInfo> infos) {
+        mergePhantoms(infos, ctx.phantomIntegrations, ctx);
+    }
+
     private void handleAutoSelect(List<IntegrationInfo> infos, boolean 
fullScan) {
         if (ctx.selectedPid != null && !refreshCtx.isInfraSelected()) {
             boolean stillAlive = infos.stream()
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DocViewerPopup.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DocViewerPopup.java
index dc7f3b5e4f62..a29c9497ba10 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DocViewerPopup.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DocViewerPopup.java
@@ -561,6 +561,28 @@ class DocViewerPopup {
                 .map(line -> TuiHelper.ansiToLine(line.replace("\t", "        
"), 0))
                 .collect(Collectors.toList());
         openLines("Failed: " + name, lines);
+        // open at why it failed, which comes after a long startup log
+        docScroll = failureLine(logLines);
+    }
+
+    /** The line to open a failure log at: a little above the last line that 
says why, else the top. */
+    static int failureLine(List<String> logLines) {
+        for (int i = logLines.size() - 1; i >= 0; i--) {
+            String line = logLines.get(i);
+            for (String marker : LaunchManager.START_FAILURES) {
+                if (line.contains(marker)) {
+                    return Math.max(0, i - 2);
+                }
+            }
+        }
+        return 0;
+    }
+
+    /** Closes the viewer when it shows the failure log of a run. */
+    void closeFailureLog() {
+        if (isVisible() && docTitle != null && docTitle.startsWith("Failed:")) 
{
+            close();
+        }
     }
 
     boolean hasFailureContent(Path logFile) {
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/IntegrationInfo.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/IntegrationInfo.java
index 19e445252f8f..e2bdef4aec27 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/IntegrationInfo.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/IntegrationInfo.java
@@ -33,6 +33,12 @@ class IntegrationInfo {
     String profile;
     boolean devMode;
     boolean phantom;
+    // an opened project (phantom): the pid of its running app while it runs
+    String linkedPid;
+    // a running app: the name of the project it was opened as, when it 
differs from the app's own name
+    String openedAs;
+    // an opened project: when it was asked to run, until its app shows up (or 
the launch fails)
+    long startingSince;
     String projectType;
     String sourceDir;
     String ready;
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorContext.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorContext.java
index 32fb08228c85..ec890d4fcf62 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorContext.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/MonitorContext.java
@@ -150,8 +150,15 @@ class MonitorContext {
         info.pid = "phantom-" + phantomCounter.incrementAndGet();
         info.state = 9;
         phantomIntegrations.add(info);
+        Runnable opened = onProjectOpened;
+        if (opened != null) {
+            opened.run();
+        }
     }
 
+    // called when a project is opened, so what was shown of an earlier run of 
it (its failure log) is put away
+    volatile Runnable onProjectOpened;
+
     void removePhantom(String pid) {
         phantomIntegrations.removeIf(i -> pid.equals(i.pid));
     }
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 da3a8ce5d197..939b5e382764 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
@@ -16,6 +16,8 @@
  */
 package org.apache.camel.dsl.jbang.core.commands.tui;
 
+import java.nio.file.Files;
+import java.nio.file.Path;
 import java.time.Instant;
 import java.util.ArrayList;
 import java.util.LinkedList;
@@ -289,7 +291,9 @@ class OverviewTab extends AbstractTab {
 
         InfraInfo infraSel = infraDetailVisible ? ctx.findSelectedInfra() : 
null;
         boolean showInfraDetail = infraSel != null;
-        boolean hasSparkline = !showInfraDetail && chartMode != CHART_OFF
+        // an opened project that does not run has no rates: what it is takes 
the place of the chart
+        IntegrationInfo projectSel = showInfraDetail ? null : 
selectedProject(infos);
+        boolean hasSparkline = !showInfraDetail && projectSel == null && 
chartMode != CHART_OFF
                 && !throughputHistory.isEmpty() && ctx.shellPercent < 50;
         List<Constraint> constraints = new ArrayList<>();
         constraints.add(Constraint.fill());
@@ -303,7 +307,7 @@ class OverviewTab extends AbstractTab {
         if (hasSparkline && tableRoom < 5) {
             hasSparkline = false;
         }
-        if (hasSparkline || showInfraDetail) {
+        if (hasSparkline || showInfraDetail || projectSel != null) {
             bottomPanelHeight = Math.max(5, Math.min(bottomPanelHeight, 
area.height() - 5));
             constraints.add(Constraint.length(Math.max(Math.min(5, tableRoom), 
Math.min(bottomPanelHeight, tableRoom))));
         }
@@ -351,9 +355,33 @@ class OverviewTab extends AbstractTab {
             renderThroughputChart(frame, chunks.get(chunks.size() - 1));
         } else if (showInfraDetail) {
             renderInfraInfoPanel(frame, chunks.get(chunks.size() - 1), 
infraSel);
+        } else if (projectSel != null && chunks.size() > 1) {
+            renderProjectPanel(frame, chunks.get(chunks.size() - 1), 
projectSel);
         }
     }
 
+    private IntegrationInfo selectedProject(List<IntegrationInfo> infos) {
+        if (ctx.selectedPid == null) {
+            return null;
+        }
+        for (IntegrationInfo info : infos) {
+            if (info.phantom && ctx.selectedPid.equals(info.pid)) {
+                return info;
+            }
+        }
+        return null;
+    }
+
+    private void renderProjectPanel(Frame frame, Rect area, IntegrationInfo 
project) {
+        Block block = 
Block.builder().borderType(BorderType.ROUNDED).borders(Borders.ALL)
+                .borderStyle(Theme.muted())
+                .title(Title.from(Line.from(Span.styled(" Opened project ", 
Theme.title()))))
+                .build();
+        frame.renderWidget(block, area);
+        Rect inner = block.inner(area);
+        
frame.renderWidget(Paragraph.builder().text(Text.from(projectLines(project, 
inner.width()))).build(), inner);
+    }
+
     /**
      * The rows, header and column widths of the integrations table for the 
current display mode.
      */
@@ -396,6 +424,10 @@ class OverviewTab extends AbstractTab {
                 String nameText = platformIcon + " " + (info.name != null ? 
info.name : "");
                 List<Span> nameSpans = new ArrayList<>();
                 nameSpans.add(Span.styled(nameText, Theme.info()));
+                if (info.openedAs != null && 
!info.openedAs.equalsIgnoreCase(info.name)) {
+                    // the project it was opened as, as the app runs under a 
name of its own
+                    nameSpans.add(Span.styled(" (" + info.openedAs + ")", 
Theme.muted()));
+                }
                 if (info.devMode) {
                     nameSpans.add(Span.styled(" [dev]", Theme.label()));
                 }
@@ -495,7 +527,7 @@ class OverviewTab extends AbstractTab {
                         Cell.from(Span.styled(nameText, Theme.info())),
                         Cell.from(Span.styled("", Theme.muted())),
                         Cell.from(Span.styled("", Theme.muted())),
-                        Cell.from(Span.styled(TuiIcons.STOPPED + " Stopped", 
Theme.error())),
+                        Cell.from(projectStatus(info, 
System.currentTimeMillis())),
                         Cell.from(Span.styled("", Theme.muted())),
                         Cell.from(Span.styled("", Theme.muted())),
                         Cell.from(Span.styled("", Theme.muted())),
@@ -546,6 +578,10 @@ class OverviewTab extends AbstractTab {
                 String nameText = platformIcon + " " + (info.name != null ? 
info.name : "");
                 List<Span> nameSpans = new ArrayList<>();
                 nameSpans.add(Span.styled(nameText, Theme.info()));
+                if (info.openedAs != null && 
!info.openedAs.equalsIgnoreCase(info.name)) {
+                    // the project it was opened as, as the app runs under a 
name of its own
+                    nameSpans.add(Span.styled(" (" + info.openedAs + ")", 
Theme.muted()));
+                }
                 if (info.devMode) {
                     nameSpans.add(Span.styled(" [dev]", Theme.label()));
                 }
@@ -793,6 +829,45 @@ class OverviewTab extends AbstractTab {
         renderInfoPanel(frame, infoArea);
     }
 
+    /** How long an opened project shows as Starting when its app does not 
show up. */
+    static final long PROJECT_START_MS = 5 * 60_000;
+
+    /** The status of an opened project: Starting while it was asked to run 
and its app is not up yet, else Stopped. */
+    static Span projectStatus(IntegrationInfo project, long now) {
+        if (project.startingSince > 0 && now - project.startingSince < 
PROJECT_START_MS) {
+            return Span.styled(TuiIcons.GEAR + " Starting", Theme.warning());
+        }
+        return Span.styled(TuiIcons.STOPPED + " Stopped", Theme.error());
+    }
+
+    /** The info panel of an opened project that is not running. */
+    static List<Line> projectLines(IntegrationInfo project, int width) {
+        Style dim = Theme.muted();
+        List<Line> lines = new ArrayList<>();
+        lines.add(Line.from(Span.styled("Project: ", dim),
+                Span.raw(TuiHelper.truncate(project.name != null ? 
project.name : "", Math.max(1, width - 9)))));
+        if (project.platform != null) {
+            lines.add(Line.from(Span.styled("Runtime: ", dim),
+                    Span.raw(TuiIcons.platformIcon(project.platform) + 
project.platform)));
+        }
+        if (project.sourceDir != null) {
+            Path dir = Path.of(project.sourceDir);
+            boolean maven = Files.isRegularFile(dir.resolve("pom.xml"));
+            lines.add(Line.from(Span.styled("Build:   ", dim),
+                    Span.raw(maven ? "Maven (pom.xml)" : "camel run (route 
files)")));
+            lines.add(Line.from(Span.styled("Folder:  ", dim),
+                    Span.raw(TuiHelper.truncateStart(project.sourceDir, 
Math.max(1, width - 9)))));
+        }
+        lines.add(Line.from(Span.raw("")));
+        lines.add(Line.from(projectStatus(project, 
System.currentTimeMillis())));
+        lines.add(Line.from(Span.raw("")));
+        if (project.startingSince == 0) {
+            lines.add(Line.from(Span.styled(" F10 ", Theme.hintKey()), 
Span.styled(" runs it", dim)));
+        }
+        lines.add(Line.from(Span.styled(" 2 ", Theme.hintKey()), Span.styled(" 
Source shows its routes", dim)));
+        return lines;
+    }
+
     private void renderInfoPanel(Frame frame, Rect area) {
         if (!infraFocused) {
             InfraInfo infra = ctx.findSelectedInfra();
@@ -822,6 +897,11 @@ class OverviewTab extends AbstractTab {
         Style dim = Theme.muted();
         int jvmDetailStart = -1;
         int jvmDetailCount = 0;
+        if (sel != null && sel.phantom) {
+            // an opened project that does not run: what it is, and how to run 
it (no runtime facts to show)
+            
frame.renderWidget(Paragraph.builder().text(Text.from(projectLines(sel, 
inner.width()))).build(), inner);
+            return;
+        }
         if (sel != null) {
             if (sel.platform != null) {
                 String platEmoji = TuiIcons.platformIcon(sel.platform);
@@ -1304,6 +1384,9 @@ class OverviewTab extends AbstractTab {
             JsonObject row = new JsonObject();
             row.put("pid", info.pid);
             row.put("name", info.name);
+            if (info.openedAs != null) {
+                row.put("openedAs", info.openedAs);
+            }
             row.put("camelVersion", info.camelVersion);
             row.put("platform", info.platform);
             row.put("state", info.state);
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceTab.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceTab.java
index 244bf7f2e51a..7c53bc357ec9 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceTab.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceTab.java
@@ -1037,6 +1037,55 @@ class SourceTab extends AbstractTab {
                 area);
     }
 
+    /**
+     * What the Source pane shows before a file is opened: the routes of the 
project and where they are, so a Maven
+     * project (whose routes are folders deep) shows what it holds and how to 
open one.
+     */
+    static List<Line> emptySourceLines(List<RouteEntry> routes, Path root) {
+        List<Line> lines = new ArrayList<>();
+        lines.add(Line.from(Span.raw("")));
+        if (routes.isEmpty()) {
+            lines.add(Line.from(Span.styled("   Select a file and press Enter 
to view source", Style.EMPTY.dim())));
+            return lines;
+        }
+        lines.add(Line.from(Span.styled("   " + routes.size() + (routes.size() 
== 1 ? " route" : " routes")
+                                        + " in this project",
+                Theme.label())));
+        lines.add(Line.from(Span.raw("")));
+        int idWidth = 4;
+        for (RouteEntry re : routes) {
+            idWidth = Math.max(idWidth, label(re).length());
+        }
+        idWidth = Math.min(idWidth, 30);
+        for (RouteEntry re : routes) {
+            String where = re.filePath();
+            if (root != null && where != null) {
+                try {
+                    where = root.relativize(Path.of(where)).toString();
+                } catch (IllegalArgumentException e) {
+                    // not under the project: keep the full path
+                }
+            }
+            lines.add(Line.from(
+                    Span.styled("   " + String.format("%-" + idWidth + "s", 
TuiHelper.truncate(label(re), idWidth)),
+                            Theme.info()),
+                    Span.styled("  " + where + ":" + re.fromLine(), 
Style.EMPTY.dim())));
+        }
+        lines.add(Line.from(Span.raw("")));
+        lines.add(Line.from(
+                Span.styled("   ", Style.EMPTY),
+                Span.styled(" g ", Theme.hintKey()),
+                Span.styled(" opens a route, or select a file and press 
Enter", Style.EMPTY.dim())));
+        return lines;
+    }
+
+    private static String label(RouteEntry re) {
+        if (re.routeId() != null && !re.routeId().isBlank()) {
+            return re.routeId();
+        }
+        return re.fromUri() != null ? re.fromUri() : "";
+    }
+
     private void renderSourcePanel(Frame frame, Rect area) {
         Style sourceTitleStyle = focusOnViewer ? Theme.title() : 
Style.EMPTY.fg(Theme.accent());
         Style sourceBorderStyle = ctx.paneBorder(focusOnViewer);
@@ -1046,9 +1095,7 @@ class SourceTab extends AbstractTab {
             sourceViewer.setFocused(focusOnViewer);
             sourceViewer.render(frame, area);
         } else {
-            List<Line> lines = new ArrayList<>();
-            lines.add(Line.from(Span.raw("")));
-            lines.add(Line.from(Span.styled("   Select a file and press Enter 
to view source", Style.EMPTY.dim())));
+            List<Line> lines = emptySourceLines(routeIndex, rootDir);
 
             frame.renderWidget(
                     Paragraph.builder()
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OpenedProjectLifecycleTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OpenedProjectLifecycleTest.java
new file mode 100644
index 000000000000..763abd87f5c8
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OpenedProjectLifecycleTest.java
@@ -0,0 +1,151 @@
+/*
+ * 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.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * An opened Maven project is shown as Stopped; while it runs its app stands 
in for it (under the app's own name), and
+ * when the run ends the project is back, still selected.
+ */
+class OpenedProjectLifecycleTest {
+
+    private final MonitorContext ctx
+            = new MonitorContext(new AtomicReference<>(List.of()), new 
AtomicReference<>(List.of()));
+
+    @Test
+    void theProjectComesBackWhenItsRunEnds() {
+        IntegrationInfo project = new IntegrationInfo();
+        project.name = "metrics";
+        project.sourceDir = "/work/metrics";
+        ctx.addPhantom(project);
+        ctx.selectedPid = project.pid;
+
+        // not running: the project is listed
+        List<IntegrationInfo> infos = merge();
+        assertTrue(infos.contains(project));
+
+        // it runs, as MyCamel: the app stands in for the project, and is 
selected
+        IntegrationInfo app = running("4242", "MyCamel", "/work/metrics");
+        infos = merge(app);
+        assertFalse(infos.contains(project));
+        assertEquals("4242", ctx.selectedPid);
+        assertEquals("metrics", app.openedAs);
+
+        // the run ends: first the app vanishes, the project is not shown twice
+        app.vanishing = true;
+        ctx.selectedPid = null;
+        assertFalse(merge(app).contains(project));
+
+        // then it is gone: the project is back, and selected again
+        infos = merge();
+        assertTrue(infos.contains(project));
+        assertEquals(project.pid, ctx.selectedPid);
+    }
+
+    @Test
+    void aProjectIsStartingUntilItsAppShowsUp() {
+        IntegrationInfo project = new IntegrationInfo();
+        project.name = "metrics";
+        project.sourceDir = "/work/metrics";
+        ctx.addPhantom(project);
+        long now = System.currentTimeMillis();
+        assertEquals("✖ Stopped", OverviewTab.projectStatus(project, 
now).content());
+
+        project.startingSince = now;
+        assertTrue(merge().contains(project), "still listed while it starts");
+        assertEquals("⚙ Starting", OverviewTab.projectStatus(project, now + 
1000).content());
+        assertEquals("✖ Stopped", OverviewTab.projectStatus(project, now + 
OverviewTab.PROJECT_START_MS).content(),
+                "an app that never shows up does not start forever");
+
+        merge(running("4242", "MyCamel", "/work/metrics"));
+        assertEquals(0, project.startingSince);
+    }
+
+    @Test
+    void aFailureLogOpensAtWhyItFailed() {
+        List<String> log = new ArrayList<>();
+        for (int i = 0; i < 50; i++) {
+            log.add("INFO starting " + i);
+        }
+        log.add("***************************");
+        log.add("APPLICATION FAILED TO START");
+        log.add("***************************");
+        log.add("Web server failed to start. Port 8080 was already in use.");
+        assertEquals(49, DocViewerPopup.failureLine(log));
+        assertEquals(0, DocViewerPopup.failureLine(List.of("Exception in 
thread main")));
+    }
+
+    @Test
+    void aStoppedRunSelectsTheProjectAgain() {
+        // stopped from the TUI: the app is gone at once (no fading), while it 
is still the selection
+        IntegrationInfo project = new IntegrationInfo();
+        project.name = "metrics";
+        project.sourceDir = "/work/metrics";
+        ctx.addPhantom(project);
+        ctx.selectedPid = project.pid;
+        merge(running("4242", "MyCamel", "/work/metrics"));
+        assertEquals("4242", ctx.selectedPid);
+
+        assertTrue(merge().contains(project));
+        assertEquals(project.pid, ctx.selectedPid);
+    }
+
+    @Test
+    void anotherSelectionIsKept() {
+        IntegrationInfo project = new IntegrationInfo();
+        project.name = "metrics";
+        project.sourceDir = "/work/metrics";
+        ctx.addPhantom(project);
+        merge(running("4242", "MyCamel", "/work/metrics"));
+
+        ctx.selectedPid = "99";
+        merge();
+        assertEquals("99", ctx.selectedPid, "the user picked another 
integration meanwhile");
+    }
+
+    @Test
+    void openingAProjectPutsAwayTheFailureOfAnEarlierRun() {
+        AtomicInteger closed = new AtomicInteger();
+        ctx.onProjectOpened = closed::incrementAndGet;
+        ctx.addPhantom(new IntegrationInfo());
+        assertEquals(1, closed.get());
+    }
+
+    private List<IntegrationInfo> merge(IntegrationInfo... live) {
+        List<IntegrationInfo> infos = new ArrayList<>(List.of(live));
+        DataRefreshService.mergePhantoms(infos, ctx.phantomIntegrations, ctx);
+        return infos;
+    }
+
+    private static IntegrationInfo running(String pid, String name, String 
dir) {
+        IntegrationInfo info = new IntegrationInfo();
+        info.pid = pid;
+        info.name = name;
+        info.directory = dir;
+        return info;
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OpenedProjectViewsTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OpenedProjectViewsTest.java
new file mode 100644
index 000000000000..7bfa9cf9fac6
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OpenedProjectViewsTest.java
@@ -0,0 +1,84 @@
+/*
+ * 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.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+
+import dev.tamboui.text.Line;
+import dev.tamboui.text.Span;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * What an opened Maven project shows before it runs: its routes in the Source 
pane, and what it is in the Overview.
+ */
+class OpenedProjectViewsTest {
+
+    @TempDir
+    Path dir;
+
+    @Test
+    void theSourcePaneListsTheRoutesOfTheProject() {
+        List<SourceTab.RouteEntry> routes = List.of(
+                new SourceTab.RouteEntry(
+                        "yaml-route", "timer:from-yaml", dir.resolve(
+                                
"src/main/resources/camel/my-routes.yaml").toString(),
+                        20),
+                new SourceTab.RouteEntry(
+                        null, "timer:foo", dir.resolve(
+                                
"src/main/java/org/acme/TimerRoute.java").toString(),
+                        27));
+
+        List<String> text = text(SourceTab.emptySourceLines(routes, dir));
+        assertThat(text).anyMatch(l -> l.contains("2 routes in this project"));
+        assertThat(text).anyMatch(l -> l.contains("yaml-route") && 
l.contains("src/main/resources/camel/my-routes.yaml:20"));
+        // a route without an id by its endpoint
+        assertThat(text).anyMatch(l -> l.contains("timer:foo") && 
l.contains("TimerRoute.java:27"));
+        assertThat(text).anyMatch(l -> l.contains(" g ") && l.contains("opens 
a route"));
+    }
+
+    @Test
+    void withoutRoutesTheHintStays() {
+        assertThat(text(SourceTab.emptySourceLines(List.of(), dir)))
+                .anyMatch(l -> l.contains("Select a file and press Enter"));
+    }
+
+    @Test
+    void aStoppedProjectSaysWhatItIsAndHowToRunIt() throws Exception {
+        Files.writeString(dir.resolve("pom.xml"), "<project/>");
+        IntegrationInfo project = new IntegrationInfo();
+        project.name = "metrics";
+        project.platform = "Spring Boot";
+        project.sourceDir = dir.toString();
+        project.phantom = true;
+
+        List<String> text = text(OverviewTab.projectLines(project, 32));
+        assertThat(text).anyMatch(l -> l.contains("Project: metrics"));
+        assertThat(text).anyMatch(l -> l.contains("Spring Boot"));
+        assertThat(text).anyMatch(l -> l.contains("Maven (pom.xml)"));
+        assertThat(text).anyMatch(l -> l.contains("F10") && l.contains("runs 
it"));
+        assertThat(text).noneMatch(l -> l.contains("Profile"));
+    }
+
+    private static List<String> text(List<Line> lines) {
+        return lines.stream().map(l -> 
l.spans().stream().map(Span::content).reduce("", String::concat)).toList();
+    }
+}

Reply via email to