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