This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch CAMEL-23648-tui-send-fullscreen in repository https://gitbox.apache.org/repos/asf/camel.git
commit ffb2d12ceaf87a28cb7f031c4e2f908a3f0b083e Author: Claus Ibsen <[email protected]> AuthorDate: Sun May 31 08:38:30 2026 +0200 CAMEL-23648: camel-jbang - TUI redesign F2 Send Message as full-screen view Redesign SendMessagePopup from a small centered popup to a full-screen 3-panel layout (request/response/history) matching the HTTP Probe pattern. InOut responses now show exchange headers and scrollable body with pretty print toggle. Adds send history with replay support. Extract shared form utilities (HeaderEntry, handleTextInput, renderLabel, handlePaste) into FormHelper to eliminate duplication between SendMessagePopup and HttpTab. Add Clear + padding to both views for visual separation. Co-Authored-By: Claude Opus 4.6 <[email protected]> --- .../dsl/jbang/core/commands/tui/ActionsPopup.java | 4 + .../dsl/jbang/core/commands/tui/FormHelper.java | 72 +++ .../camel/dsl/jbang/core/commands/tui/HttpTab.java | 127 ++-- .../jbang/core/commands/tui/SendMessagePopup.java | 638 ++++++++++++++++----- 4 files changed, 627 insertions(+), 214 deletions(-) diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopup.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopup.java index 82fdea42df6e..cc8f3b4dfaf0 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopup.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopup.java @@ -589,6 +589,10 @@ class ActionsPopup { } void renderFooter(List<Span> spans) { + if (sendMessagePopup.isVisible()) { + sendMessagePopup.renderFooter(spans); + return; + } if (captionOverlay.isInlineMode()) { captionOverlay.renderFooter(spans); return; diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FormHelper.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FormHelper.java new file mode 100644 index 000000000000..98db646f408f --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FormHelper.java @@ -0,0 +1,72 @@ +/* + * 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 dev.tamboui.layout.Rect; +import dev.tamboui.style.Style; +import dev.tamboui.terminal.Frame; +import dev.tamboui.text.Line; +import dev.tamboui.text.Span; +import dev.tamboui.tui.event.KeyCode; +import dev.tamboui.tui.event.KeyEvent; +import dev.tamboui.widgets.input.TextInputState; +import dev.tamboui.widgets.paragraph.Paragraph; + +final class FormHelper { + + private FormHelper() { + } + + record HeaderEntry(TextInputState keyInput, TextInputState valueInput) { + } + + static void handleTextInput(KeyEvent ke, TextInputState state) { + if (ke.isDeleteBackward()) { + state.deleteBackward(); + } else if (ke.isDeleteForward()) { + state.deleteForward(); + } else if (ke.isLeft()) { + state.moveCursorLeft(); + } else if (ke.isRight()) { + state.moveCursorRight(); + } else if (ke.isHome()) { + state.moveCursorToStart(); + } else if (ke.isEnd()) { + state.moveCursorToEnd(); + } else if (ke.code() == KeyCode.CHAR) { + state.insert(ke.character()); + } + } + + static void renderLabel(Frame frame, int x, int y, int w, String label, boolean selected) { + Style style = selected ? Style.EMPTY.bold() : Style.EMPTY.dim(); + Rect labelArea = new Rect(x, y, w, 1); + frame.renderWidget(Paragraph.from(Line.from(Span.styled(label, style))), labelArea); + } + + static void handlePaste(String text, TextInputState target) { + if (text == null || text.isEmpty() || target == null) { + return; + } + for (int i = 0; i < text.length(); i++) { + char ch = text.charAt(i); + if (ch != '\n' && ch != '\r') { + target.insert(ch); + } + } + } +} diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HttpTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HttpTab.java index 23c769132f08..7f7938addd1b 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HttpTab.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HttpTab.java @@ -43,6 +43,7 @@ import dev.tamboui.text.Span; import dev.tamboui.text.Text; import dev.tamboui.tui.event.KeyCode; import dev.tamboui.tui.event.KeyEvent; +import dev.tamboui.widgets.Clear; import dev.tamboui.widgets.block.Block; import dev.tamboui.widgets.block.BorderType; import dev.tamboui.widgets.block.Title; @@ -99,7 +100,7 @@ class HttpTab implements MonitorTab { private int probeMethodIndex; private final TextInputState probePathState = new TextInputState(""); private final TextInputState probeBodyState = new TextInputState(""); - private List<HeaderEntry> probeHeaders; + private List<FormHelper.HeaderEntry> probeHeaders; private int probeSelectedHeader; private boolean probeEditingHeaderKey; private final AtomicBoolean probeSending = new AtomicBoolean(false); @@ -289,18 +290,10 @@ class HttpTab implements MonitorTab { } void handlePaste(String text) { - if (!probeMode || probeSending.get() || text == null || text.isEmpty()) { + if (!probeMode || probeSending.get()) { return; } - TextInputState target = probeActiveTextInput(); - if (target != null) { - for (int i = 0; i < text.length(); i++) { - char ch = text.charAt(i); - if (ch != '\n' && ch != '\r') { - target.insert(ch); - } - } - } + FormHelper.handlePaste(text, probeActiveTextInput()); } // ---- Probe mode ---- @@ -374,7 +367,7 @@ class HttpTab implements MonitorTab { for (int i = 0; i < value.length(); i++) { valState.insert(value.charAt(i)); } - probeHeaders.add(new HeaderEntry(keyState, valState)); + probeHeaders.add(new FormHelper.HeaderEntry(keyState, valState)); } private boolean handleProbeKeyEvent(KeyEvent ke) { @@ -453,7 +446,7 @@ class HttpTab implements MonitorTab { addProbeHeaderEmpty(); return true; } - handleTextInput(ke, probePathState); + FormHelper.handleTextInput(ke, probePathState); return true; } if (probeField == PROBE_HEADERS) { @@ -482,7 +475,7 @@ class HttpTab implements MonitorTab { addProbeHeaderEmpty(); return true; } - handleTextInput(ke, probeBodyState); + FormHelper.handleTextInput(ke, probeBodyState); return true; } if (probeField == PROBE_HISTORY) { @@ -510,8 +503,8 @@ class HttpTab implements MonitorTab { } private boolean handleProbeHeaderKeyEvent(KeyEvent ke) { - HeaderEntry current = probeHeaders.get(probeSelectedHeader); - TextInputState activeInput = probeEditingHeaderKey ? current.keyInput : current.valueInput; + FormHelper.HeaderEntry current = probeHeaders.get(probeSelectedHeader); + TextInputState activeInput = probeEditingHeaderKey ? current.keyInput() : current.valueInput(); if (ke.isChar('+')) { addProbeHeaderEmpty(); @@ -542,7 +535,7 @@ class HttpTab implements MonitorTab { return true; } if (ke.isDeleteBackward()) { - if (probeEditingHeaderKey && current.keyInput.text().isEmpty()) { + if (probeEditingHeaderKey && current.keyInput().text().isEmpty()) { probeHeaders.remove(probeSelectedHeader); if (probeHeaders.isEmpty()) { probeHeaders = null; @@ -594,7 +587,7 @@ class HttpTab implements MonitorTab { if (probeHeaders == null) { probeHeaders = new ArrayList<>(); } - probeHeaders.add(new HeaderEntry(new TextInputState(""), new TextInputState(""))); + probeHeaders.add(new FormHelper.HeaderEntry(new TextInputState(""), new TextInputState(""))); probeField = PROBE_HEADERS; probeSelectedHeader = probeHeaders.size() - 1; probeEditingHeaderKey = true; @@ -612,8 +605,8 @@ class HttpTab implements MonitorTab { return probeBodyState; } if (probeField == PROBE_HEADERS && hasProbeHeaders()) { - HeaderEntry he = probeHeaders.get(probeSelectedHeader); - return probeEditingHeaderKey ? he.keyInput : he.valueInput; + FormHelper.HeaderEntry he = probeHeaders.get(probeSelectedHeader); + return probeEditingHeaderKey ? he.keyInput() : he.valueInput(); } return null; } @@ -638,8 +631,8 @@ class HttpTab implements MonitorTab { } probeHeaders = null; if (entry.headers != null) { - for (HeaderEntry he : entry.headers) { - addProbeHeader(he.keyInput.text(), he.valueInput.text()); + for (FormHelper.HeaderEntry he : entry.headers) { + addProbeHeader(he.keyInput().text(), he.valueInput().text()); } } probeField = PROBE_BODY; @@ -677,16 +670,16 @@ class HttpTab implements MonitorTab { String baseUrl = probeBaseUrl; // Snapshot headers - List<HeaderEntry> headerSnapshot = null; + List<FormHelper.HeaderEntry> headerSnapshot = null; if (hasProbeHeaders()) { headerSnapshot = new ArrayList<>(); - for (HeaderEntry he : probeHeaders) { - headerSnapshot.add(new HeaderEntry( - new TextInputState(he.keyInput.text()), - new TextInputState(he.valueInput.text()))); + for (FormHelper.HeaderEntry he : probeHeaders) { + headerSnapshot.add(new FormHelper.HeaderEntry( + new TextInputState(he.keyInput().text()), + new TextInputState(he.valueInput().text()))); } } - List<HeaderEntry> hdrs = headerSnapshot; + List<FormHelper.HeaderEntry> hdrs = headerSnapshot; ctx.runner.scheduler().execute(() -> { try { @@ -698,7 +691,7 @@ class HttpTab implements MonitorTab { } private void doProbeRequestInBackground( - String baseUrl, String method, String path, String body, List<HeaderEntry> hdrs) { + String baseUrl, String method, String path, String body, List<FormHelper.HeaderEntry> hdrs) { String url = baseUrl + path; String statusText; @@ -725,9 +718,9 @@ class HttpTab implements MonitorTab { // Add user headers if (hdrs != null) { - for (HeaderEntry he : hdrs) { - String k = he.keyInput.text().trim(); - String v = he.valueInput.text(); + for (FormHelper.HeaderEntry he : hdrs) { + String k = he.keyInput().text().trim(); + String v = he.valueInput().text(); if (!k.isEmpty()) { reqBuilder.header(k, v); } @@ -770,7 +763,7 @@ class HttpTab implements MonitorTab { } // Build history entry - List<HeaderEntry> histHeaders = null; + List<FormHelper.HeaderEntry> histHeaders = null; if (hdrs != null && !hdrs.isEmpty()) { histHeaders = new ArrayList<>(hdrs); } @@ -860,6 +853,14 @@ class HttpTab implements MonitorTab { // ---- Probe rendering ---- private void renderProbe(Frame frame, Rect area) { + frame.renderWidget(Clear.INSTANCE, area); + + int padX = 2; + int padY = 1; + Rect inner = new Rect( + area.left() + padX, area.top() + padY, + area.width() - padX * 2, area.height() - padY * 2); + int headerCount = hasProbeHeaders() ? probeHeaders.size() : 0; int requestHeight = 7 + headerCount + (headerCount > 0 ? 1 : 0); int historyHeight = Math.min(4 + 2, probeHistory.size() + 2); @@ -872,7 +873,7 @@ class HttpTab implements MonitorTab { Constraint.length(requestHeight), Constraint.fill(), Constraint.length(historyHeight)) - .split(area); + .split(inner); renderProbeRequest(frame, chunks.get(0)); renderProbeResponse(frame, chunks.get(1)); @@ -896,7 +897,7 @@ class HttpTab implements MonitorTab { int row = area.top() + 1; // Method selector - renderProbeLabel(frame, innerX, row, labelW, "Method:", probeField == PROBE_METHOD); + FormHelper.renderLabel(frame, innerX, row, labelW, "Method:", probeField == PROBE_METHOD); Rect methodArea = new Rect(innerX + labelW, row, fieldW, 1); Style methodSt = methodStyle(method); String leftArr = probeField == PROBE_METHOD ? "◀ " : " "; @@ -908,7 +909,7 @@ class HttpTab implements MonitorTab { // Full URL (read-only) row++; - renderProbeLabel(frame, innerX, row, labelW, "URL:", false); + FormHelper.renderLabel(frame, innerX, row, labelW, "URL:", false); String fullUrl = (probeBaseUrl != null ? probeBaseUrl : "") + probePathState.text(); Rect urlArea = new Rect(innerX + labelW, row, fieldW, 1); frame.renderWidget(Paragraph.from(Line.from( @@ -916,7 +917,7 @@ class HttpTab implements MonitorTab { // Path input row++; - renderProbeLabel(frame, innerX, row, labelW, "Path:", probeField == PROBE_PATH); + FormHelper.renderLabel(frame, innerX, row, labelW, "Path:", probeField == PROBE_PATH); Rect pathArea = new Rect(innerX + labelW, row, fieldW, 1); if (probeField == PROBE_PATH && !probeSending.get()) { TextInput textInput = TextInput.builder().cursorStyle(Style.EMPTY.reversed()).build(); @@ -937,18 +938,18 @@ class HttpTab implements MonitorTab { row++; boolean isSelected = probeField == PROBE_HEADERS && probeSelectedHeader == i; String label = i == 0 ? "Headers:" : ""; - renderProbeLabel(frame, innerX, row, labelW, label, + FormHelper.renderLabel(frame, innerX, row, labelW, label, isSelected || (i == 0 && probeField == PROBE_HEADERS)); - HeaderEntry he = probeHeaders.get(i); + FormHelper.HeaderEntry he = probeHeaders.get(i); int fieldX = innerX + labelW; Rect keyArea = new Rect(fieldX, row, keyW, 1); if (isSelected && probeEditingHeaderKey && !probeSending.get()) { TextInput keyInput = TextInput.builder().cursorStyle(Style.EMPTY.reversed()).build(); - frame.renderStatefulWidget(keyInput, keyArea, he.keyInput); + frame.renderStatefulWidget(keyInput, keyArea, he.keyInput()); } else { - String keyText = he.keyInput.text(); + String keyText = he.keyInput().text(); Style keyStyle = keyText.isEmpty() ? Style.EMPTY.dim() : isSelected ? Style.EMPTY.bold() : Style.EMPTY; frame.renderWidget(Paragraph.from(Line.from( @@ -962,9 +963,9 @@ class HttpTab implements MonitorTab { Rect valArea = new Rect(fieldX + keyW + 3, row, valW, 1); if (isSelected && !probeEditingHeaderKey && !probeSending.get()) { TextInput valInput = TextInput.builder().cursorStyle(Style.EMPTY.reversed()).build(); - frame.renderStatefulWidget(valInput, valArea, he.valueInput); + frame.renderStatefulWidget(valInput, valArea, he.valueInput()); } else { - String valText = he.valueInput.text(); + String valText = he.valueInput().text(); Style valStyle = valText.isEmpty() ? Style.EMPTY.dim() : isSelected ? Style.EMPTY.bold() : Style.EMPTY; frame.renderWidget(Paragraph.from(Line.from( @@ -975,7 +976,7 @@ class HttpTab implements MonitorTab { // Body input row++; - renderProbeLabel(frame, innerX, row, labelW, "Body:", probeField == PROBE_BODY); + FormHelper.renderLabel(frame, innerX, row, labelW, "Body:", probeField == PROBE_BODY); Rect bodyArea = new Rect(innerX + labelW, row, fieldW, 1); if (probeField == PROBE_BODY && !probeSending.get()) { TextInput textInput = TextInput.builder() @@ -1095,7 +1096,7 @@ class HttpTab implements MonitorTab { String statusStr = entry.error ? "ERR" : entry.statusText; String elapsedStr = entry.elapsed > 0 ? entry.elapsed + "ms" : ""; String bodySnippet = entry.body != null && !entry.body.isEmpty() - ? " " + truncate(entry.body, 30) + ? " " + TuiHelper.truncate(entry.body, 30) : ""; Style lineStyle = selected ? Style.EMPTY.bold() : Style.EMPTY; @@ -1116,12 +1117,6 @@ class HttpTab implements MonitorTab { area); } - private void renderProbeLabel(Frame frame, int x, int y, int w, String label, boolean selected) { - Style style = selected ? Style.EMPTY.bold() : Style.EMPTY.dim(); - Rect labelArea = new Rect(x, y, w, 1); - frame.renderWidget(Paragraph.from(Line.from(Span.styled(label, style))), labelArea); - } - private static Style statusStyle(String status) { if (status == null) { return Style.EMPTY.bold(); @@ -1545,31 +1540,6 @@ class HttpTab implements MonitorTab { return MonitorContext.sortStyle(column, sort); } - private static void handleTextInput(KeyEvent ke, TextInputState state) { - if (ke.isDeleteBackward()) { - state.deleteBackward(); - } else if (ke.isDeleteForward()) { - state.deleteForward(); - } else if (ke.isLeft()) { - state.moveCursorLeft(); - } else if (ke.isRight()) { - state.moveCursorRight(); - } else if (ke.isHome()) { - state.moveCursorToStart(); - } else if (ke.isEnd()) { - state.moveCursorToEnd(); - } else if (ke.code() == KeyCode.CHAR) { - state.insert(ke.character()); - } - } - - private static String truncate(String s, int max) { - if (s == null) { - return ""; - } - return s.length() <= max ? s : s.substring(0, max - 1) + "…"; - } - @Override public SelectionContext getSelectionContext() { IntegrationInfo info = ctx.findSelectedIntegration(); @@ -1584,12 +1554,9 @@ class HttpTab implements MonitorTab { return new SelectionContext("table", items, sel != null ? sel : -1, items.size(), "HTTP"); } - record HeaderEntry(TextInputState keyInput, TextInputState valueInput) { - } - record ProbeHistoryEntry( String method, String path, - List<HeaderEntry> headers, String body, + List<FormHelper.HeaderEntry> headers, String body, int statusCode, long elapsed, String statusText, boolean error) { } diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SendMessagePopup.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SendMessagePopup.java index 1201b06fa9e4..4c31ff7a5ce3 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SendMessagePopup.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SendMessagePopup.java @@ -19,15 +19,19 @@ package org.apache.camel.dsl.jbang.core.commands.tui; import java.io.File; import java.nio.file.Path; import java.util.ArrayList; +import java.util.Collection; import java.util.List; import java.util.concurrent.ScheduledExecutorService; +import dev.tamboui.layout.Constraint; +import dev.tamboui.layout.Layout; import dev.tamboui.layout.Rect; import dev.tamboui.style.Color; import dev.tamboui.style.Style; import dev.tamboui.terminal.Frame; import dev.tamboui.text.Line; import dev.tamboui.text.Span; +import dev.tamboui.text.Text; import dev.tamboui.tui.event.KeyCode; import dev.tamboui.tui.event.KeyEvent; import dev.tamboui.widgets.Clear; @@ -37,16 +41,22 @@ import dev.tamboui.widgets.block.Title; import dev.tamboui.widgets.input.TextInput; import dev.tamboui.widgets.input.TextInputState; import dev.tamboui.widgets.paragraph.Paragraph; +import org.apache.camel.dsl.jbang.core.common.CamelCommandHelper; import org.apache.camel.dsl.jbang.core.common.PathUtils; import org.apache.camel.util.json.JsonArray; import org.apache.camel.util.json.JsonObject; +import static org.apache.camel.dsl.jbang.core.commands.tui.MonitorContext.*; + class SendMessagePopup { private static final int FIELD_ROUTE = 0; private static final int FIELD_BODY = 1; private static final int FIELD_HEADERS = 2; private static final int FIELD_MODE = 3; + private static final int FIELD_HISTORY = 4; + + private static final int MAX_HISTORY = 20; private boolean visible; private boolean sending; @@ -57,13 +67,26 @@ class SendMessagePopup { private final TextInputState bodyState = new TextInputState(""); private int selectedField = FIELD_BODY; private boolean inOut; - private String resultMessage; - private boolean resultError; - private List<HeaderEntry> headers; + private List<FormHelper.HeaderEntry> headers; private int selectedHeader; private boolean editingHeaderKey; + // Response state + private String responseStatus; + private long responseElapsed; + private String responseExchangeId; + private List<String> responseHeaderLines; + private String responseRawBody; + private List<String> responseLines; + private boolean responseError; + private int responseScroll; + private boolean prettyPrint; + + // History + private final List<SendHistoryEntry> history = new ArrayList<>(); + private int historyIndex; + boolean isVisible() { return visible; } @@ -79,12 +102,12 @@ class SendMessagePopup { this.bodyState.clear(); this.selectedField = FIELD_BODY; this.inOut = false; - this.resultMessage = null; - this.resultError = false; this.sending = false; this.headers = null; this.selectedHeader = 0; this.editingHeaderKey = true; + clearResponse(); + this.historyIndex = 0; this.visible = true; } @@ -104,8 +127,32 @@ class SendMessagePopup { return true; } if (ke.isConfirm()) { + if (selectedField == FIELD_HISTORY && !history.isEmpty()) { + replayHistoryEntry(history.get(historyIndex)); + } + return true; + } + + // PgUp/PgDn scroll response + if (ke.isPageUp() || ke.isKey(KeyCode.PAGE_UP)) { + responseScroll = Math.max(0, responseScroll - 10); return true; } + if (ke.isPageDown() || ke.isKey(KeyCode.PAGE_DOWN)) { + responseScroll += 10; + return true; + } + + // Toggle pretty print (only when not editing text) + if (ke.isChar('p') && selectedField != FIELD_BODY && selectedField != FIELD_HEADERS) { + prettyPrint = !prettyPrint; + if (responseLines != null) { + rebuildResponseLines(); + responseScroll = 0; + } + return true; + } + if (selectedField == FIELD_BODY) { if (ke.isKey(KeyCode.TAB) || ke.isDown()) { if (hasHeaders()) { @@ -120,10 +167,16 @@ class SendMessagePopup { if (ke.isUp()) { if (routes.size() > 1) { selectedField = FIELD_ROUTE; + } else { + goToLastField(); } return true; } - handleTextInput(ke, bodyState); + if (ke.isChar('+')) { + addHeader(); + return true; + } + FormHelper.handleTextInput(ke, bodyState); return true; } if (selectedField == FIELD_ROUTE) { @@ -132,7 +185,7 @@ class SendMessagePopup { return true; } if (ke.isUp()) { - selectedField = FIELD_MODE; + goToLastField(); return true; } if (ke.isLeft()) { @@ -150,7 +203,10 @@ class SendMessagePopup { } if (selectedField == FIELD_MODE) { if (ke.isKey(KeyCode.TAB) || ke.isDown()) { - if (routes.size() > 1) { + if (!history.isEmpty()) { + selectedField = FIELD_HISTORY; + historyIndex = 0; + } else if (routes.size() > 1) { selectedField = FIELD_ROUTE; } else { selectedField = FIELD_BODY; @@ -177,12 +233,46 @@ class SendMessagePopup { } return true; } + if (selectedField == FIELD_HISTORY) { + if (ke.isKey(KeyCode.TAB)) { + if (routes.size() > 1) { + selectedField = FIELD_ROUTE; + } else { + selectedField = FIELD_BODY; + } + return true; + } + if (ke.isUp()) { + if (historyIndex > 0) { + historyIndex--; + } else { + selectedField = FIELD_MODE; + } + return true; + } + if (ke.isDown()) { + if (historyIndex < history.size() - 1) { + historyIndex++; + } + return true; + } + return true; + } return true; } + private void goToLastField() { + if (!history.isEmpty()) { + selectedField = FIELD_HISTORY; + historyIndex = 0; + } else { + selectedField = FIELD_MODE; + } + } + private boolean handleHeaderKeyEvent(KeyEvent ke) { - HeaderEntry current = headers.get(selectedHeader); - TextInputState activeInput = editingHeaderKey ? current.keyInput : current.valueInput; + FormHelper.HeaderEntry current = headers.get(selectedHeader); + TextInputState activeInput = editingHeaderKey ? current.keyInput() : current.valueInput(); if (ke.isChar('+')) { addHeader(); @@ -213,7 +303,7 @@ class SendMessagePopup { return true; } if (ke.isDeleteBackward()) { - if (editingHeaderKey && current.keyInput.text().isEmpty()) { + if (editingHeaderKey && current.keyInput().text().isEmpty()) { headers.remove(selectedHeader); if (headers.isEmpty()) { headers = null; @@ -265,7 +355,7 @@ class SendMessagePopup { if (headers == null) { headers = new ArrayList<>(); } - headers.add(new HeaderEntry(new TextInputState(""), new TextInputState(""))); + headers.add(new FormHelper.HeaderEntry(new TextInputState(""), new TextInputState(""))); selectedField = FIELD_HEADERS; selectedHeader = headers.size() - 1; editingHeaderKey = true; @@ -276,18 +366,10 @@ class SendMessagePopup { } void handlePaste(String text) { - if (!visible || sending || text == null || text.isEmpty()) { + if (!visible || sending) { return; } - TextInputState target = activeTextInput(); - if (target != null) { - for (int i = 0; i < text.length(); i++) { - char ch = text.charAt(i); - if (ch != '\n' && ch != '\r') { - target.insert(ch); - } - } - } + FormHelper.handlePaste(text, activeTextInput()); } private TextInputState activeTextInput() { @@ -295,8 +377,8 @@ class SendMessagePopup { return bodyState; } if (selectedField == FIELD_HEADERS && hasHeaders()) { - HeaderEntry he = headers.get(selectedHeader); - return editingHeaderKey ? he.keyInput : he.valueInput; + FormHelper.HeaderEntry he = headers.get(selectedHeader); + return editingHeaderKey ? he.keyInput() : he.valueInput(); } return null; } @@ -306,8 +388,9 @@ class SendMessagePopup { return; } sending = true; - resultMessage = "Sending..."; - resultError = false; + responseStatus = "Sending..."; + responseError = false; + responseScroll = 0; String body = bodyState.text(); if (body != null && body.startsWith("file:")) { @@ -321,7 +404,9 @@ class SendMessagePopup { String endpoint = route.routeId; String mep = inOut ? "InOut" : "InOnly"; String targetPid = pid; - List<HeaderEntry> hdrs = headers != null ? new ArrayList<>(headers) : null; + boolean captureInOut = inOut; + List<FormHelper.HeaderEntry> hdrs = headers != null ? new ArrayList<>(headers) : null; + String routeId = route.routeId; scheduler.execute(() -> { try { @@ -338,9 +423,9 @@ class SendMessagePopup { if (hdrs != null && !hdrs.isEmpty()) { JsonArray arr = new JsonArray(); - for (HeaderEntry he : hdrs) { - String k = he.keyInput.text().trim(); - String v = he.valueInput.text(); + for (FormHelper.HeaderEntry he : hdrs) { + String k = he.keyInput().text().trim(); + String v = he.valueInput().text(); if (!k.isEmpty()) { JsonObject jo = new JsonObject(); jo.put("key", k); @@ -359,86 +444,269 @@ class SendMessagePopup { JsonObject response = MonitorContext.pollJsonResponse(outputFile, 25000); PathUtils.deleteFile(outputFile); - if (response != null) { - String status = response.getString("status"); - Object elapsed = response.get("elapsed"); - if ("success".equals(status)) { - String msg = "Sent (" + elapsed + "ms)"; - JsonObject message = response.getMap("message"); - if (inOut && message != null) { - String replyBody = objToString(message.get("body")); - if (replyBody != null && !replyBody.isEmpty()) { - msg += " - Reply: " + truncate(replyBody, 40); + if (response == null) { + applyResult(routeId, sendBody, hdrs, captureInOut, + null, 0, null, null, null, + true, "No response from integration"); + return; + } + + String status = response.getString("status"); + long elapsed = TuiHelper.objToLong(response.get("elapsed")); + String exchangeId = response.getString("exchangeId"); + + if ("success".equals(status)) { + List<String> hdrLines = new ArrayList<>(); + String rawBody = null; + + if (exchangeId != null) { + hdrLines.add("exchangeId: " + exchangeId); + } + hdrLines.add("exchangePattern: " + mep); + + JsonObject message = response.getMap("message"); + if (captureInOut && message != null) { + // Parse exchange headers + Collection<JsonObject> msgHeaders = message.getCollection("headers"); + if (msgHeaders != null) { + for (JsonObject h : msgHeaders) { + String k = h.getString("key"); + Object v = h.get("value"); + if (k != null) { + hdrLines.add(k + ": " + (v != null ? v.toString() : "")); + } } } - resultMessage = msg; - resultError = false; - } else { - JsonObject exception = response.getMap("exception"); - if (exception != null) { - String exMsg = exception.getString("message"); - resultMessage = "Error: " + (exMsg != null ? truncate(exMsg, 50) : status); - } else { - resultMessage = "Error: " + (status != null ? status : "unknown"); + + // Parse body + JsonObject bodyObj = message.getMap("body"); + if (bodyObj != null) { + Object bodyValue = bodyObj.get("value"); + if (bodyValue != null) { + rawBody = bodyValue.toString(); + } } - resultError = true; } + + applyResult(routeId, sendBody, hdrs, captureInOut, + "success", elapsed, exchangeId, hdrLines, rawBody, + false, null); } else { - resultMessage = "No response from integration"; - resultError = true; + List<String> errLines = new ArrayList<>(); + JsonObject exception = response.getMap("exception"); + String errMsg; + if (exception != null) { + errMsg = exception.getString("message"); + if (errMsg == null) { + errMsg = status != null ? status : "unknown error"; + } + errLines.add("Exception: " + errMsg); + String stackTrace = exception.getString("stackTrace"); + if (stackTrace != null) { + for (String line : stackTrace.split("\n")) { + errLines.add(line); + } + } + } else { + errMsg = status != null ? status : "unknown error"; + errLines.add("Error: " + errMsg); + } + + applyResult(routeId, sendBody, hdrs, captureInOut, + status, elapsed, exchangeId, errLines, null, + true, errMsg); } } catch (Exception e) { - resultMessage = "Error: " + e.getMessage(); - resultError = true; + applyResult(routeId, sendBody, hdrs, captureInOut, + null, 0, null, null, null, + true, "Error: " + e.getMessage()); } finally { sending = false; } }); } + private void applyResult( + String routeId, String body, List<FormHelper.HeaderEntry> hdrs, boolean wasInOut, + String status, long elapsed, String exchangeId, + List<String> hdrLines, String rawBody, + boolean error, String errorMessage) { + + // Build history entry + List<FormHelper.HeaderEntry> histHeaders = null; + if (hdrs != null && !hdrs.isEmpty()) { + histHeaders = new ArrayList<>(); + for (FormHelper.HeaderEntry he : hdrs) { + histHeaders.add(new FormHelper.HeaderEntry( + new TextInputState(he.keyInput().text()), + new TextInputState(he.valueInput().text()))); + } + } + SendHistoryEntry histEntry = new SendHistoryEntry( + routeId, body, histHeaders, wasInOut, + status != null ? status : (error ? "failed" : "unknown"), + elapsed, error); + + responseStatus = status; + responseElapsed = elapsed; + responseExchangeId = exchangeId; + responseError = error; + responseScroll = 0; + + if (error && hdrLines == null) { + responseHeaderLines = null; + responseRawBody = null; + responseLines = errorMessage != null ? List.of(errorMessage) : List.of("Unknown error"); + } else { + responseHeaderLines = hdrLines; + responseRawBody = rawBody; + rebuildResponseLines(); + } + + history.add(0, histEntry); + if (history.size() > MAX_HISTORY) { + history.remove(history.size() - 1); + } + historyIndex = 0; + } + + private void replayHistoryEntry(SendHistoryEntry entry) { + // Restore route selection + if (entry.routeId != null) { + for (int i = 0; i < routes.size(); i++) { + if (entry.routeId.equals(routes.get(i).routeId)) { + selectedRouteIndex = i; + break; + } + } + } + + // Restore body + bodyState.clear(); + if (entry.body != null) { + for (int i = 0; i < entry.body.length(); i++) { + bodyState.insert(entry.body.charAt(i)); + } + } + + // Restore headers + headers = null; + if (entry.headers != null) { + for (FormHelper.HeaderEntry he : entry.headers) { + addHeaderWithValues(he.keyInput().text(), he.valueInput().text()); + } + } + + // Restore mode + inOut = entry.inOut; + selectedField = FIELD_BODY; + } + + private void addHeaderWithValues(String key, String value) { + if (headers == null) { + headers = new ArrayList<>(); + } + TextInputState keyState = new TextInputState(""); + TextInputState valState = new TextInputState(""); + for (int i = 0; i < key.length(); i++) { + keyState.insert(key.charAt(i)); + } + for (int i = 0; i < value.length(); i++) { + valState.insert(value.charAt(i)); + } + headers.add(new FormHelper.HeaderEntry(keyState, valState)); + } + + private void clearResponse() { + responseStatus = null; + responseElapsed = 0; + responseExchangeId = null; + responseHeaderLines = null; + responseRawBody = null; + responseLines = null; + responseError = false; + responseScroll = 0; + } + + private void rebuildResponseLines() { + List<String> lines = new ArrayList<>(); + if (responseHeaderLines != null) { + lines.addAll(responseHeaderLines); + } + if (responseRawBody != null && !responseRawBody.isEmpty()) { + lines.add(""); + String body = responseRawBody; + if (prettyPrint) { + body = CamelCommandHelper.valueAsStringPretty(body, false); + } + for (String line : body.split("\n", -1)) { + lines.add(line); + } + } + responseLines = lines; + } + + // ---- Rendering ---- + void render(Frame frame, Rect area) { if (!visible) { return; } + frame.renderWidget(Clear.INSTANCE, area); + + int padX = 2; + int padY = 1; + Rect inner = new Rect( + area.left() + padX, area.top() + padY, + area.width() - padX * 2, area.height() - padY * 2); + int headerCount = hasHeaders() ? headers.size() : 0; - int popupW = Math.min(80, area.width() - 4); - int baseH = routes.size() > 1 ? 14 : 12; - int popupH = baseH + (headerCount > 0 ? headerCount + 1 : 0); - int x = area.left() + Math.max(0, (area.width() - popupW) / 2); - int y = area.top() + 2; - Rect popup = new Rect(x, y, Math.min(popupW, area.width()), Math.min(popupH, area.height() - 2)); + int requestHeight = 7 + headerCount + (headerCount > 0 ? 1 : 0); + if (routes.size() > 1) { + requestHeight += 1; + } - frame.renderWidget(Clear.INSTANCE, popup); + int historyHeight = Math.min(6, history.size() + 2); + if (historyHeight < 3) { + historyHeight = 3; + } + List<Rect> chunks = Layout.vertical() + .constraints( + Constraint.length(requestHeight), + Constraint.fill(), + Constraint.length(historyHeight)) + .split(inner); + + renderRequest(frame, chunks.get(0)); + renderResponse(frame, chunks.get(1)); + renderHistory(frame, chunks.get(2)); + } + + private void renderRequest(Frame frame, Rect area) { String title = " Send Message"; if (integrationName != null) { - title += " - " + truncate(integrationName, 20); + title += " — " + TuiHelper.truncate(integrationName, 30); } title += " "; + Block block = Block.builder() .borderType(BorderType.ROUNDED) .title(Title.from(Line.from(Span.styled(title, Style.EMPTY.fg(Color.YELLOW).bold())))) - .titleBottom(Title.from(Line.from( - Span.styled(" +", MonitorContext.HINT_KEY_STYLE), - Span.raw(" header │"), - Span.styled(" Enter", MonitorContext.HINT_KEY_STYLE), - Span.raw(" send │"), - Span.styled(" Esc", MonitorContext.HINT_KEY_STYLE), - Span.raw(" close ")))) .build(); - frame.renderWidget(block, popup); + frame.renderWidget(block, area); + Rect inner = block.inner(area); - int innerX = popup.left() + 2; - int innerW = popup.width() - 4; + int innerX = inner.left() + 1; + int innerW = inner.width() - 2; int labelW = 8; int fieldW = innerW - labelW; - int row = popup.top() + 1; + int row = inner.top(); // Route selector (only if multiple routes) if (routes.size() > 1) { - row++; - renderLabel(frame, innerX, row, labelW, "Route:", selectedField == FIELD_ROUTE); + FormHelper.renderLabel(frame, innerX, row, labelW, "Route:", selectedField == FIELD_ROUTE); RouteInfo ri = routes.get(selectedRouteIndex); String routeDisplay = ri.routeId + " (" + truncateUri(ri.from, fieldW - ri.routeId.length() - 6) + ")"; String arrow = selectedField == FIELD_ROUTE ? "◀ " : " "; @@ -449,11 +717,12 @@ class SendMessagePopup { Span.styled(arrow, routeStyle), Span.styled(routeDisplay, routeStyle), Span.styled(arrowR, routeStyle))), routeArea); + row++; } // Body input - row += 2; - renderLabel(frame, innerX, row, labelW, "Body:", selectedField == FIELD_BODY); + row++; + FormHelper.renderLabel(frame, innerX, row, labelW, "Body:", selectedField == FIELD_BODY); Rect bodyArea = new Rect(innerX + labelW, row, fieldW, 1); if (selectedField == FIELD_BODY && !sending) { TextInput textInput = TextInput.builder() @@ -470,17 +739,16 @@ class SendMessagePopup { // Headers section if (hasHeaders()) { - row++; int keyW = Math.min(20, fieldW / 3); int valW = fieldW - keyW - 3; for (int i = 0; i < headers.size(); i++) { row++; boolean isSelected = selectedField == FIELD_HEADERS && selectedHeader == i; String label = i == 0 ? "Hdrs:" : ""; - renderLabel(frame, innerX, row, labelW, label, + FormHelper.renderLabel(frame, innerX, row, labelW, label, isSelected || (i == 0 && selectedField == FIELD_HEADERS)); - HeaderEntry he = headers.get(i); + FormHelper.HeaderEntry he = headers.get(i); int fieldX = innerX + labelW; // Key field @@ -489,9 +757,9 @@ class SendMessagePopup { TextInput keyInput = TextInput.builder() .cursorStyle(Style.EMPTY.reversed()) .build(); - frame.renderStatefulWidget(keyInput, keyArea, he.keyInput); + frame.renderStatefulWidget(keyInput, keyArea, he.keyInput()); } else { - String keyText = he.keyInput.text(); + String keyText = he.keyInput().text(); Style keyStyle; if (keyText.isEmpty()) { keyStyle = Style.EMPTY.dim(); @@ -514,9 +782,9 @@ class SendMessagePopup { TextInput valInput = TextInput.builder() .cursorStyle(Style.EMPTY.reversed()) .build(); - frame.renderStatefulWidget(valInput, valArea, he.valueInput); + frame.renderStatefulWidget(valInput, valArea, he.valueInput()); } else { - String valText = he.valueInput.text(); + String valText = he.valueInput().text(); Style valStyle; if (valText.isEmpty()) { valStyle = Style.EMPTY.dim(); @@ -532,7 +800,7 @@ class SendMessagePopup { // Mode toggle row += 2; - renderLabel(frame, innerX, row, labelW, "Mode:", selectedField == FIELD_MODE); + FormHelper.renderLabel(frame, innerX, row, labelW, "Mode:", selectedField == FIELD_MODE); Rect modeArea = new Rect(innerX + labelW, row, fieldW, 1); Style inOnlyStyle = !inOut ? Style.EMPTY.bold().reversed() : Style.EMPTY.dim(); Style inOutStyle = inOut ? Style.EMPTY.bold().reversed() : Style.EMPTY.dim(); @@ -540,19 +808,155 @@ class SendMessagePopup { Span.styled(" InOnly ", inOnlyStyle), Span.raw(" "), Span.styled(" InOut ", inOutStyle))), modeArea); + } - // Result line - if (resultMessage != null) { - row += 2; - Style resultStyle = resultError - ? Style.EMPTY.fg(Color.LIGHT_RED) - : Style.EMPTY.fg(Color.GREEN); - Rect resultArea = new Rect(innerX, row, innerW, 1); - frame.renderWidget(Paragraph.from(Line.from( - Span.styled(resultMessage, resultStyle))), resultArea); + private void renderResponse(Frame frame, Rect area) { + String titleStr; + Style titleStyle = Style.EMPTY.bold(); + + if (responseStatus == null && !sending) { + titleStr = " Response "; + } else if (sending) { + titleStr = " Sending... "; + titleStyle = Style.EMPTY.fg(Color.YELLOW).bold(); + } else if (responseError) { + titleStr = " Response — Error "; + if (responseElapsed > 0) { + titleStr = " Response — Error (" + responseElapsed + "ms) "; + } + titleStyle = Style.EMPTY.fg(Color.LIGHT_RED).bold(); + } else { + titleStr = " Response — " + (inOut ? "InOut" : "InOnly"); + if (responseElapsed > 0) { + titleStr += " (" + responseElapsed + "ms)"; + } + titleStr += " "; + titleStyle = Style.EMPTY.fg(Color.GREEN).bold(); + } + + Title title = Title.from(Line.from(Span.styled(titleStr, titleStyle))); + + if (responseLines == null || responseLines.isEmpty()) { + String placeholder = responseStatus == null && !sending + ? " Press Enter to send message" + : " No response content"; + frame.renderWidget( + Paragraph.builder() + .text(Text.from(Line.from(Span.styled(placeholder, Style.EMPTY.dim())))) + .block(Block.builder().borderType(BorderType.ROUNDED).title(title).build()) + .build(), + area); + return; + } + + int visibleLines = area.height() - 2; + if (visibleLines < 1) { + visibleLines = 1; + } + int maxScroll = Math.max(0, responseLines.size() - visibleLines); + responseScroll = Math.min(responseScroll, maxScroll); + + int end = Math.min(responseScroll + visibleLines, responseLines.size()); + List<Line> lines = new ArrayList<>(); + for (int i = responseScroll; i < end; i++) { + String line = responseLines.get(i); + if (line.isEmpty()) { + lines.add(Line.from(Span.raw(""))); + } else if (line.contains(": ") && !line.startsWith(" ") && !line.startsWith("{") + && !line.startsWith("[") && !line.startsWith("\"")) { + int colon = line.indexOf(": "); + lines.add(Line.from( + Span.styled(line.substring(0, colon + 1), Style.EMPTY.fg(Color.YELLOW)), + Span.raw(line.substring(colon + 1)))); + } else { + lines.add(Line.from(Span.raw(line))); + } + } + + frame.renderWidget( + Paragraph.builder() + .text(Text.from(lines)) + .block(Block.builder().borderType(BorderType.ROUNDED).title(title).build()) + .build(), + area); + } + + private void renderHistory(Frame frame, Rect area) { + String title = " History [" + history.size() + "] "; + + if (history.isEmpty()) { + frame.renderWidget( + Paragraph.builder() + .text(Text.from(Line.from( + Span.styled(" No messages sent yet", Style.EMPTY.dim())))) + .block(Block.builder().borderType(BorderType.ROUNDED).title(title).build()) + .build(), + area); + return; + } + + int visibleLines = area.height() - 2; + if (visibleLines < 1) { + visibleLines = 1; + } + + int start = 0; + if (historyIndex >= visibleLines) { + start = historyIndex - visibleLines + 1; + } + int end = Math.min(start + visibleLines, history.size()); + + List<Line> lines = new ArrayList<>(); + for (int i = start; i < end; i++) { + SendHistoryEntry entry = history.get(i); + boolean selected = selectedField == FIELD_HISTORY && i == historyIndex; + String pointer = selected ? "► " : " "; + String routeStr = String.format("%-16s", entry.routeId != null ? entry.routeId : ""); + String modeStr = entry.inOut ? "InOut " : "InOnly"; + String statusStr = entry.error ? "ERR" : entry.status; + String elapsedStr = entry.elapsed > 0 ? entry.elapsed + "ms" : ""; + String bodySnippet = entry.body != null && !entry.body.isEmpty() + ? " " + TuiHelper.truncate(entry.body, 30) + : ""; + + Style lineStyle = selected ? Style.EMPTY.bold() : Style.EMPTY; + Style statusStyle = entry.error ? Style.EMPTY.fg(Color.LIGHT_RED) : Style.EMPTY.fg(Color.GREEN); + if (!selected) { + statusStyle = statusStyle.dim(); + } + + lines.add(Line.from( + Span.styled(pointer, lineStyle), + Span.styled(routeStr, lineStyle), + Span.styled(modeStr + " ", Style.EMPTY.dim()), + Span.styled(statusStr, statusStyle), + Span.styled(" " + elapsedStr, Style.EMPTY.dim()), + Span.styled(bodySnippet, Style.EMPTY.dim()))); + } + + frame.renderWidget( + Paragraph.builder() + .text(Text.from(lines)) + .block(Block.builder().borderType(BorderType.ROUNDED).title(title).build()) + .build(), + area); + } + + void renderFooter(List<Span> spans) { + hint(spans, "Esc", "back"); + hint(spans, "Tab", "fields"); + hint(spans, "Enter", "send"); + hint(spans, "+", "header"); + hint(spans, "p", "pretty" + (prettyPrint ? " [on]" : "")); + if (!history.isEmpty()) { + hintLast(spans, "↑↓", "history"); + } else { + hintLast(spans, "PgUp/Dn", "scroll"); } } + // ---- Helpers ---- + private int findSmartDefault(String preSelectRouteId) { if (preSelectRouteId != null) { for (int i = 0; i < routes.size(); i++) { @@ -571,53 +975,19 @@ class SendMessagePopup { return 0; } - private void handleTextInput(KeyEvent ke, TextInputState state) { - if (ke.isDeleteBackward()) { - state.deleteBackward(); - } else if (ke.isDeleteForward()) { - state.deleteForward(); - } else if (ke.isLeft()) { - state.moveCursorLeft(); - } else if (ke.isRight()) { - state.moveCursorRight(); - } else if (ke.isHome()) { - state.moveCursorToStart(); - } else if (ke.isEnd()) { - state.moveCursorToEnd(); - } else if (ke.code() == KeyCode.CHAR) { - state.insert(ke.character()); - } - } - - private void renderLabel(Frame frame, int x, int y, int w, String label, boolean selected) { - Style style = selected ? Style.EMPTY.bold() : Style.EMPTY.dim(); - Rect labelArea = new Rect(x, y, w, 1); - frame.renderWidget(Paragraph.from(Line.from(Span.styled(label, style))), labelArea); - } - - private static String truncate(String s, int max) { - if (s == null) { - return ""; - } - return s.length() <= max ? s : s.substring(0, max - 1) + "…"; - } - private static String truncateUri(String uri, int max) { if (uri == null) { return ""; } int q = uri.indexOf('?'); String clean = q > 0 ? uri.substring(0, q) : uri; - return truncate(clean, max); - } - - private static String objToString(Object obj) { - if (obj == null) { - return null; - } - return obj.toString(); + return TuiHelper.truncate(clean, max); } - record HeaderEntry(TextInputState keyInput, TextInputState valueInput) { + record SendHistoryEntry( + String routeId, String body, + List<FormHelper.HeaderEntry> headers, + boolean inOut, String status, + long elapsed, boolean error) { } }
