This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new fada070a5ec2 CAMEL-23237: camel-jbang catalog tables fill the terminal
width
fada070a5ec2 is described below
commit fada070a5ec233b18398269de9e50f0a3e715ef4
Author: Adriano Machado <[email protected]>
AuthorDate: Tue Jul 7 03:56:24 2026 -0400
CAMEL-23237: camel-jbang catalog tables fill the terminal width
Standardize catalog table column definitions via a new CamelTableColumns
toolkit and expand the last free-text column (DESCRIPTION) to fill the
detected terminal width instead of capping at 80 characters. Fix
noBorderOverhead formula from (n-1)*2 to n*2 to match AsciiTable
NO_BORDERS padding. Add Windows terminal width detection via mode con.
Migrate CatalogBaseCommand, CatalogTransformer, CatalogKamelet,
UpdateList, and InfraBaseCommand onto the toolkit.
Closes #24386
Co-Authored-By: Claude Opus 4.8 <[email protected]>
---
.../ROOT/pages/camel-4x-upgrade-guide-4_22.adoc | 18 ++++
.../core/commands/catalog/CatalogBaseCommand.java | 40 ++++----
.../core/commands/catalog/CatalogKamelet.java | 18 ++--
.../core/commands/catalog/CatalogTransformer.java | 5 -
.../core/commands/infra/InfraBaseCommand.java | 57 +++++++----
.../dsl/jbang/core/commands/update/UpdateList.java | 21 ++--
.../dsl/jbang/core/common/CamelTableColumns.java | 107 ++++++++++++++++++++
.../dsl/jbang/core/common/TerminalWidthHelper.java | 97 ++++++++++++++----
.../dsl/jbang/core/commands/infra/InfraTest.java | 30 ++++++
.../jbang/core/common/CamelTableColumnsTest.java | 110 +++++++++++++++++++++
.../jbang/core/common/TerminalWidthHelperTest.java | 77 +++++++++++++--
11 files changed, 486 insertions(+), 94 deletions(-)
diff --git
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc
index 5a9fc917b966..e22ff8fd638d 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc
@@ -128,3 +128,21 @@ discarding the message with no exception or log.
Scalar value nodes are now sent as a single record. Only container nodes
(`ArrayNode`, `ObjectNode`)
are still split, which is unchanged. Any `convertBodyTo(...)` previously used
as a workaround is no
longer required.
+
+=== camel-jbang catalog tables fill the terminal width
+
+The `camel catalog` commands (`camel catalog component`, `camel catalog
dataformat`,
+`camel catalog language`, `camel catalog transformer`, `camel catalog
kamelet`, ...) now size the
+`DESCRIPTION` column to the detected terminal width instead of the previous
fixed 80-character cap,
+so wide terminals show more of the description before it is truncated with an
ellipsis. The `NAME`
+column width is also standardized across these commands (it previously
differed per command, for
+example 60 for `transformer` and 30 elsewhere).
+
+Terminal width is now detected on Windows (`cmd` / PowerShell) via `mode con`,
in addition to the
+existing `COLUMNS` / `stty size` detection. When no terminal can be detected
(for example when the
+output is piped or redirected), the width falls back to 120 columns. For full,
untruncated output
+suitable for scripting, use the `--json` option.
+
+The `camel infra list` table now sizes its `DESCRIPTION` column to the
terminal width, and truncates
+the `IMPLEMENTATION` and `SERVICE_DATA` columns with an ellipsis instead of
letting the raw service
+data overflow the terminal. The complete, structured service data remains
available via `--json`.
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/catalog/CatalogBaseCommand.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/catalog/CatalogBaseCommand.java
index a83f99aa1113..b8ac84ee7605 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/catalog/CatalogBaseCommand.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/catalog/CatalogBaseCommand.java
@@ -20,18 +20,19 @@ import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
+import java.util.function.Function;
import java.util.stream.Collectors;
import com.github.freva.asciitable.AsciiTable;
import com.github.freva.asciitable.Column;
import com.github.freva.asciitable.HorizontalAlign;
-import com.github.freva.asciitable.OverflowBehaviour;
import org.apache.camel.catalog.CamelCatalog;
import org.apache.camel.catalog.DefaultCamelCatalog;
import org.apache.camel.dsl.jbang.core.commands.CamelCommand;
import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain;
import org.apache.camel.dsl.jbang.core.commands.MavenResolverMixin;
import org.apache.camel.dsl.jbang.core.commands.QuarkusPlatformMixin;
+import org.apache.camel.dsl.jbang.core.common.CamelTableColumns;
import org.apache.camel.dsl.jbang.core.common.CatalogLoader;
import org.apache.camel.dsl.jbang.core.common.RuntimeCompletionCandidates;
import org.apache.camel.dsl.jbang.core.common.RuntimeType;
@@ -159,27 +160,28 @@ public abstract class CatalogBaseCommand extends
CamelCommand {
.map(CatalogBaseDTO::toMap)
.collect(Collectors.toList())));
} else {
- // Compute description width: terminal minus fixed columns and
border overhead
- int fixedWidth = nameWidth() + 12 + 8; // LEVEL ~12 chars,
SINCE ~8 chars
- if (RuntimeType.quarkus == runtime) {
- fixedWidth += 8; // NATIVE column
- }
- int descWidth = TerminalWidthHelper.flexWidth(
- terminalWidth(), fixedWidth,
TerminalWidthHelper.noBorderOverhead(
- RuntimeType.quarkus == runtime ? 5 : 4),
- 20, 80);
+ // Size the DESCRIPTION column to fill the terminal: measure
the actual width of the other
+ // visible columns and give the remainder to DESCRIPTION
(floored on narrow terminals).
+ boolean quarkus = RuntimeType.quarkus == runtime;
+ Function<Row, String> nameGetter = displayGav ? this::shortGav
: r -> r.name;
+ int nameW = CamelTableColumns.measure(displayGav ?
"ARTIFACT-ID" : "NAME",
+ displayGav ? Integer.MAX_VALUE :
CamelTableColumns.NAME_MAX, rows, nameGetter);
+ int levelW = CamelTableColumns.measure("LEVEL",
Integer.MAX_VALUE, rows, this::level);
+ int sinceW = CamelTableColumns.measure("SINCE",
Integer.MAX_VALUE, rows, r -> r.since);
+ int overhead = TerminalWidthHelper.noBorderOverhead(quarkus ?
5 : 4);
+ int descWidth = quarkus
+ ? CamelTableColumns.lastColumnWidth(terminalWidth(),
overhead, nameW, levelW, sinceW,
+ CamelTableColumns.measure("NATIVE",
Integer.MAX_VALUE, rows, this::nativeSupported))
+ : CamelTableColumns.lastColumnWidth(terminalWidth(),
overhead, nameW, levelW, sinceW);
printer().println(AsciiTable.getTable(AsciiTable.NO_BORDERS,
rows, Arrays.asList(
- new
Column().header("NAME").visible(!displayGav).dataAlign(HorizontalAlign.LEFT).maxWidth(nameWidth())
- .with(r -> r.name),
+ CamelTableColumns.name().visible(!displayGav).with(r
-> r.name),
new
Column().header("ARTIFACT-ID").visible(displayGav).dataAlign(HorizontalAlign.LEFT)
.with(this::shortGav),
new
Column().header("LEVEL").dataAlign(HorizontalAlign.LEFT).with(this::level),
new
Column().header("NATIVE").dataAlign(HorizontalAlign.CENTER)
- .visible(RuntimeType.quarkus ==
runtime).with(this::nativeSupported),
- new
Column().header("SINCE").dataAlign(HorizontalAlign.RIGHT).with(r -> r.since),
- new
Column().header("DESCRIPTION").dataAlign(HorizontalAlign.LEFT)
- .maxWidth(descWidth,
OverflowBehaviour.ELLIPSIS_RIGHT)
- .with(this::shortDescription))));
+ .visible(quarkus).with(this::nativeSupported),
+ CamelTableColumns.since().with(r -> r.since),
+ CamelTableColumns.lastText("DESCRIPTION",
descWidth).with(this::shortDescription))));
}
} else if (filterName != null) {
// suggest similar names when filter returns no results
@@ -196,10 +198,6 @@ public abstract class CatalogBaseCommand extends
CamelCommand {
return 0;
}
- int nameWidth() {
- return 30;
- }
-
int sortRow(Row o1, Row o2) {
String s = sort;
int negate = 1;
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/catalog/CatalogKamelet.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/catalog/CatalogKamelet.java
index bc00499e416e..18cae77dd45c 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/catalog/CatalogKamelet.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/catalog/CatalogKamelet.java
@@ -29,6 +29,7 @@ import com.github.freva.asciitable.Column;
import com.github.freva.asciitable.HorizontalAlign;
import org.apache.camel.dsl.jbang.core.commands.CamelCommand;
import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain;
+import org.apache.camel.dsl.jbang.core.common.CamelTableColumns;
import org.apache.camel.dsl.jbang.core.common.TerminalWidthHelper;
import org.apache.camel.dsl.jbang.core.common.VersionHelper;
import org.apache.camel.main.download.DependencyDownloaderClassLoader;
@@ -113,18 +114,17 @@ public class CatalogKamelet extends CamelCommand {
rows.sort(this::sortRow);
if (!rows.isEmpty()) {
- int tw = terminalWidth();
- // Fixed columns: NAME (~30), TYPE (10), LEVEL (12)
- int fixedWidth = 30 + 10 + 12;
- int descWidth = TerminalWidthHelper.flexWidth(
- tw, fixedWidth, TerminalWidthHelper.noBorderOverhead(4),
- 20, 80);
+ // Size the DESCRIPTION column to fill the terminal from the
measured width of the other columns.
+ int nameW = CamelTableColumns.measure("NAME",
CamelTableColumns.NAME_MAX, rows, r -> r.name);
+ int typeW = Math.max(10, CamelTableColumns.measure("TYPE",
Integer.MAX_VALUE, rows, r -> r.type));
+ int levelW = Math.max(12, CamelTableColumns.measure("LEVEL",
Integer.MAX_VALUE, rows, r -> r.supportLevel));
+ int descWidth = CamelTableColumns.lastColumnWidth(
+ terminalWidth(), TerminalWidthHelper.noBorderOverhead(4),
nameW, typeW, levelW);
printer().println(AsciiTable.getTable(AsciiTable.NO_BORDERS, rows,
Arrays.asList(
- new
Column().header("NAME").dataAlign(HorizontalAlign.LEFT).with(r -> r.name),
+ CamelTableColumns.name().with(r -> r.name),
new
Column().header("TYPE").dataAlign(HorizontalAlign.LEFT).minWidth(10).with(r ->
r.type),
new
Column().header("LEVEL").dataAlign(HorizontalAlign.LEFT).minWidth(12).with(r ->
r.supportLevel),
- new
Column().header("DESCRIPTION").dataAlign(HorizontalAlign.LEFT).maxWidth(descWidth)
- .with(this::getDescription))));
+ CamelTableColumns.lastText("DESCRIPTION",
descWidth).with(this::getDescription))));
}
return 0;
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/catalog/CatalogTransformer.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/catalog/CatalogTransformer.java
index 0d44412ff816..317538e1c127 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/catalog/CatalogTransformer.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/catalog/CatalogTransformer.java
@@ -35,11 +35,6 @@ public class CatalogTransformer extends CatalogBaseCommand {
super(main);
}
- @Override
- int nameWidth() {
- return 60;
- }
-
@Override
List<Row> collectRows() {
List<Row> rows = new ArrayList<>();
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/infra/InfraBaseCommand.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/infra/InfraBaseCommand.java
index 474cdb266f6a..e59bfd51653a 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/infra/InfraBaseCommand.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/infra/InfraBaseCommand.java
@@ -48,6 +48,7 @@ import org.apache.camel.catalog.CamelCatalog;
import org.apache.camel.catalog.DefaultCamelCatalog;
import org.apache.camel.dsl.jbang.core.commands.CamelCommand;
import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain;
+import org.apache.camel.dsl.jbang.core.common.CamelTableColumns;
import org.apache.camel.dsl.jbang.core.common.CommandLineHelper;
import org.apache.camel.dsl.jbang.core.common.TerminalWidthHelper;
import org.apache.camel.dsl.jbang.core.model.InfraBaseDTO;
@@ -192,40 +193,54 @@ public abstract class InfraBaseCommand extends
CamelCommand {
if (jsonOutput) {
printer().println(
Jsoner.serialize(
- rows.stream().map(row -> {
- Object serviceDataObj = null;
- try {
- serviceDataObj =
Jsoner.deserialize(row.serviceData());
- } catch (DeserializationException e) {
- // ignore
- }
- return new InfraBaseDTO(row.alias,
row.aliasImplementation, row.description, serviceDataObj);
- })
+ rows.stream().map(row -> new InfraBaseDTO(
+ row.alias, row.aliasImplementation,
row.description,
+ parseServiceData(row.serviceData())))
.map(InfraBaseDTO::toMap)
.collect(Collectors.toList())));
} else {
int tw = terminalWidth();
- // Fixed columns: PID (~8), ALIAS (width+2), SERVICE_DATA (~30),
DESCRIPTION (~30)
- int fixedWidth = (width + 2) + 30 + 30;
- if (showPidColumn()) {
- fixedWidth += 8;
- }
- int implWidth = TerminalWidthHelper.flexWidth(
- tw, fixedWidth,
TerminalWidthHelper.noBorderOverhead(showPidColumn() ? 5 : 4),
- 20, 35);
+ // Size DESCRIPTION to fill the terminal: measure the other
columns so it gets the exact remainder.
+ // IMPLEMENTATION is capped and SERVICE_DATA keeps a compact fixed
width; both truncate with an ellipsis
+ // instead of overflowing the terminal (the full, structured
service data is available via --json).
+ int serviceDataWidth = 30;
+ int aliasWidth = width + 2;
+ int pidWidth = showPidColumn()
+ ? CamelTableColumns.measure("PID", Integer.MAX_VALUE,
rows, r -> r.pid) : 0;
+ int implWidth = CamelTableColumns.measure("IMPLEMENTATION", 35,
rows, Row::aliasImplementation);
+ int overhead =
TerminalWidthHelper.noBorderOverhead(showPidColumn() ? 5 : 4);
+ int descWidth = CamelTableColumns.lastColumnWidth(
+ tw, overhead, pidWidth, aliasWidth, implWidth,
serviceDataWidth);
printer().println(AsciiTable.getTable(AsciiTable.NO_BORDERS, rows,
Arrays.asList(
new
Column().header("PID").visible(showPidColumn()).headerAlign(HorizontalAlign.CENTER).with(r
-> r.pid),
- new Column().header("ALIAS").minWidth(width +
2).dataAlign(HorizontalAlign.LEFT)
+ new
Column().header("ALIAS").minWidth(aliasWidth).dataAlign(HorizontalAlign.LEFT)
.with(Row::alias),
- new Column().header("IMPLEMENTATION").maxWidth(implWidth,
OverflowBehaviour.NEWLINE)
+ new Column().header("IMPLEMENTATION").maxWidth(implWidth,
OverflowBehaviour.ELLIPSIS_RIGHT)
.dataAlign(HorizontalAlign.LEFT).with(Row::aliasImplementation),
- new
Column().header("DESCRIPTION").dataAlign(HorizontalAlign.LEFT).with(Row::description),
- new
Column().header("SERVICE_DATA").dataAlign(HorizontalAlign.LEFT).with(Row::serviceData))));
+ CamelTableColumns.lastText("DESCRIPTION",
descWidth).with(Row::description),
+ new
Column().header("SERVICE_DATA").maxWidth(serviceDataWidth,
OverflowBehaviour.ELLIPSIS_RIGHT)
+
.dataAlign(HorizontalAlign.LEFT).with(Row::serviceData))));
}
return 0;
}
+ /**
+ * Parses the raw service-data JSON string (read from the infra {@code
.json} file) into a structured object so it
+ * is emitted as nested JSON by {@code --json}, rather than as an escaped
string. Returns {@code null} (so the
+ * {@code serviceData} field is omitted) when there is no data or the
stored content is not valid JSON.
+ */
+ static Object parseServiceData(String serviceData) {
+ if (serviceData == null) {
+ return null;
+ }
+ try {
+ return Jsoner.deserialize(serviceData);
+ } catch (DeserializationException e) {
+ return null;
+ }
+ }
+
private String getServiceData(String key, String pid) {
Path jsonFilePath =
CommandLineHelper.getCamelDir().resolve(getJsonFileName(key, pid));
if (jsonFilePath.toFile().exists()) {
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/update/UpdateList.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/update/UpdateList.java
index 57a9182715ec..739aafdce5ca 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/update/UpdateList.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/update/UpdateList.java
@@ -37,6 +37,7 @@ import com.github.freva.asciitable.Column;
import com.github.freva.asciitable.HorizontalAlign;
import org.apache.camel.dsl.jbang.core.commands.CamelCommand;
import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain;
+import org.apache.camel.dsl.jbang.core.common.CamelTableColumns;
import org.apache.camel.dsl.jbang.core.common.RuntimeType;
import org.apache.camel.dsl.jbang.core.common.TerminalWidthHelper;
import org.apache.camel.dsl.jbang.core.common.VersionHelper;
@@ -183,21 +184,23 @@ public class UpdateList extends CamelCommand {
.map(UpdateListDTO::toMap)
.collect(Collectors.toList())));
} else {
- int tw = terminalWidth();
- // Fixed columns: VERSION (10), RUNTIME (~18), RUNTIME VERSION
(~17)
- int fixedWidth = 10 + 18 + 17;
- int descWidth = TerminalWidthHelper.flexWidth(
- tw, fixedWidth, TerminalWidthHelper.noBorderOverhead(4),
- 20, 80);
+ // Size the DESCRIPTION column to fill the terminal: measure the
actual rendered width of the
+ // other columns so the remainder handed to the last column is
exact (see CamelTableColumns).
+ int versionW = CamelTableColumns.measure("VERSION",
Integer.MAX_VALUE, rows, r -> r.version().toString());
+ int runtimeW = CamelTableColumns.measure("RUNTIME",
Integer.MAX_VALUE, rows, r -> r.runtime());
+ int runtimeVersionW
+ = CamelTableColumns.measure("RUNTIME VERSION",
Integer.MAX_VALUE, rows, r -> r.runtimeVersion());
+ int overhead = TerminalWidthHelper.noBorderOverhead(4);
+ int descWidth
+ = CamelTableColumns.lastColumnWidth(terminalWidth(),
overhead, versionW, runtimeW, runtimeVersionW);
printer().println(AsciiTable.getTable(AsciiTable.NO_BORDERS, rows,
Arrays.asList(
- new
Column().header("VERSION").minWidth(10).dataAlign(HorizontalAlign.LEFT)
+ new
Column().header("VERSION").dataAlign(HorizontalAlign.LEFT)
.with(r -> r.version().toString()),
new Column().header("RUNTIME")
.dataAlign(HorizontalAlign.LEFT).with(r ->
r.runtime()),
new Column().header("RUNTIME VERSION")
.dataAlign(HorizontalAlign.LEFT).with(r ->
r.runtimeVersion()),
- new Column().header("DESCRIPTION").maxWidth(descWidth)
- .dataAlign(HorizontalAlign.LEFT).with(r ->
r.description()))));
+ CamelTableColumns.lastText("DESCRIPTION",
descWidth).with(r -> r.description()))));
}
return 0;
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/CamelTableColumns.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/CamelTableColumns.java
new file mode 100644
index 000000000000..838ff1b78ab8
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/CamelTableColumns.java
@@ -0,0 +1,107 @@
+/*
+ * 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.common;
+
+import java.util.Collection;
+import java.util.function.Function;
+import java.util.stream.IntStream;
+
+import com.github.freva.asciitable.Column;
+import com.github.freva.asciitable.HorizontalAlign;
+import com.github.freva.asciitable.OverflowBehaviour;
+
+/**
+ * Standardized {@link Column} definitions for camel-jbang table output.
+ *
+ * <p>
+ * The same logical column (NAME, DESCRIPTION, ...) was previously hand-rolled
in every command with differing widths
+ * and overflow behaviour. These factories give a single source of truth so
tables render consistently, and so the last
+ * (rightmost) free-text column can be sized to fill the terminal width.
+ *
+ * <p>
+ * Each factory returns a pre-configured {@link Column}; the caller chains
{@code .with(getter)} (which returns the
+ * opaque {@code ColumnData}), so all configuration must happen before {@code
with}.
+ */
+public final class CamelTableColumns {
+
+ /**
+ * Maximum width for a NAME column. A name longer than this is the rare
case; short names render at their content
+ * width regardless, so this only acts as a ceiling.
+ */
+ public static final int NAME_MAX = 60;
+
+ /** Minimum width for the last free-text column on narrow terminals. */
+ public static final int LAST_MIN = 20;
+
+ private CamelTableColumns() {
+ }
+
+ /** Standard NAME column: left aligned, capped at {@link #NAME_MAX}. */
+ public static Column name() {
+ return new
Column().header("NAME").dataAlign(HorizontalAlign.LEFT).maxWidth(NAME_MAX);
+ }
+
+ /** Standard SINCE column: right aligned. */
+ public static Column since() {
+ return new Column().header("SINCE").dataAlign(HorizontalAlign.RIGHT);
+ }
+
+ /**
+ * The rightmost free-text column. Grows up to {@code width} and truncates
with an ellipsis so each row stays on a
+ * single line (use this for flat list/status tables; detail views may
keep {@code NEWLINE} wrapping).
+ *
+ * @param header the column header
+ * @param width the maximum width, typically from {@link
#lastColumnWidth(int, int, int...)}
+ */
+ public static Column lastText(String header, int width) {
+ return new Column().header(header).dataAlign(HorizontalAlign.LEFT)
+ .maxWidth(width, OverflowBehaviour.ELLIPSIS_RIGHT);
+ }
+
+ /**
+ * Actual rendered width of a structured column: the longest of the header
and any cell value, capped at
+ * {@code maxWidth}. Pass {@link Integer#MAX_VALUE} for an unbounded
column.
+ *
+ * @param header the column header (may be {@code null})
+ * @param maxWidth the column's maximum width (ceiling)
+ * @param rows the rows being rendered
+ * @param getter accessor returning the cell value for a row
+ */
+ public static <T> int measure(String header, int maxWidth, Collection<T>
rows, Function<T, String> getter) {
+ int width = header != null ? header.length() : 0;
+ for (T row : rows) {
+ String value = getter.apply(row);
+ if (value != null) {
+ width = Math.max(width, value.length());
+ }
+ }
+ return Math.min(width, maxWidth);
+ }
+
+ /**
+ * Width for the last column so the table reaches the terminal edge: the
terminal width minus the (actual) widths of
+ * the other columns and the border overhead, floored at {@link #LAST_MIN}.
+ *
+ * @param terminalWidth total terminal width in columns
+ * @param borderOverhead overhead from borders/padding (see {@link
TerminalWidthHelper#noBorderOverhead(int)})
+ * @param otherWidths the measured widths of every other (visible)
column
+ */
+ public static int lastColumnWidth(int terminalWidth, int borderOverhead,
int... otherWidths) {
+ int others = IntStream.of(otherWidths).sum();
+ return TerminalWidthHelper.fillWidth(terminalWidth, others,
borderOverhead, LAST_MIN);
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/TerminalWidthHelper.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/TerminalWidthHelper.java
index e096f8f96150..24e1e7e3ff04 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/TerminalWidthHelper.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/TerminalWidthHelper.java
@@ -16,12 +16,17 @@
*/
package org.apache.camel.dsl.jbang.core.common;
+import java.util.Locale;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
/**
* Helper for detecting the terminal width to adapt table and command output.
*
* <p>
- * Uses the {@code COLUMNS} environment variable or {@code stty size} to
detect the terminal width. Falls back to a
- * default width when the terminal size cannot be determined (e.g., when
output is piped or redirected).
+ * Uses the {@code COLUMNS} environment variable, {@code stty size} (POSIX) or
{@code mode con} (Windows) to detect the
+ * terminal width. Falls back to a default width when the terminal size cannot
be determined (e.g., when output is piped
+ * or redirected).
*
* <p>
* Avoids using JLine's {@code TerminalBuilder} for width detection because it
sends escape sequence queries (DA1, CPR)
@@ -32,6 +37,8 @@ public final class TerminalWidthHelper {
private static final int DEFAULT_WIDTH = 120;
private static final int MIN_WIDTH = 40;
+ private static final Pattern INTEGER = Pattern.compile("\\d+");
+
private TerminalWidthHelper() {
}
@@ -39,8 +46,8 @@ public final class TerminalWidthHelper {
* Returns the current terminal width in columns.
*
* <p>
- * Tries {@code COLUMNS} environment variable first, then {@code stty
size}. Returns {@value #DEFAULT_WIDTH} if
- * detection fails or if the output is not connected to a terminal.
+ * Tries the {@code COLUMNS} environment variable first, then {@code stty
size} on POSIX or {@code mode con} on
+ * Windows. Returns {@value #DEFAULT_WIDTH} if detection fails or if the
output is not connected to a terminal.
*/
public static int getTerminalWidth() {
// Try COLUMNS env var first (set by most shells)
@@ -55,26 +62,58 @@ public final class TerminalWidthHelper {
// ignore
}
}
- // Fall back to stty which reads the terminal size without escape
sequences
+ // Fall back to an OS native command that reads the terminal size
without escape sequences
+ int w = isWindows()
+ ? readWidthFromCommand("cmd", "/c", "mode", "con")
+ : readWidthFromCommand("stty", "size");
+ if (w > 0) {
+ return Math.max(w, MIN_WIDTH);
+ }
+ return DEFAULT_WIDTH;
+ }
+
+ private static boolean isWindows() {
+ return System.getProperty("os.name",
"").toLowerCase(Locale.ROOT).startsWith("windows");
+ }
+
+ private static int readWidthFromCommand(String... command) {
try {
- Process p = new ProcessBuilder("stty", "size")
+ Process p = new ProcessBuilder(command)
.redirectInput(ProcessBuilder.Redirect.INHERIT)
.start();
- String output = new
String(p.getInputStream().readAllBytes()).trim();
+ String output = new String(p.getInputStream().readAllBytes());
p.waitFor();
- if (!output.isEmpty()) {
- String[] parts = output.split("\\s+");
- if (parts.length >= 2) {
- int w = Integer.parseInt(parts[1]);
- if (w > 0) {
- return Math.max(w, MIN_WIDTH);
- }
- }
- }
+ return parseColumns(output);
} catch (Exception e) {
- // ignore — stty not available (e.g. Windows)
+ // ignore — command not available (e.g. stty/mode missing)
+ return -1;
}
- return DEFAULT_WIDTH;
+ }
+
+ /**
+ * Parses the column count from the output of {@code stty size} ("rows
cols") or Windows {@code mode con} (a
+ * multi-line "Lines: N / Columns: N" block). Both place the column count
as the <b>second</b> integer in the
+ * output, so it is parsed positionally rather than by label to survive
localized Windows output where the
+ * {@code Columns:} label is translated.
+ *
+ * @param output the raw command output
+ * @return the column count, or {@code -1} if it cannot be
determined
+ */
+ static int parseColumns(String output) {
+ if (output == null) {
+ return -1;
+ }
+ Matcher m = INTEGER.matcher(output);
+ Integer first = null;
+ while (m.find()) {
+ int value = Integer.parseInt(m.group());
+ if (first == null) {
+ first = value;
+ } else {
+ return value;
+ }
+ }
+ return -1;
}
/**
@@ -96,6 +135,23 @@ public final class TerminalWidthHelper {
return Math.max(minFlexWidth, Math.min(maxFlexWidth, available));
}
+ /**
+ * Computes the width for the last column so the table fills the terminal
width. Unlike
+ * {@link #flexWidth(int, int, int, int, int)} there is no upper cap: the
column grows all the way to the terminal
+ * edge.
+ *
+ * @param terminalWidth total terminal width in columns
+ * @param fixedColumnsWidth sum of the (actual) widths of all other
columns
+ * @param borderOverhead overhead from table borders and padding (use
{@link #noBorderOverhead(int)} or
+ * {@link #fancyBorderOverhead(int)})
+ * @param minWidth minimum width for the last column (used when
terminal is narrow)
+ * @return the computed width for the last column
+ */
+ public static int fillWidth(
+ int terminalWidth, int fixedColumnsWidth, int borderOverhead, int
minWidth) {
+ return Math.max(minWidth, terminalWidth - fixedColumnsWidth -
borderOverhead);
+ }
+
/**
* Scales a column width proportionally based on available terminal width.
All columns with the given preferred
* widths are scaled proportionally to fit within the terminal.
@@ -119,10 +175,11 @@ public final class TerminalWidthHelper {
}
/**
- * Border overhead for NO_BORDERS tables: 2 spaces between each column
pair.
+ * Border overhead for NO_BORDERS tables. AsciiTable pads every column
with one leading and one trailing space (no
+ * separator characters), so the overhead is exactly {@code 2 *
columnCount} regardless of the number of columns.
*/
public static int noBorderOverhead(int columnCount) {
- return (columnCount - 1) * 2;
+ return columnCount * 2;
}
/**
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/infra/InfraTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/infra/InfraTest.java
index f82c23ac25f3..e8983b74be70 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/infra/InfraTest.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/infra/InfraTest.java
@@ -21,6 +21,8 @@ import java.util.stream.Collectors;
import org.apache.camel.dsl.jbang.core.commands.CamelCommandBaseTestSupport;
import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain;
+import org.apache.camel.dsl.jbang.core.model.InfraBaseDTO;
+import org.apache.camel.util.json.Jsoner;
import org.assertj.core.api.Assertions;
import org.awaitility.Awaitility;
import org.junit.jupiter.api.Test;
@@ -69,4 +71,32 @@ public class InfraTest extends CamelCommandBaseTestSupport {
Assertions.assertThat(output).contains("amqp");
Assertions.assertThat(output).contains("minio");
}
+
+ @Test
+ public void serviceDataInJsonIsNestedNotEscaped() {
+ String rawServiceData = "{\"host\":\"localhost\",\"port\":61616}";
+ Object serviceData = InfraBaseCommand.parseServiceData(rawServiceData);
+
+ InfraBaseDTO dto = new InfraBaseDTO("artemis", "amqp", "AMQP broker",
serviceData);
+ String json = Jsoner.serialize(List.of(dto.toMap()));
+
+ // serviceData is embedded as a nested JSON object (so --json is
machine-parseable), not a string-escaped blob
+ Assertions.assertThat(json).contains("\"serviceData\":{");
+ Assertions.assertThat(json).contains("\"host\":\"localhost\"");
+ Assertions.assertThat(json).doesNotContain("\\\"host\\\"");
+ }
+
+ @Test
+ public void malformedServiceDataIsOmittedFromJson() {
+ Object serviceData = InfraBaseCommand.parseServiceData("{not valid
json");
+
+ // unparseable content is dropped rather than emitted as an escaped
string
+ Assertions.assertThat(serviceData).isNull();
+
+ InfraBaseDTO dto = new InfraBaseDTO("artemis", "amqp", "AMQP broker",
serviceData);
+ String json = Jsoner.serialize(List.of(dto.toMap()));
+
+ Assertions.assertThat(json).contains("\"alias\":\"artemis\"");
+ Assertions.assertThat(json).doesNotContain("serviceData");
+ }
}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/CamelTableColumnsTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/CamelTableColumnsTest.java
new file mode 100644
index 000000000000..7c15ae7687c1
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/CamelTableColumnsTest.java
@@ -0,0 +1,110 @@
+/*
+ * 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.common;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.function.Function;
+
+import com.github.freva.asciitable.AsciiTable;
+import com.github.freva.asciitable.Column;
+import com.github.freva.asciitable.HorizontalAlign;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class CamelTableColumnsTest {
+
+ private static final Function<String, String> IDENTITY = s -> s;
+
+ // --- measure ---
+
+ @Test
+ void measureUsesLongestCellWhenWiderThanHeader() {
+ List<String> rows = List.of("ftp", "salesforce", "kafka");
+ // longest value "salesforce" = 10, header "NAME" = 4
+ assertEquals(10, CamelTableColumns.measure("NAME",
CamelTableColumns.NAME_MAX, rows, IDENTITY));
+ }
+
+ @Test
+ void measureUsesHeaderWhenWiderThanCells() {
+ List<String> rows = List.of("x", "y");
+ // header "DESCRIPTION" = 11 is wider than any 1-char value
+ assertEquals(11, CamelTableColumns.measure("DESCRIPTION",
Integer.MAX_VALUE, rows, IDENTITY));
+ }
+
+ @Test
+ void measureCapsAtMaxWidth() {
+ List<String> rows =
List.of("a-very-long-component-name-that-exceeds-the-cap-by-a-lot-indeed");
+ // value length is > NAME_MAX, so it is capped at NAME_MAX
+ assertEquals(CamelTableColumns.NAME_MAX,
+ CamelTableColumns.measure("NAME", CamelTableColumns.NAME_MAX,
rows, IDENTITY));
+ }
+
+ @Test
+ void measureIgnoresNullCells() {
+ List<String> rows = Arrays.asList("ok", null, "fine");
+ assertEquals(4, CamelTableColumns.measure("N", Integer.MAX_VALUE,
rows, IDENTITY));
+ }
+
+ // --- lastColumnWidth ---
+
+ @Test
+ void lastColumnWidthConsumesExactRemainder() {
+ // 200 terminal - (20 + 10 + 8) others - 6 borders = 156 for the last
column
+ assertEquals(156, CamelTableColumns.lastColumnWidth(200, 6, 20, 10,
8));
+ }
+
+ @Test
+ void lastColumnWidthFloorsAtLastMinOnNarrowTerminal() {
+ // remainder would be negative, so it is floored at LAST_MIN
+ assertEquals(CamelTableColumns.LAST_MIN,
CamelTableColumns.lastColumnWidth(40, 6, 30, 12, 10));
+ }
+
+ // --- rendering: a table sized with lastColumnWidth must fit the terminal
---
+
+ @Test
+ void lastColumnWidthKeepsRenderedTableWithinTerminal() {
+ // The whole point of filling the terminal is that the rendered line
still fits inside it. A single-line
+ // description longer than the last column exercises the exact-fit
case where an off-by-one in the border
+ // overhead would overflow the terminal by a couple of columns.
+ int terminalWidth = 100;
+ List<String[]> rows = List.of(
+ new String[] {
+ "ftp", "Stable", "4.0",
+ "A very long component description that definitely
exceeds the remaining width and must be truncated" },
+ new String[] { "salesforce-streaming", "Preview", "3.1",
"short" });
+
+ int nameW = CamelTableColumns.measure("NAME",
CamelTableColumns.NAME_MAX, rows, r -> r[0]);
+ int levelW = CamelTableColumns.measure("LEVEL", Integer.MAX_VALUE,
rows, r -> r[1]);
+ int sinceW = CamelTableColumns.measure("SINCE", Integer.MAX_VALUE,
rows, r -> r[2]);
+ int descWidth = CamelTableColumns.lastColumnWidth(
+ terminalWidth, TerminalWidthHelper.noBorderOverhead(4), nameW,
levelW, sinceW);
+
+ String table = AsciiTable.getTable(AsciiTable.NO_BORDERS, rows,
Arrays.asList(
+ CamelTableColumns.name().with(r -> r[0]),
+ new
Column().header("LEVEL").dataAlign(HorizontalAlign.LEFT).with(r -> r[1]),
+ CamelTableColumns.since().with(r -> r[2]),
+ CamelTableColumns.lastText("DESCRIPTION", descWidth).with(r ->
r[3])));
+
+ for (String line : table.split("\n")) {
+ assertTrue(line.length() <= terminalWidth,
+ "Rendered line width %d exceeds terminal %d:
[%s]".formatted(line.length(), terminalWidth, line));
+ }
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/TerminalWidthHelperTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/TerminalWidthHelperTest.java
index af8e5da22c3e..3b331cf0dcfb 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/TerminalWidthHelperTest.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/TerminalWidthHelperTest.java
@@ -31,6 +31,63 @@ class TerminalWidthHelperTest {
assertTrue(width >= 40, "Terminal width should be at least 40, got: "
+ width);
}
+ // --- fillWidth ---
+
+ @Test
+ void fillWidthFillsRemainingSpaceWithoutCap() {
+ // 200 cols - 80 others - 6 borders = 114 available; no upper cap, so
the last column keeps all 114
+ assertEquals(114, TerminalWidthHelper.fillWidth(200, 80, 6, 20));
+ }
+
+ @Test
+ void fillWidthFloorsAtMinOnNarrowTerminal() {
+ // 60 cols - 86 others - 6 borders = -32 available; floored at min 20
+ assertEquals(20, TerminalWidthHelper.fillWidth(60, 86, 6, 20));
+ }
+
+ // --- parseColumns (shared by stty size and Windows mode con) ---
+
+ @Test
+ void parseColumnsFromStty() {
+ // stty size prints "rows cols"; the column count is the second integer
+ assertEquals(80, TerminalWidthHelper.parseColumns("24 80"));
+ assertEquals(211, TerminalWidthHelper.parseColumns("51 211\n"));
+ }
+
+ @Test
+ void parseColumnsFromWindowsModeCon() {
+ String output = """
+ Status for device CON:
+ ----------------------
+ Lines: 30
+ Columns: 120
+ Keyboard rate: 31
+ Keyboard delay: 1
+ Code page: 850
+ """;
+ assertEquals(120, TerminalWidthHelper.parseColumns(output));
+ }
+
+ @Test
+ void parseColumnsFromLocalizedModeCon() {
+ // Non-English Windows translates the labels; parsing the second
integer positionally still works
+ String output = """
+ État du périphérique CON :
+ --------------------------
+ Lignes : 30
+ Colonnes : 120
+ """;
+ assertEquals(120, TerminalWidthHelper.parseColumns(output));
+ }
+
+ @Test
+ void parseColumnsReturnsNegativeWhenUndetermined() {
+ assertEquals(-1, TerminalWidthHelper.parseColumns(null));
+ assertEquals(-1, TerminalWidthHelper.parseColumns(""));
+ assertEquals(-1, TerminalWidthHelper.parseColumns("no numbers here"));
+ assertEquals(-1, TerminalWidthHelper.parseColumns("42")); // only one
integer, no column value
+ }
+
// --- flexWidth ---
@Test
@@ -101,14 +158,16 @@ class TerminalWidthHelperTest {
@Test
void noBorderOverheadSingleColumn() {
- assertEquals(0, TerminalWidthHelper.noBorderOverhead(1));
+ // one leading + one trailing padding space around the single column
+ assertEquals(2, TerminalWidthHelper.noBorderOverhead(1));
}
@Test
void noBorderOverheadMultipleColumns() {
- assertEquals(2, TerminalWidthHelper.noBorderOverhead(2));
- assertEquals(6, TerminalWidthHelper.noBorderOverhead(4));
- assertEquals(16, TerminalWidthHelper.noBorderOverhead(9));
+ // each column contributes 2 padding spaces (one on each side)
+ assertEquals(4, TerminalWidthHelper.noBorderOverhead(2));
+ assertEquals(8, TerminalWidthHelper.noBorderOverhead(4));
+ assertEquals(18, TerminalWidthHelper.noBorderOverhead(9));
}
// --- fancyBorderOverhead ---
@@ -168,19 +227,19 @@ class TerminalWidthHelperTest {
void flexWidthForNoBordersProcessCommand() {
// Simulate ListProcess: 9 columns, fixed ~56 chars, NAME flex (max
40), error flex (max 70)
int tw = 80;
- int borders = TerminalWidthHelper.noBorderOverhead(9); // 16
+ int borders = TerminalWidthHelper.noBorderOverhead(9); // 18
int nameW = TerminalWidthHelper.flexWidth(tw, 56, borders, 15, 40);
- // 80 - 56 - 16 = 8, but min is 15
+ // 80 - 56 - 18 = 6, but min is 15
assertEquals(15, nameW);
tw = 120;
nameW = TerminalWidthHelper.flexWidth(tw, 56, borders, 15, 40);
- // 120 - 56 - 16 = 48, capped at max 40
+ // 120 - 56 - 18 = 46, capped at max 40
assertEquals(40, nameW);
tw = 100;
nameW = TerminalWidthHelper.flexWidth(tw, 56, borders, 15, 40);
- // 100 - 56 - 16 = 28
- assertEquals(28, nameW);
+ // 100 - 56 - 18 = 26
+ assertEquals(26, nameW);
}
}