This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch feature/CAMEL-25897-source-body-types
in repository https://gitbox.apache.org/repos/asf/camel.git

commit 8a4c917d63390d9dfd6bc2eee16526e27682e47a
Author: Claus Ibsen <[email protected]>
AuthorDate: Wed Oct 21 11:23:37 2026 +0200

    CAMEL-25897: camel-jbang - the source view shows the body types each step 
has been reached with at the end of its line
    
    With the message history on, such as with camel run --dev, the body types
    each step of a running route has been reached with go at the end of its line
    in the source view of the Source tab, dimmed, lined up, and cut to the room
    there is. A step reached with bodies of more than one kind (null, payload,
    data or collection) has a ⚠ in the gutter, and its types in the warning
    color. The quick documentation of the line lists all the types, and for a 
mix
    of kinds says that the body there depends on the path a message takes.
    
    The facts of a line only hold while the view has the version of the file
    that runs. The live run data is dimmed, and the body types are left out:
    - while the integration does not run the file as saved (saved after it
      loaded it, reloading, or the reload failed);
    - after a save in the editor, until the next refresh knows the state again.
    Keeping the body types of the routes an edit did not change would need the
    checksums of the routes of the edited file, which the TUI cannot compute
    without loading the file with Camel.
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
    Signed-off-by: Claus Ibsen <[email protected]>
