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();
+ }
+}