diff --git a/docs/user-manual/modules/ROOT/images/jbang/camel-tui-source-body-types.png b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-source-body-types.png new file mode 100644 index 0000000000000..8a30469ce191b Binary files /dev/null and b/docs/user-manual/modules/ROOT/images/jbang/camel-tui-source-body-types.png differ diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-diagram.adoc b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-diagram.adoc index c50d9a84a4210..6fc27a5b1c34e 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-diagram.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-diagram.adoc @@ -213,7 +213,8 @@ The arrows of a branch are side by side, so the types beside them are cut to the many more (`ByteArrayInputStreamCache +2`). The Info panel of the selected step lists them all with their kind. In the topology, the Info panel of a route says how many of its steps were reached with more than one type (`⚠ 1 mixed · 2 varied`), and the Routes tab has the same in its BODY TYPES column, with the types of each step in the -table of the processors of the selected route. +table of the processors of the selected route. The source view of the Source tab has them at the end of the line of each +step (see xref:camel-jbang-tui-source-editor.adoc#_body_types[Body Types]). image::jbang/camel-tui-routes-body-types.png[The Routes tab: the orders route has four steps reached with bodies of more than one kind, and its processors list the types of each step] diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-source-editor.adoc b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-source-editor.adoc index 55a7117b77c21..0ae3a7755295b 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-source-editor.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui-source-editor.adoc @@ -8,7 +8,7 @@ routes. == Top Features * <<_live_run_data,*Live run data*>> -- the exchanges and failures of each line of the running integration, next to - the code. + the code, and the body types each step has been reached with at the end of its line. * <<_running_as_saved,*Running as saved*>> -- whether the integration runs the file as it is saved, or needs a restart. * <<_problems_as_you_type,*Problems as you type*>> -- endpoint options, Simple expressions and more are checked as soon as a file opens and while you type, in YAML, Java and XML routes; a YAML or XML file with problems is not saved. @@ -55,6 +55,23 @@ In the example the timer fired 227 times; the `filter` let 137 of those through The column keeps its width, so the code does not move as the numbers grow. It works for YAML, Java and XML routes, as `camel run` records the source line of every processor. +=== Body Types + +With the message history on, such as with `camel run --dev`, the body types each step has been reached with go at the +end of its line, dimmed and lined up. A step reached with bodies of more than one kind (`null`, `payload`, `data` or +`collection`) needs attention, as its body depends on the path a message takes: it has a `⚠` in the gutter, and its +types and kinds are in the warning color. The quick documentation of the line lists all the types, which the end of the +line cuts to the room there is. + +image::jbang/camel-tui-source-body-types.png[The orders route in the source view: the steps after the choice are reached with a map and a stream, as only one branch marshals, so they have a warning in the gutter, and the quick documentation of the selected line says why] + +The same body types are in the Routes and Diagram tabs (see +xref:camel-jbang-tui-diagram.adoc#_body_types[Body Types]). + +The live run data and the body types are of the version of the file the integration runs. While it does not run the +file as saved (see <<_running_as_saved,Running as Saved>>), the numbers are dimmed and the body types are left out, as +the lines of the version that runs may be others. + == Running as Saved The bottom border of the source says whether the integration runs the routes of the file as it is saved: @@ -64,6 +81,8 @@ The bottom border of the source says whether the integration runs the routes of * `○ not running yet` -- the file was saved after the integration loaded it. An integration that runs without `--dev` does not reload a file when it is saved: restart it. The log of one in dev mode says why it did not reload the file. * `⚠ the reload failed` -- the integration runs the routes it ran before the reload, until a reload succeeds. +* `○ the routes of this file are stopped` -- stopped (or suspended) by hand. The live run data and the body types still + show when the integration loaded the file as saved, which is not known for a route stopped after a reload. image::jbang/camel-tui-source-running-as-saved.png[The bottom border of an edited route file: the integration runs without --dev, so the saved file is not running yet] diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LiveRunLines.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LiveRunLines.java index a65e5aec6a925..fafddd1111291 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LiveRunLines.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LiveRunLines.java @@ -17,8 +17,10 @@ package org.apache.camel.dsl.jbang.core.commands.tui; import java.util.HashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Set; /** * The live run data of a source file: what the processors of a running integration on each line of it did, read from @@ -33,10 +35,12 @@ private LiveRunLines() { /** * @param routes the routes of the running integration, with their processors * @param filePath the file shown - * @return per 0-based line of the file, the exchanges, failures and mean time of the processors on it + * @return per 0-based line of the file, the exchanges, failures and mean time of the processors on it, and + * the body types they have been reached with */ static Map of(List routes, String filePath) { Map sums = new HashMap<>(); + Map types = new HashMap<>(); String name = nameOf(filePath); if (routes == null || name == null) { return Map.of(); @@ -46,13 +50,48 @@ static Map of(List routes, String add(sums, name, r.source, r.total, r.failed, r.meanTime); for (ProcessorInfo p : r.processors) { add(sums, name, p.source, p.total, p.failed, p.meanTime); + addBodyTypes(types, name, p); } } Map answer = new HashMap<>(); - sums.forEach((line, s) -> answer.put(line, new SourceLiveColumn.LiveLine(s[0], s[1], s[0] > 0 ? s[2] / s[0] : 0))); + sums.forEach((line, s) -> { + LineTypes t = types.get(line); + answer.put(line, t == null + ? new SourceLiveColumn.LiveLine(s[0], s[1], s[0] > 0 ? s[2] / s[0] : 0) + : new SourceLiveColumn.LiveLine( + s[0], s[1], s[0] > 0 ? s[2] / s[0] : 0, List.copyOf(t.types), List.copyOf(t.kinds), t.mixed)); + }); return answer; } + /** The body types of the processors on a line, and whether one of them was reached with more than one kind. */ + private static final class LineTypes { + final Set types = new LinkedHashSet<>(); + final Set kinds = new LinkedHashSet<>(); + boolean mixed; + } + + /** + * Adds the body types a processor has been reached with to its line (CAMEL-25897): the types and the kinds of all + * the processors on the line, which is mixed when one of them was reached with bodies of more than one kind, not + * when two of them each had their own kind. + */ + private static void addBodyTypes(Map types, String name, ProcessorInfo p) { + if (p.bodyTypes == null || p.bodyTypes.isEmpty()) { + return; + } + int line = lineOf(p.source, name); + if (line < 0) { + return; + } + LineTypes t = types.computeIfAbsent(line, k -> new LineTypes()); + t.types.addAll(p.bodyTypes); + if (p.bodyKinds != null) { + t.kinds.addAll(p.bodyKinds); + } + t.mixed |= BodyTypes.mixed(p.bodyKinds); + } + /** Adds what a processor did to its line: totals and failures summed, the mean time weighted by the exchanges. */ private static void add(Map sums, String name, String source, long total, long failed, long mean) { int line = lineOf(source, name); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceLiveColumn.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceLiveColumn.java index 580a435ddd348..d5eb29c645e2f 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceLiveColumn.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceLiveColumn.java @@ -24,20 +24,37 @@ import java.util.function.Supplier; import dev.tamboui.style.Style; +import dev.tamboui.text.CharWidth; import dev.tamboui.text.Span; /** * The live run data of the running integration in the source view ({@link SourceViewer}), a column at the end of the * line numbers: how many exchanges the processors on a line handled, how many failed, and their mean time (a heat map * of the source while it runs). A line that failed is what Shift+F8 asks the AI to fix when it has no problem. + *

+ * The body types the steps of a line have been reached with go at the end of the line, lined up, with a ⚠ in the gutter + * of a step reached with bodies of more than one kind (CAMEL-25897). They only hold while the view has the version of + * the file that runs: once the file is saved and the integration did not load it, the live run data is dimmed and the + * body types are left out. */ class SourceLiveColumn { + /** + * The most a line (its number and code) counts for lining up the body types: a long one does not push them away. + */ + private static final int MAX_BODY_TYPES_ALIGN = 72; + /** * What a line of a running integration's source did: the exchanges of the processors on it, the failed ones and the - * mean processing time. + * mean processing time; and the body types they have been reached with, mixed when one of them was reached with + * bodies of more than one kind. */ - record LiveLine(long total, long failed, long meanMillis) { + record LiveLine(long total, long failed, long meanMillis, List bodyTypes, List bodyKinds, + boolean mixed) { + + LiveLine(long total, long failed, long meanMillis) { + this(total, failed, meanMillis, List.of(), List.of(), false); + } } /** The live run data of a file by its path: per 0-based line, what the processors on it did. */ @@ -54,6 +71,10 @@ interface LineFailures { private final Supplier loadedFile; private final BooleanSupplier dirty; + private final Supplier> lines; + private BooleanSupplier stale = () -> false; + // the column the body types of the lines line up at, from the lines that have them + private int bodyTypesAlign; private LiveRunData liveRunData; private LineFailures lineFailures; private Map liveLines = Collections.emptyMap(); @@ -62,14 +83,32 @@ interface LineFailures { private int liveFailedWidth; private int liveMeanWidth; private String liveWidthsFile; + // the file the live run data was read for: another one is read at once, not after a second + private String liveLinesFile; /** * @param loadedFile the path of the file shown, whose live run data is read; null when none is * @param dirty whether the file is edited and not saved, so the live run data is not of what is shown + * @param lines the lines shown (the line number, then the code), to line up the body types after them */ - SourceLiveColumn(Supplier loadedFile, BooleanSupplier dirty) { + SourceLiveColumn(Supplier loadedFile, BooleanSupplier dirty, Supplier> lines) { this.loadedFile = loadedFile; this.dirty = dirty; + this.lines = lines; + } + + /** + * Whether the integration runs another version of the file than the one shown, such as one saved since it loaded + * the file (CAMEL-25771): the live run data is of other lines then, so it is dimmed and the body types are left + * out. + */ + void setStale(BooleanSupplier stale) { + this.stale = stale; + } + + /** Whether what the integration did is of the lines shown: the file is not edited, and it runs as shown. */ + private boolean current() { + return !dirty.getAsBoolean() && !stale.getAsBoolean(); } /** @@ -105,7 +144,7 @@ String runtimeFailure(int row) { * of each frame asks it; the exception is only read on Shift+F8). */ boolean failsAtRuntime(int row) { - if (dirty.getAsBoolean() || row < 0) { + if (!current() || row < 0) { return false; } LiveLine live = liveLines().get(row); @@ -130,6 +169,11 @@ void addLiveColumn(List spans, int lineIndex, Style selBg) { Style totalStyle = data ? Theme.label() : Style.EMPTY; Style failedStyle = Theme.error().bold(); Style dim = Style.EMPTY.dim(); + if (stale.getAsBoolean()) { + // of the version of the file that runs, whose lines may be others + totalStyle = dim; + failedStyle = dim; + } if (selBg != null) { totalStyle = totalStyle.patch(selBg); failedStyle = failedStyle.patch(selBg); @@ -145,6 +189,61 @@ void addLiveColumn(List spans, int lineIndex, Style selBg) { spans.add(Span.styled(" │", dim)); } + /** + * The gutter of a line: a ⚠ when a step on it has been reached with bodies of more than one kind, which needs + * attention (CAMEL-25897); blank otherwise. + */ + Span gutter(int lineIndex) { + LiveLine live = current() ? liveLines().get(lineIndex) : null; + return live != null && live.mixed() ? Span.styled(" " + BodyTypes.MARK + " ", Theme.warning()) : Span.raw(" "); + } + + /** + * The body types the steps of a line have been reached with, at the end of the line, dimmed (a warning when they + * are of more than one kind), lined up with those of the other lines, and cut to the room up to the right edge + * (CAMEL-25897). Nothing in the plain mode, or when the view does not have the version of the file that runs. + * + * @param spans the spans of the line so far: the gutter, the line number, the live run data and the code + * @param rightEdge the column of the right edge of the view, in the columns of the line + */ + void addBodyTypes(List spans, int lineIndex, boolean plain, Style selBg, int rightEdge) { + LiveLine live = plain || !current() ? null : liveLines().get(lineIndex); + if (live == null || live.bodyTypes().isEmpty()) { + return; + } + int width = 0; + for (Span span : spans) { + width += CharWidth.of(span.content()); + } + int pad = Math.max(2, bodyTypesAlign - width); + String text = BodyTypes.fit(live.bodyTypes(), live.mixed() ? live.bodyKinds() : List.of(), rightEdge - width - pad); + if (text.isEmpty()) { + return; + } + Style style = live.mixed() ? Theme.warning() : Theme.muted(); + spans.add(Span.styled(" ".repeat(pad), selBg != null ? selBg : Style.EMPTY)); + spans.add(Span.styled(text, selBg != null ? style.patch(selBg) : style)); + } + + /** + * The body types of the steps of a line for the quick documentation (CAMEL-25897), all of them, and why a mix of + * kinds needs attention; null when the line has none, or the editor does not have the version of the file that + * runs. + */ + SourceViewer.DocEntry bodyTypesDoc(int row) { + LiveLine live = current() ? liveLines().get(row) : null; + if (live == null || live.bodyTypes().isEmpty()) { + return null; + } + if (live.mixed()) { + return SourceViewer.DocEntry.withTitle("Body types", BodyTypes.names(live.bodyTypes()) + " " + BodyTypes.MARK + + " " + BodyTypes.kinds(live.bodyKinds()) + + ": the body here depends on the path a message takes"); + } + String title = live.bodyKinds().size() == 1 ? "Body types (" + live.bodyKinds().get(0) + ")" : "Body types"; + return SourceViewer.DocEntry.withTitle(title, BodyTypes.names(live.bodyTypes())); + } + /** The live run data of the loaded file, read again at most once a second (the status is polled about as often). */ private Map liveLines() { String loadedFilePath = loadedFile.get(); @@ -152,7 +251,7 @@ private Map liveLines() { return Collections.emptyMap(); } long now = MonotonicClock.millis(); - if (now - liveLinesTime > 1000) { + if (now - liveLinesTime > 1000 || !loadedFilePath.equals(liveLinesFile)) { refreshLiveLines(now); } return liveLines; @@ -178,6 +277,7 @@ private void refreshLiveLines(long now) { Map lines = liveRunData.lines(loadedFilePath); liveLines = lines != null ? lines : Collections.emptyMap(); liveLinesTime = now; + liveLinesFile = loadedFilePath; // the widths of the column, for all the lines alike; they only grow while the same file is shown (from // a minimum), so the column does not jitter when a number crosses 99 -> 100 and back (a mean time going // up and down, a reset of the statistics); another file, or the integration stopping, starts afresh @@ -198,6 +298,32 @@ private void refreshLiveLines(long now) { } } } + bodyTypesAlign = bodyTypesAlign(); + } + } + + /** + * The column the body types line up at: after the longest of the lines that have them (the gutter, the line number, + * the live run data and the code), and at most {@link #MAX_BODY_TYPES_ALIGN} into the code, so a long line does not + * push the body types of the others off screen. + */ + private int bodyTypesAlign() { + List shown = lines.get(); + int longest = 0; + for (Map.Entry e : liveLines.entrySet()) { + int i = e.getKey(); + if (!e.getValue().bodyTypes().isEmpty() && shown != null && i >= 0 && i < shown.size()) { + longest = Math.max(longest, Math.min(CharWidth.of(shown.get(i)), MAX_BODY_TYPES_ALIGN)); + } + } + if (longest == 0) { + return 0; } + // the gutter before the line number, and the live run data after it + int live = liveTotalWidth == 0 + ? 0 + : 1 + liveTotalWidth + (liveFailedWidth > 0 ? 1 + liveFailedWidth : 0) + + (liveMeanWidth > 0 ? 1 + liveMeanWidth : 0) + 2; + return 3 + longest + live + 3; } } diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceRunState.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceRunState.java index 76ee51aaed46e..e038144d3741e 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceRunState.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceRunState.java @@ -21,6 +21,8 @@ import java.nio.file.Path; import java.util.ArrayList; import java.util.List; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicLong; import dev.tamboui.layout.Rect; import dev.tamboui.terminal.Frame; @@ -45,7 +47,9 @@ *

  • after a reload, a route started after the save runs what is saved. The integration does not say when it loaded a * route, so a route stopped and started again by hand after a reload is taken as loaded then.
  • * - * A failed reload puts back the routes that ran before it, so until a reload succeeds the state is the reload error. + * A failed reload puts back the routes that ran before it, so until a reload succeeds the state is the reload error. A + * file whose routes are all stopped is shown as such; the facts of its lines still hold when the integration loaded it + * as saved, which is known without a reload, but not for a route stopped after a reload. */ final class SourceRunState { @@ -60,13 +64,15 @@ enum Kind { /** the last reload failed, so the routes that ran before it run */ RELOAD_FAILED, /** saved after the routes were loaded, and the integration did not load it again */ - NOT_RUNNING + NOT_RUNNING, + /** the routes the integration has from the file are all stopped (or suspended) */ + STOPPED } /** * @param kind the state * @param routes the routes the integration has from the file - * @param stale how many of them were loaded before the file was saved + * @param stale how many of them were loaded before the file was saved, or at a time not known * @param detail why not running, or the reload error; null when running */ record State(Kind kind, int routes, int stale, String detail) { @@ -90,21 +96,23 @@ static State of(IntegrationInfo info, String filePath, long fileModified, long n // an integration that has not reloaded loaded its routes when it started; after a reload, when each started boolean reloaded = info.reloaded > 0 || info.reloadError != null || info.uptime <= 0; int routes = 0; + int started = 0; int stale = 0; + int unknown = 0; for (RouteInfo r : info.routes) { - if (!"Started".equals(r.state) || LiveRunLines.lineOf(r.source, name) < 0) { + if (LiveRunLines.lineOf(r.source, name) < 0) { continue; } - long loaded = info.uptime; - if (reloaded) { - long uptime = uptimeMillis(r.uptime); - if (uptime < 0) { - continue; - } - loaded = Math.max(info.uptime, now - uptime); - } routes++; - if (loaded < fileModified) { + boolean running = "Started".equals(r.state); + if (running) { + started++; + } + long uptime = running ? uptimeMillis(r.uptime) : -1; + if (reloaded && uptime < 0) { + // a stopped route after a reload: when it was loaded is not known, so it may be of another version + unknown++; + } else if ((reloaded ? Math.max(info.uptime, now - uptime) : info.uptime) < fileModified) { stale++; } } @@ -112,11 +120,13 @@ static State of(IntegrationInfo info, String filePath, long fileModified, long n return null; } if (info.reloadError != null) { - return new State(Kind.RELOAD_FAILED, routes, stale, info.reloadError); + return new State(Kind.RELOAD_FAILED, routes, stale + unknown, info.reloadError); } if (stale == 0) { - return new State(Kind.RUNNING, routes, 0, null); + // what a route stopped by hand says of the lines holds when it is the version that was loaded + return new State(started == 0 ? Kind.STOPPED : Kind.RUNNING, routes, unknown, null); } + stale += unknown; if (info.devMode) { if (now - fileModified < RELOAD_GRACE_MILLIS) { return new State(Kind.RELOADING, routes, stale, null); @@ -150,6 +160,7 @@ static Line line(State state) { case NOT_RUNNING -> Line.from( Span.styled(TuiIcons.IDLE + " not running yet", Theme.warning()), Span.styled(": " + state.detail(), Theme.muted())); + case STOPPED -> Line.from(Span.styled(TuiIcons.IDLE + " the routes of this file are stopped", Theme.muted())); }; } @@ -161,6 +172,7 @@ static JsonObject toJson(State state) { case RELOADING -> "reloading"; case RELOAD_FAILED -> "reloadFailed"; case NOT_RUNNING -> "notRunning"; + case STOPPED -> "stopped"; }); jo.put("routes", state.routes()); jo.put("routesLoadedBeforeSave", state.stale()); @@ -176,44 +188,101 @@ static JsonObject toJson(State state) { */ static final class Tracker { + /** The state computed for a file: null when the integration has no route from it. */ private record Computed(String file, State state) { } private volatile String file; private volatile Computed computed; + // a save in the editor makes the state unknown until a refresh after it computes it again (written by the UI + // thread only) + private volatile int saves; + private volatile int computedSaves; + private boolean wasDirty; + // the refreshes in the order they started: a later one wins over one that ends after it (guarded by this) + private final AtomicLong refreshes = new AtomicLong(); + private long written; - /** The file the source panel shows, on the UI thread when it draws; null when none is. */ - void shown(String file) { + /** + * The file the source panel shows, on the UI thread when it draws; null when none is. Edits that are no longer + * unsaved were saved (or discarded): the state is unknown until the next refresh. + * + * @return whether the state is to be computed now: another file is shown, or the file was saved + */ + boolean shown(String file, boolean dirty) { + boolean saved = wasDirty && !dirty; + if (saved) { + saves++; + } + wasDirty = dirty; + boolean other = !Objects.equals(file, this.file); this.file = file; + return file != null && (saved || other); } - /** Computes the state of the file shown, on the background thread of the data refresh. */ + /** + * Computes the state of the file shown, off the UI thread: on the data refresh, when another file is shown or + * the file is saved, and when tui_get_table reads the tab. + */ void refresh(IntegrationInfo info) { + long order = refreshes.incrementAndGet(); + int at = saves; String shown = file; - if (shown == null || info == null) { - computed = null; - return; + Computed result = null; + if (shown != null && info != null) { + try { + // the file system keeps the time of the save by the wall clock, so it is compared with the wall clock + long modified = Files.getLastModifiedTime(Path.of(shown)).toMillis(); + result = new Computed(shown, of(info, shown, modified, System.currentTimeMillis())); + } catch (IOException | RuntimeException e) { + // not known + } } - try { - // the file system keeps the time of the save by the wall clock, so it is compared with the wall clock - long modified = Files.getLastModifiedTime(Path.of(shown)).toMillis(); - State state = of(info, shown, modified, System.currentTimeMillis()); - computed = state != null ? new Computed(shown, state) : null; - } catch (IOException | RuntimeException e) { - computed = null; + synchronized (this) { + if (order > written) { + written = order; + computed = result; + computedSaves = at; + } } } void reset() { file = null; - computed = null; + synchronized (this) { + // a refresh that started before does not write after + written = refreshes.incrementAndGet(); + computed = null; + computedSaves = saves; + } } - /** The state of the file shown; null when unknown, or when it was computed for another file. */ + /** + * The state of the file shown; null when unknown: computed for another file, or before the last save in the + * editor. + */ State current() { Computed c = computed; String shown = file; - return c != null && c.file().equals(shown) ? c.state() : null; + return c != null && c.file().equals(shown) && computedSaves == saves ? c.state() : null; + } + + /** + * Whether the integration may run another version of the file than the one shown, so the live run data is of + * other lines (CAMEL-25897): not known yet for the file shown (just opened, or saved in the editor since), + * saved after the integration loaded it, a failed reload, or a route stopped after a reload. + */ + boolean stale() { + Computed c = computed; + String shown = file; + if (shown == null) { + return false; + } + if (c == null || !c.file().equals(shown) || computedSaves != saves) { + return true; + } + State state = c.state(); + return state != null && (state.kind() == Kind.RELOAD_FAILED || state.stale() > 0); } /** The state on the bottom border of the source panel, left of the cursor position of the editor. */ 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 8eb377e8f88f9..98b3d58f1c676 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 @@ -160,11 +160,13 @@ record ToEntry(String routeId, String toUri, String filePath, int toLine) { } }); sourceViewer.setOnFileCreated(this::refreshFiles); - // the live run data of the selected integration at the end of the lines of its source + // the live run data of the selected integration at the end of the lines of its source, and the body types of + // its steps; dimmed and left out while it runs another version of the file (CAMEL-25897) sourceViewer.liveColumn().setLiveRunData(path -> { IntegrationInfo info = ctx.findSelectedIntegration(); return info != null ? LiveRunLines.of(info.routes, path) : Map.of(); }); + sourceViewer.liveColumn().setStale(runState::stale); // the exception of the last failure on a line, for fix with AI (Shift+F8) on a line that fails at runtime sourceViewer.liveColumn().setLineFailures((path, line) -> { IntegrationInfo info = ctx.findSelectedIntegration(); @@ -1192,10 +1194,13 @@ private void renderSourcePanel(Frame frame, Rect area) { sourceViewer.setFocused(focusOnViewer); sourceViewer.render(frame, area); // whether the integration runs the file shown as it is saved, in the view and in the editor (CAMEL-25771) - runState.shown(sourceViewer.getCurrentFilePath()); + if (runState.shown(sourceViewer.getCurrentFilePath(), sourceViewer.isDirty())) { + // another file, or a save: its run state now, rather than at the next refresh + ctx.backgroundExecutor.execute(() -> runState.refresh(ctx.findSelectedIntegration())); + } runState.render(frame, area); } else { - runState.shown(null); + runState.shown(null, false); List lines = emptySourceLines(routeIndex, rootDir); frame.renderWidget( @@ -1625,8 +1630,8 @@ ProjectBeans projectBeans() { } /** - * The quick doc of a line with what the project knows about it put first: the values of its property placeholders - * and where the bean it refers to is declared. + * The quick doc of a line with what the project knows about it put first: the body types its steps have been + * reached with, the values of its property placeholders and where the bean it refers to is declared. */ private SourceViewer.EditQuickDocProvider withProjectDocs(SourceViewer.EditQuickDocProvider provider) { return (lines, row) -> { @@ -1634,7 +1639,13 @@ private SourceViewer.EditQuickDocProvider withProjectDocs(SourceViewer.EditQuick if (row < 0 || row >= lines.size()) { return base; } - List extra = new ArrayList<>(assist.placeholderDocs(lines.get(row))); + List extra = new ArrayList<>(); + // the body types the steps of the line have been reached with, while it runs as shown (CAMEL-25897) + SourceViewer.DocEntry bodyTypes = sourceViewer.liveColumn().bodyTypesDoc(row); + if (bodyTypes != null) { + extra.add(bodyTypes); + } + extra.addAll(assist.placeholderDocs(lines.get(row))); ProjectBeans.Location bean = projectBeans().refOn(lines.get(row)); if (bean != null) { extra.add(new SourceViewer.DocEntry(ProjectBeans.describe(bean), false, "Bean")); 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 284e56c1100c2..1e9c03f6bbc3c 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 @@ -198,7 +198,8 @@ public void markDirty() { dirty = true; } }); - private final SourceLiveColumn liveColumn = new SourceLiveColumn(() -> this.loadedFilePath, () -> this.dirty); + private final SourceLiveColumn liveColumn + = new SourceLiveColumn(() -> this.loadedFilePath, () -> this.dirty, () -> this.lines); private final SourceRefactorActions refactor = new SourceRefactorActions(editState, new SourceRefactorActions.Editor() { @Override public Path editableFile() { @@ -2931,9 +2932,7 @@ private Line highlightSourceLine(String raw, int lineIndex, int hSkip, boolean i ? Span.styled(" ⚠ ", Theme.warning().bold()) : Span.styled(" ✗ ", Theme.error().bold())); } else { - spans.add(isDeprecated - ? Span.styled(" ⚠ ", Theme.warning()) - : Span.raw(" ")); + spans.add(isDeprecated ? Span.styled(" ⚠ ", Theme.warning()) : liveColumn.gutter(lineIndex)); } if (!prefix.isEmpty()) { spans.add(Span.styled(prefix, Style.EMPTY.dim())); @@ -2950,6 +2949,7 @@ private Line highlightSourceLine(String raw, int lineIndex, int hSkip, boolean i } spans.add(Span.styled(" ↵ " + jl.routeId(), linkStyle)); } + liveColumn.addBodyTypes(spans, lineIndex, plainMode, isSelected ? selBg : null, viewportWidth - 1 + hSkip); Line full = Line.from(spans); if (hSkip > 0) { diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/source.md b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/source.md index 563f893b0ca88..f3bf3049ec5e6 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/source.md +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/source.md @@ -47,6 +47,18 @@ processors on each line do: the exchanges they handled (`79`), how many failed its width, so the code does not move as the numbers grow. The source reads as a heat map of the route: where messages go, where they fail. +With the message history on (such as `camel run --dev`), the body types each +step has been reached with go at the end of its line, dimmed and lined up +(`ByteArrayInputStreamCache`, `String | byte[]`). A step reached with bodies of +more than one kind (null, payload, data or collection) needs attention, as its +body depends on the path a message takes: it has a `⚠` in the gutter and its +types in the warning color (`⚠ byte[] | LinkedHashMap (payload and data)`). The quick +documentation of the line lists them all. + +The live run data and the body types are of the version of the file the +integration runs: while it is not running as saved (see below), the numbers +are dimmed and the body types are left out, as the lines may be others. + ## Running as Saved The bottom border of the source says whether the integration runs the routes of the file as it is saved: @@ -57,6 +69,9 @@ of the file as it is saved: reload the file - `⚠ the reload failed` — the integration runs what it ran before the reload, until a reload succeeds +- `○ the routes of this file are stopped` — stopped (or suspended) by hand: the + live run data and the body types still show when the integration loaded the + file as saved, which is not known for a route stopped after a reload It compares when the file was saved with when the integration loaded the routes it has from it, and shows nothing for a file it has no route from. diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceBodyTypesTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceBodyTypesTest.java new file mode 100644 index 0000000000000..3487ad7eee8ec --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceBodyTypesTest.java @@ -0,0 +1,303 @@ +/* + * 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.nio.file.attribute.FileTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import dev.tamboui.buffer.Buffer; +import dev.tamboui.layout.Rect; +import dev.tamboui.terminal.Frame; +import dev.tamboui.tui.event.KeyEvent; +import org.apache.camel.dsl.jbang.core.commands.tui.SourceLiveColumn.LiveLine; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The body types each step has been reached with, at the end of its line in the source view, with a ⚠ in the gutter of + * a step reached with bodies of more than one kind (CAMEL-25897). + */ +class SourceBodyTypesTest { + + private static final String ROUTE = """ + - route: + id: orders + from: + uri: timer:orders + steps: + - unmarshal: + json: {} + - choice: + when: + - simple: "${body[priority]} == 1" + steps: + - marshal: + json: {} + - to: + uri: log:outbox + """; + + private static final int UNMARSHAL = 5; + private static final int CHOICE = 7; + private static final int TO = 13; + + @TempDir + Path dir; + + private final Map data = new HashMap<>(); + private SourceViewer viewer; + private Path file; + + @BeforeEach + void open() throws Exception { + Theme.resetForTesting(); + file = Files.writeString(dir.resolve("orders.camel.yaml"), ROUTE); + viewer = new SourceViewer(); + viewer.loadFile(file); + viewer.liveColumn().setLiveRunData(path -> data); + data.put(UNMARSHAL, line(List.of("org.apache.camel.converter.stream.ByteArrayInputStreamCache"), List.of("payload"), + false)); + data.put(CHOICE, line(List.of("java.util.LinkedHashMap"), List.of("data"), false)); + data.put(TO, line(List.of("byte[]", "java.util.LinkedHashMap"), List.of("payload", "data"), true)); + viewer.liveColumn().refreshLiveLinesForTesting(); + } + + @Test + void theBodyTypesOfAStepAtTheEndOfItsLineLinedUp() { + String[] rows = render(120).split("\n"); + + String unmarshal = row(rows, "- unmarshal:"); + String choice = row(rows, "- choice:"); + String to = row(rows, "- to:"); + assertThat(unmarshal).contains("ByteArrayInputStreamCache"); + assertThat(choice).contains("LinkedHashMap"); + assertThat(to).contains(BodyTypes.MARK + " byte[] | LinkedHashMap (payload and data)"); + // in a column of their own + assertThat(unmarshal.indexOf("ByteArrayInputStreamCache")) + .isEqualTo(choice.indexOf("LinkedHashMap")) + .isEqualTo(to.indexOf(BodyTypes.MARK + " byte[]")); + // the step reached with bodies of more than one kind has a ⚠ in the gutter, the others not + assertThat(to.substring(0, 4)).contains(BodyTypes.MARK); + assertThat(unmarshal.substring(0, 4)).doesNotContain(BodyTypes.MARK); + // a line without a step has nothing + assertThat(row(rows, "json: {}")).doesNotContain("ByteArrayInputStreamCache").doesNotContain("LinkedHashMap"); + } + + @Test + void theTypesAreCutToTheRoomThereIs() { + String to = row(render(56).split("\n"), "- to:"); + + assertThat(to).contains(BodyTypes.MARK + " byte[]").doesNotContain("(payload and data)"); + } + + @Test + void aCutNameKeepsItsEllipsisAndTheCountItsDigits() { + // the last column of the view is the scrollbar's: what is cut ends before it + boolean cut = false; + for (int width = 44; width <= 72; width++) { + String[] rows = render(width).split("\n"); + String unmarshal = row(rows, "- unmarshal:"); + if (unmarshal.contains("ByteArr") && !unmarshal.contains("ByteArrayInputStreamCache")) { + assertThat(unmarshal).as("width %d", width).contains("…"); + cut = true; + } + String to = row(rows, "- to:"); + if (to.contains(" +")) { + assertThat(to).as("width %d", width).contains(" +1"); + } + } + assertThat(cut).isTrue(); + } + + @Test + void anotherFileHasItsOwnLiveRunDataAtOnce() throws Exception { + Path other = Files.writeString(dir.resolve("stock.camel.yaml"), "- from:\n uri: timer:stock\n"); + Map> byFile = Map.of( + file.toString(), Map.of(0, new LiveLine(120, 0, 0)), + other.toString(), Map.of(0, new LiveLine(7, 0, 0))); + viewer.liveColumn().setLiveRunData(byFile::get); + viewer.liveColumn().refreshLiveLinesForTesting(); + assertThat(viewer.liveColumn().liveColumnForTesting(0)).contains("120"); + + // not the numbers of the file shown before for up to a second + viewer.loadFile(other); + assertThat(viewer.liveColumn().liveColumnForTesting(0)).contains("7").doesNotContain("120"); + } + + @Test + void anotherFileIsStaleUntilItsStateIsKnown() throws Exception { + Path other = Files.writeString(dir.resolve("stock.camel.yaml"), "- from:\n uri: timer:stock\n"); + long now = System.currentTimeMillis(); + Files.setLastModifiedTime(file, FileTime.fromMillis(now - 3_600_000)); + IntegrationInfo info = new IntegrationInfo(); + info.uptime = now - 60_000; + RouteInfo route = new RouteInfo(); + route.routeId = "orders"; + route.source = "orders.camel.yaml:1"; + route.state = "Started"; + route.uptime = "59s"; + info.routes.add(route); + SourceRunState.Tracker tracker = new SourceRunState.Tracker(); + + // shown: computed at once + assertThat(tracker.shown(file.toString(), false)).isTrue(); + assertThat(tracker.stale()).isTrue(); + tracker.refresh(info); + assertThat(tracker.stale()).isFalse(); + assertThat(tracker.shown(file.toString(), false)).isFalse(); + + // another file: not known until computed, then known to have no route of the integration + assertThat(tracker.shown(other.toString(), false)).isTrue(); + assertThat(tracker.stale()).isTrue(); + tracker.refresh(info); + assertThat(tracker.current()).isNull(); + assertThat(tracker.stale()).isFalse(); + + // a save is computed at once too + assertThat(tracker.shown(other.toString(), true)).isFalse(); + assertThat(tracker.shown(other.toString(), false)).isTrue(); + } + + @Test + void nothingInThePlainMode() { + viewer.handleKeyEvent(KeyEvent.ofChar('p')); + + String rendered = render(120); + + assertThat(rendered).contains("- unmarshal:").doesNotContain("ByteArrayInputStreamCache"); + } + + @Test + void nothingWhileTheIntegrationRunsAnotherVersionOfTheFile() { + data.put(TO, new LiveLine(10, 2, 0, List.of("byte[]", "java.util.LinkedHashMap"), List.of("payload", "data"), true)); + viewer.liveColumn().refreshLiveLinesForTesting(); + assertThat(viewer.liveColumn().failsAtRuntime(TO)).isTrue(); + + // saved after the integration loaded it: the lines it ran may be others + viewer.liveColumn().setStale(() -> true); + String[] rows = render(120).split("\n"); + + assertThat(row(rows, "- unmarshal:")).doesNotContain("ByteArrayInputStreamCache"); + String to = row(rows, "- to:"); + assertThat(to).doesNotContain("byte[]"); + assertThat(to.substring(0, 4)).doesNotContain(BodyTypes.MARK); + // the live run data stays, dimmed, and no line is offered to fix as failing at runtime + assertThat(to).contains("10"); + assertThat(viewer.liveColumn().failsAtRuntime(TO)).isFalse(); + assertThat(viewer.liveColumn().bodyTypesDoc(TO)).isNull(); + } + + @Test + void theQuickDocumentationHasThemAll() { + SourceViewer.DocEntry to = viewer.liveColumn().bodyTypesDoc(TO); + assertThat(to.title()).isEqualTo("Body types"); + assertThat(to.text()).isEqualTo("byte[] | LinkedHashMap " + BodyTypes.MARK + + " payload and data: the body here depends on the path a message takes"); + + SourceViewer.DocEntry unmarshal = viewer.liveColumn().bodyTypesDoc(UNMARSHAL); + assertThat(unmarshal.title()).isEqualTo("Body types (payload)"); + assertThat(unmarshal.text()).isEqualTo("ByteArrayInputStreamCache"); + + assertThat(viewer.liveColumn().bodyTypesDoc(UNMARSHAL + 1)).isNull(); + } + + @Test + void theStepsOfALineTogether() { + RouteInfo route = new RouteInfo(); + route.source = "MyRoute.java:10"; + // two steps on one line, each with a kind of its own: the line is not mixed + route.processors.add(processor("MyRoute.java:12", List.of("java.lang.String"), List.of("payload"))); + route.processors.add(processor("MyRoute.java:12", List.of("java.util.LinkedHashMap"), List.of("data"))); + route.processors.add(processor("MyRoute.java:14", List.of("byte[]", "java.util.LinkedHashMap"), + List.of("payload", "data"))); + route.processors.add(processor("MyRoute.java:15", List.of(), List.of())); + + Map lines = LiveRunLines.of(List.of(route), "/work/MyRoute.java"); + + assertThat(lines.get(11).bodyTypes()).containsExactly("java.lang.String", "java.util.LinkedHashMap"); + assertThat(lines.get(11).mixed()).isFalse(); + assertThat(lines.get(13).mixed()).isTrue(); + assertThat(lines.get(14).bodyTypes()).isEmpty(); + assertThat(lines.get(14)).isEqualTo(new LiveLine(0, 0, 0)); + } + + @Test + void aSaveInTheEditorLeavesThemOutUntilTheIntegrationIsSeenToLoadIt() throws Exception { + long now = System.currentTimeMillis(); + Files.setLastModifiedTime(file, FileTime.fromMillis(now - 3_600_000)); + IntegrationInfo info = new IntegrationInfo(); + info.uptime = now - 60_000; + RouteInfo route = new RouteInfo(); + route.routeId = "orders"; + route.source = "orders.camel.yaml:1"; + route.state = "Started"; + route.uptime = "59s"; + info.routes.add(route); + SourceRunState.Tracker tracker = new SourceRunState.Tracker(); + tracker.shown(file.toString(), false); + tracker.refresh(info); + assertThat(tracker.stale()).isFalse(); + assertThat(tracker.current().kind()).isEqualTo(SourceRunState.Kind.RUNNING); + + // edited, then saved + tracker.shown(file.toString(), true); + tracker.shown(file.toString(), false); + assertThat(tracker.stale()).isTrue(); + assertThat(tracker.current()).isNull(); + + // the next refresh knows again: saved now, after the integration loaded the file + Files.setLastModifiedTime(file, FileTime.fromMillis(now)); + tracker.refresh(info); + assertThat(tracker.current().kind()).isEqualTo(SourceRunState.Kind.NOT_RUNNING); + assertThat(tracker.stale()).isTrue(); + } + + private String render(int width) { + Rect area = new Rect(0, 0, width, 24); + Buffer buffer = Buffer.empty(area); + viewer.render(Frame.forTesting(buffer), area); + return TuiTestHelper.bufferToString(buffer); + } + + private static String row(String[] rows, String code) { + for (String row : rows) { + if (row.contains(code)) { + return row; + } + } + throw new AssertionError("No row with " + code); + } + + private static LiveLine line(List types, List kinds, boolean mixed) { + return new LiveLine(10, 0, 0, types, kinds, mixed); + } + + private static ProcessorInfo processor(String source, List bodyTypes, List bodyKinds) { + ProcessorInfo proc = new ProcessorInfo(); + proc.source = source; + proc.bodyTypes = bodyTypes; + proc.bodyKinds = bodyKinds; + return proc; + } +} diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceRunStateTest.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceRunStateTest.java index 9539dea0cc036..983dd5d9ba97f 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceRunStateTest.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/SourceRunStateTest.java @@ -132,9 +132,8 @@ void aFailedReloadPutsBackWhatRanBefore() { } @Test - void onlyTheStartedRoutesOfTheFile() { + void onlyTheRoutesOfTheFile() { route("stock", "stock.camel.yaml:3", "59s"); - route("orders", "orders.camel.yaml:3", "59s").state = "Stopped"; assertThat(SourceRunState.of(info, FILE, NOW - 10_000, NOW)).isNull(); @@ -142,6 +141,28 @@ void onlyTheStartedRoutesOfTheFile() { assertThat(SourceRunState.of(info, "/work/README.md", NOW - 10_000, NOW)).isNull(); } + @Test + void aFileWhoseRoutesAreStopped() { + RouteInfo orders = route("orders", "orders.camel.yaml:3", ""); + orders.state = "Stopped"; + + // loaded when the integration started, as saved: what its lines say holds + SourceRunState.State state = SourceRunState.of(info, FILE, NOW - 3_600_000, NOW); + assertThat(state.kind()).isEqualTo(SourceRunState.Kind.STOPPED); + assertThat(state.stale()).isZero(); + assertThat(text(SourceRunState.line(state))).isEqualTo(TuiIcons.IDLE + " the routes of this file are stopped"); + assertThat(SourceRunState.toJson(state).getString("state")).isEqualTo("stopped"); + + // saved since: the saved file is not running, stopped or not + assertThat(SourceRunState.of(info, FILE, NOW - 10_000, NOW).kind()).isEqualTo(SourceRunState.Kind.NOT_RUNNING); + + // stopped after a reload: when it was loaded is not known + info.reloaded = 1; + state = SourceRunState.of(info, FILE, NOW - 3_600_000, NOW); + assertThat(state.kind()).isEqualTo(SourceRunState.Kind.STOPPED); + assertThat(state.stale()).isEqualTo(1); + } + @Test void theUptimeOfARoute() { assertThat(SourceRunState.uptimeMillis("0s")).isZero();