---
 .../images/jbang/camel-tui-source-body-types.png   | Bin 0 -> 366120 bytes
 .../ROOT/pages/camel-jbang-tui-diagram.adoc        |   3 +-
 .../ROOT/pages/camel-jbang-tui-source-editor.adoc  |  19 +-
 .../dsl/jbang/core/commands/tui/LiveRunLines.java  |  43 +++-
 .../jbang/core/commands/tui/SourceLiveColumn.java  | 131 +++++++++++-
 .../jbang/core/commands/tui/SourceRunState.java    |  39 +++-
 .../dsl/jbang/core/commands/tui/SourceTab.java     |  20 +-
 .../dsl/jbang/core/commands/tui/SourceViewer.java  |   8 +-
 .../src/main/resources/tui/help/source.md          |  12 ++
 .../core/commands/tui/SourceBodyTypesTest.java     | 235 +++++++++++++++++++++
 10 files changed, 488 insertions(+), 22 deletions(-)

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 000000000000..8a30469ce191
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 1d485d1193e2..77068cd2719a 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 55a7117b77c2..6554e80d54f9 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:
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 a65e5aec6a92..fafddd111129 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 @@ final class 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<Integer, SourceLiveColumn.LiveLine> of(List<RouteInfo> routes, 
String filePath) {
         Map<Integer, long[]> sums = new HashMap<>();
+        Map<Integer, LineTypes> types = new HashMap<>();
         String name = nameOf(filePath);
         if (routes == null || name == null) {
             return Map.of();
@@ -46,13 +50,48 @@ final class LiveRunLines {
             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<Integer, SourceLiveColumn.LiveLine> 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<String> types = new LinkedHashSet<>();
+        final Set<String> 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<Integer, LineTypes> 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<Integer, long[]> 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 26036dafb4e7..c4f0c9f938ec 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.BooleanSupplier;
 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.
+ * <p/>
+ * 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<String> 
bodyTypes, List<String> 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 @@ class SourceLiveColumn {
 
     private final Supplier<String> loadedFile;
     private final BooleanSupplier dirty;
+    private final Supplier<List<String>> 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<Integer, LiveLine> liveLines = Collections.emptyMap();
@@ -66,10 +87,26 @@ class SourceLiveColumn {
     /**
      * @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<String> loadedFile, BooleanSupplier dirty) {
+    SourceLiveColumn(Supplier<String> loadedFile, BooleanSupplier dirty, 
Supplier<List<String>> 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();
     }
 
     /** The path of the file shown, whose live run data is read; null when 
none is. */
@@ -110,7 +147,7 @@ class SourceLiveColumn {
      * 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);
@@ -135,6 +172,11 @@ class SourceLiveColumn {
         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);
@@ -150,6 +192,61 @@ class SourceLiveColumn {
         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<Span> 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<Integer, LiveLine> liveLines() {
         String loadedFilePath = loadedFile.get();
@@ -203,6 +300,32 @@ class SourceLiveColumn {
                     }
                 }
             }
+            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<String> shown = lines.get();
+        int longest = 0;
+        for (Map.Entry<Integer, LiveLine> 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 76ee51aaed46..36c645db12a0 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
@@ -181,17 +181,31 @@ final class SourceRunState {
 
         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 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.
+         */
+        void shown(String file, boolean dirty) {
+            if (wasDirty && !dirty) {
+                saves++;
+            }
+            wasDirty = dirty;
             this.file = file;
         }
 
         /** Computes the state of the file shown, on the background thread of 
the data refresh. */
         void refresh(IntegrationInfo info) {
+            int at = saves;
             String shown = file;
             if (shown == null || info == null) {
                 computed = null;
+                computedSaves = at;
                 return;
             }
             try {
@@ -202,18 +216,35 @@ final class SourceRunState {
             } catch (IOException | RuntimeException e) {
                 computed = null;
             }
+            computedSaves = at;
         }
 
         void reset() {
             file = null;
             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: saved after it loaded it, or
+         * saved in the editor since the state was computed. The live run data 
is of other lines then (CAMEL-25897).
+         */
+        boolean stale() {
+            if (computedSaves != saves) {
+                return true;
+            }
+            State state = current();
+            return state != null && state.kind() != Kind.RUNNING;
         }
 
         /** 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 4d69f835c107..c4c408df8911 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 @@ class SourceTab extends AbstractTab {
             }
         });
         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();
@@ -1188,10 +1190,10 @@ class SourceTab extends AbstractTab {
             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.liveColumn().loadedFile());
+            runState.shown(sourceViewer.liveColumn().loadedFile(), 
sourceViewer.isDirty());
             runState.render(frame, area);
         } else {
-            runState.shown(null);
+            runState.shown(null, false);
             List<Line> lines = emptySourceLines(routeIndex, rootDir);
 
             frame.renderWidget(
@@ -1621,8 +1623,8 @@ class SourceTab extends AbstractTab {
     }
 
     /**
-     * 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) -> {
@@ -1630,7 +1632,13 @@ class SourceTab extends AbstractTab {
             if (row < 0 || row >= lines.size()) {
                 return base;
             }
-            List<SourceViewer.DocEntry> extra = new 
ArrayList<>(assist.placeholderDocs(lines.get(row)));
+            List<SourceViewer.DocEntry> 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 284e56c1100c..c7cb664ee176 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 @@ class SourceViewer {
             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 @@ class SourceViewer {
                         ? 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 @@ class SourceViewer {
             }
             spans.add(Span.styled(" ↵ " + jl.routeId(), linkStyle));
         }
+        liveColumn.addBodyTypes(spans, lineIndex, plainMode, isSelected ? 
selBg : null, viewportWidth + 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 563f893b0ca8..c587ba7f9349 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:
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 000000000000..8603f2e278b2
--- /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,235 @@
+/*
+ * 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<Integer, LiveLine> 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 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<Integer, LiveLine> 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<String> types, List<String> kinds, 
boolean mixed) {
+        return new LiveLine(10, 0, 0, types, kinds, mixed);
+    }
+
+    private static ProcessorInfo processor(String source, List<String> 
bodyTypes, List<String> bodyKinds) {
+        ProcessorInfo proc = new ProcessorInfo();
+        proc.source = source;
+        proc.bodyTypes = bodyTypes;
+        proc.bodyKinds = bodyKinds;
+        return proc;
+    }
+}

Reply via email to