This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch fix/CAMEL-23913 in repository https://gitbox.apache.org/repos/asf/camel.git
commit 00c8573c6f39342ff13420765ea5c27950d42d5d Author: Claus Ibsen <[email protected]> AuthorDate: Sun Jul 19 17:14:39 2026 +0200 CAMEL-23913: camel-tui - Add Catalog tab and remove standalone catalog command Co-Authored-By: Claude Opus 4.6 <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../jbang/core/commands/tui/CamelCatalogTui.java | 738 --------------------- .../dsl/jbang/core/commands/tui/CatalogTab.java | 535 +++++++++++++++ .../dsl/jbang/core/commands/tui/TabRegistry.java | 3 + .../dsl/jbang/core/commands/tui/TuiIcons.java | 1 + .../dsl/jbang/core/commands/tui/TuiPlugin.java | 3 +- 5 files changed, 540 insertions(+), 740 deletions(-) diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelCatalogTui.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelCatalogTui.java deleted file mode 100644 index 0f736f5e64ce..000000000000 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelCatalogTui.java +++ /dev/null @@ -1,738 +0,0 @@ -/* - * 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.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Map; - -import dev.tamboui.layout.Constraint; -import dev.tamboui.layout.Layout; -import dev.tamboui.layout.Rect; -import dev.tamboui.style.Overflow; -import dev.tamboui.style.Style; -import dev.tamboui.terminal.Frame; -import dev.tamboui.text.CharWidth; -import dev.tamboui.text.Line; -import dev.tamboui.text.Span; -import dev.tamboui.text.Text; -import dev.tamboui.tui.TuiRunner; -import dev.tamboui.tui.event.Event; -import dev.tamboui.tui.event.KeyCode; -import dev.tamboui.tui.event.KeyEvent; -import dev.tamboui.widgets.block.Block; -import dev.tamboui.widgets.block.BorderType; -import dev.tamboui.widgets.block.Borders; -import dev.tamboui.widgets.paragraph.Paragraph; -import dev.tamboui.widgets.table.Cell; -import dev.tamboui.widgets.table.Row; -import dev.tamboui.widgets.table.Table; -import dev.tamboui.widgets.table.TableState; -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.util.json.JsonArray; -import org.apache.camel.util.json.JsonObject; -import org.apache.camel.util.json.Jsoner; -import picocli.CommandLine.Command; -import sun.misc.Signal; - -@Command(name = "catalog", - description = "Interactive TUI catalog browser", - sortOptions = false) -public class CamelCatalogTui extends CamelCommand { - - private static final int FOCUS_LIST = 0; - private static final int FOCUS_OPTIONS = 1; - - // Catalog data - private List<ComponentInfo> allComponents = Collections.emptyList(); - private List<ComponentInfo> filteredComponents = Collections.emptyList(); - - // UI state - private final TableState listTableState = new TableState(); - private final TableState optionsTableState = new TableState(); - private int focus = FOCUS_LIST; - private final StringBuilder componentFilter = new StringBuilder(); - private final StringBuilder optionFilter = new StringBuilder(); - private boolean componentFullText; - private boolean optionFullText; - private int descriptionScroll; - - // All options for selected component (unfiltered) - private List<OptionInfo> allOptionsUnfiltered = Collections.emptyList(); - // Filtered options displayed in table - private List<OptionInfo> filteredOptions = Collections.emptyList(); - - private ClassLoader classLoader; - - public CamelCatalogTui(CamelJBangMain main, ClassLoader classLoader) { - super(main); - this.classLoader = classLoader; - } - - @Override - public Integer doCall() throws Exception { - // to make ServiceLoader work with tamboui for downloaded JARs - Thread.currentThread().setContextClassLoader(classLoader); - - loadCatalog(); - - try (var tui = TuiBackendHelper.createTuiRunner()) { - Signal.handle(new Signal("INT"), sig -> tui.quit()); - tui.run(this::handleEvent, this::render); - } - return 0; - } - - // ---- Catalog Loading ---- - - @SuppressWarnings("unchecked") - private void loadCatalog() { - CamelCatalog catalog = new DefaultCamelCatalog(); - List<String> names = new ArrayList<>(catalog.findComponentNames()); - Collections.sort(names); - - allComponents = new ArrayList<>(); - for (String name : names) { - try { - String json = catalog.componentJSonSchema(name); - if (json == null) { - continue; - } - JsonObject root = (JsonObject) Jsoner.deserialize(json); - JsonObject component = (JsonObject) root.get("component"); - if (component == null) { - continue; - } - - ComponentInfo info = new ComponentInfo(); - info.name = name; - info.title = component.getStringOrDefault("title", name); - info.description = component.getStringOrDefault("description", ""); - info.label = component.getStringOrDefault("label", ""); - info.groupId = component.getStringOrDefault("groupId", ""); - info.artifactId = component.getStringOrDefault("artifactId", ""); - info.version = component.getStringOrDefault("version", ""); - info.scheme = component.getStringOrDefault("scheme", name); - info.syntax = component.getStringOrDefault("syntax", ""); - info.deprecated = component.getBooleanOrDefault("deprecated", false); - - // Parse properties — separate by kind - JsonObject properties = (JsonObject) root.get("properties"); - if (properties != null) { - for (Map.Entry<String, Object> entry : properties.entrySet()) { - JsonObject prop = (JsonObject) entry.getValue(); - OptionInfo opt = parseOption(entry.getKey(), prop); - String kind = prop.getStringOrDefault("kind", "parameter"); - opt.kind = kind; - if ("property".equals(kind)) { - info.componentOptions.add(opt); - } else { - info.endpointOptions.add(opt); - } - } - } - - allComponents.add(info); - } catch (Exception e) { - // skip unparseable components - } - } - - applyComponentFilter(); - if (!filteredComponents.isEmpty()) { - listTableState.select(0); - updateSelectedComponent(); - } - } - - private OptionInfo parseOption(String name, JsonObject prop) { - OptionInfo opt = new OptionInfo(); - opt.name = name; - opt.type = prop.getStringOrDefault("type", ""); - opt.required = prop.getBooleanOrDefault("required", false); - opt.defaultValue = prop.getStringOrDefault("defaultValue", ""); - opt.description = prop.getStringOrDefault("description", ""); - opt.group = prop.getStringOrDefault("group", ""); - opt.enumValues = ""; - Object enums = prop.get("enum"); - if (enums instanceof JsonArray arr) { - List<String> vals = new ArrayList<>(); - for (Object v : arr) { - vals.add(String.valueOf(v)); - } - opt.enumValues = String.join(", ", vals); - } - return opt; - } - - private void applyComponentFilter() { - String filter = componentFilter.toString().toLowerCase(); - if (filter.isEmpty()) { - filteredComponents = new ArrayList<>(allComponents); - } else { - filteredComponents = new ArrayList<>(); - for (ComponentInfo c : allComponents) { - if (componentFullText) { - if (c.name.toLowerCase().contains(filter) - || c.title.toLowerCase().contains(filter) - || c.description.toLowerCase().contains(filter) - || c.label.toLowerCase().contains(filter)) { - filteredComponents.add(c); - } - } else { - if (c.name.toLowerCase().contains(filter)) { - filteredComponents.add(c); - } - } - } - } - if (!filteredComponents.isEmpty()) { - listTableState.select(0); - } else { - listTableState.clearSelection(); - } - updateSelectedComponent(); - } - - private void applyOptionFilter() { - String filter = optionFilter.toString().toLowerCase(); - if (filter.isEmpty()) { - filteredOptions = new ArrayList<>(allOptionsUnfiltered); - } else { - filteredOptions = new ArrayList<>(); - for (OptionInfo o : allOptionsUnfiltered) { - if (optionFullText) { - if (o.name.toLowerCase().contains(filter) - || o.description.toLowerCase().contains(filter) - || o.group.toLowerCase().contains(filter)) { - filteredOptions.add(o); - } - } else { - if (o.name.toLowerCase().contains(filter)) { - filteredOptions.add(o); - } - } - } - } - if (!filteredOptions.isEmpty()) { - optionsTableState.select(0); - } else { - optionsTableState.clearSelection(); - } - descriptionScroll = 0; - } - - private void updateSelectedComponent() { - Integer sel = listTableState.selected(); - if (sel != null && sel >= 0 && sel < filteredComponents.size()) { - ComponentInfo info = filteredComponents.get(sel); - allOptionsUnfiltered = new ArrayList<>(); - allOptionsUnfiltered.addAll(info.endpointOptions); - allOptionsUnfiltered.addAll(info.componentOptions); - } else { - allOptionsUnfiltered = Collections.emptyList(); - } - optionFilter.setLength(0); - optionFullText = false; - applyOptionFilter(); - descriptionScroll = 0; - } - - // ---- Event Handling ---- - - private boolean handleEvent(Event event, TuiRunner runner) { - if (event instanceof KeyEvent ke) { - // Quit - if (ke.isQuit()) { - runner.quit(); - return true; - } - - // Escape: clear filter first, then go back, then quit - if (ke.isCancel()) { - if (focus == FOCUS_OPTIONS && (!optionFilter.isEmpty() || optionFullText)) { - optionFilter.setLength(0); - optionFullText = false; - applyOptionFilter(); - return true; - } - if (focus == FOCUS_OPTIONS) { - focus = FOCUS_LIST; - descriptionScroll = 0; - return true; - } - if (!componentFilter.isEmpty() || componentFullText) { - componentFilter.setLength(0); - componentFullText = false; - applyComponentFilter(); - return true; - } - runner.quit(); - return true; - } - - // Backspace: delete from active filter - if (ke.isDeleteBackward()) { - if (focus == FOCUS_LIST && !componentFilter.isEmpty()) { - componentFilter.deleteCharAt(componentFilter.length() - 1); - applyComponentFilter(); - } else if (focus == FOCUS_OPTIONS && !optionFilter.isEmpty()) { - optionFilter.deleteCharAt(optionFilter.length() - 1); - applyOptionFilter(); - } - return true; - } - - // Panel switching — only when no active filter on current panel - if (ke.isFocusNext()) { - if (focus == FOCUS_LIST) { - focus = FOCUS_OPTIONS; - } else { - focus = FOCUS_LIST; - } - descriptionScroll = 0; - return true; - } - if (ke.isRight() && focus == FOCUS_LIST) { - focus = FOCUS_OPTIONS; - descriptionScroll = 0; - return true; - } - if (ke.isLeft() && focus == FOCUS_OPTIONS) { - focus = FOCUS_LIST; - descriptionScroll = 0; - return true; - } - - // Enter drills into options - if (ke.isConfirm() && focus == FOCUS_LIST) { - focus = FOCUS_OPTIONS; - descriptionScroll = 0; - return true; - } - - // Navigation - if (ke.isUp()) { - if (focus == FOCUS_LIST) { - listTableState.selectPrevious(); - updateSelectedComponent(); - } else { - optionsTableState.selectPrevious(); - descriptionScroll = 0; - } - return true; - } - if (ke.isDown()) { - if (focus == FOCUS_LIST) { - listTableState.selectNext(filteredComponents.size()); - updateSelectedComponent(); - } else { - optionsTableState.selectNext(filteredOptions.size()); - descriptionScroll = 0; - } - return true; - } - if (ke.isPageUp()) { - descriptionScroll = Math.max(0, descriptionScroll - 5); - return true; - } - if (ke.isPageDown()) { - descriptionScroll += 5; - return true; - } - - // '/' toggles full-text search mode - if (ke.isChar('/')) { - if (focus == FOCUS_LIST) { - componentFullText = !componentFullText; - applyComponentFilter(); - } else { - optionFullText = !optionFullText; - applyOptionFilter(); - } - return true; - } - - // Typing filters the active panel - if (ke.code() == KeyCode.CHAR) { - if (focus == FOCUS_LIST) { - componentFilter.append(ke.string()); - applyComponentFilter(); - } else { - optionFilter.append(ke.string()); - applyOptionFilter(); - } - return true; - } - } - return false; - } - - // ---- Rendering ---- - - private void render(Frame frame) { - Rect area = frame.area(); - - // Layout: header (3) + top panels (fill) + separator (1) + description (40%) + footer (1) - List<Rect> mainChunks = Layout.vertical() - .constraints( - Constraint.length(3), - Constraint.percentage(50), - Constraint.length(1), - Constraint.fill(), - Constraint.length(1)) - .split(area); - - renderHeader(frame, mainChunks.get(0)); - renderTopPanels(frame, mainChunks.get(1)); - renderSeparator(frame, mainChunks.get(2)); - renderDescription(frame, mainChunks.get(3)); - renderFooter(frame, mainChunks.get(4)); - } - - private void renderHeader(Frame frame, Rect area) { - Line titleLine = Line.from( - Span.styled(" Camel Catalog", Style.EMPTY.fg(Theme.accent()).bold()), - Span.raw(" "), - Span.styled(filteredComponents.size() + "/" + allComponents.size() + " components", - Style.EMPTY.fg(Theme.accent()))); - - Block headerBlock = Block.builder() - .borderType(BorderType.ROUNDED).borders(Borders.ALL) - .title(" Apache Camel ") - .build(); - - frame.renderWidget( - Paragraph.builder().text(Text.from(titleLine)).block(headerBlock).build(), - area); - } - - private void renderTopPanels(Frame frame, Rect area) { - // Split horizontally: component list (30%) + options table (70%) - List<Rect> chunks = Layout.horizontal() - .constraints(Constraint.percentage(30), Constraint.percentage(70)) - .split(area); - - renderComponentList(frame, chunks.get(0)); - renderOptionsTable(frame, chunks.get(1)); - } - - private void renderComponentList(Frame frame, Rect area) { - List<Row> rows = new ArrayList<>(); - for (ComponentInfo comp : filteredComponents) { - Style nameStyle = comp.deprecated - ? Theme.error().dim() - : Style.EMPTY.fg(Theme.accent()); - String label = comp.name; - if (comp.deprecated) { - label = label + " (deprecated)"; - } - rows.add(Row.from(Cell.from(Span.styled(label, nameStyle)))); - } - - if (rows.isEmpty()) { - rows.add(Row.from(Cell.from(Span.styled("No matching components", Style.EMPTY.dim())))); - } - - Style borderStyle = focus == FOCUS_LIST - ? Style.EMPTY.fg(Theme.accent()) - : Style.EMPTY; - - String modePrefix = componentFullText ? "/" : ""; - String listTitle = componentFilter.isEmpty() && !componentFullText - ? " Components " - : " Components [" + modePrefix + componentFilter + "] "; - - Table table = Table.builder() - .rows(rows) - .widths(Constraint.fill()) - .highlightStyle(Theme.selectionBg()) - .highlightSpacing(Table.HighlightSpacing.ALWAYS) - .block(Block.builder() - .borderType(BorderType.ROUNDED).borders(Borders.ALL) - .borderStyle(borderStyle) - .title(listTitle) - .build()) - .build(); - - frame.renderStatefulWidget(table, area, listTableState); - } - - private void renderOptionsTable(Frame frame, Rect area) { - Style borderStyle = focus == FOCUS_OPTIONS - ? Style.EMPTY.fg(Theme.accent()) - : Style.EMPTY; - - String optModePrefix = optionFullText ? "/" : ""; - String optTitle = optionFilter.isEmpty() && !optionFullText - ? " Options " - : " Options [" + optModePrefix + optionFilter + "] "; - - if (filteredOptions.isEmpty()) { - String emptyMsg = allOptionsUnfiltered.isEmpty() ? " Select a component" : " No matching options"; - frame.renderWidget( - Paragraph.builder() - .text(Text.from(Line.from( - Span.styled(emptyMsg, Style.EMPTY.dim())))) - .block(Block.builder() - .borderType(BorderType.ROUNDED).borders(Borders.ALL) - .borderStyle(borderStyle) - .title(optTitle) - .build()) - .build(), - area); - return; - } - - List<Row> rows = new ArrayList<>(); - for (OptionInfo opt : filteredOptions) { - rows.add(optionToRow(opt)); - } - - Row header = Row.from( - Cell.from(Span.styled("NAME", Style.EMPTY.bold())), - Cell.from(Span.styled("TYPE", Style.EMPTY.bold())), - Cell.from(Span.styled("REQ", Style.EMPTY.bold())), - Cell.from(Span.styled("DEFAULT", Style.EMPTY.bold())), - Cell.from(Span.styled("KIND", Style.EMPTY.bold()))); - - Table table = Table.builder() - .rows(rows) - .header(header) - .widths( - Constraint.length(25), - Constraint.length(12), - Constraint.length(4), - Constraint.length(12), - Constraint.fill()) - .highlightStyle(Theme.selectionBg()) - .highlightSpacing(Table.HighlightSpacing.ALWAYS) - .block(Block.builder() - .borderType(BorderType.ROUNDED).borders(Borders.ALL) - .borderStyle(borderStyle) - .title(optTitle) - .build()) - .build(); - - frame.renderStatefulWidget(table, area, optionsTableState); - } - - private void renderSeparator(Frame frame, Rect area) { - frame.renderWidget( - Block.builder() - .borders(Borders.TOP_ONLY) - .borderStyle(Theme.muted()) - .build(), - area); - } - - private void renderDescription(Frame frame, Rect area) { - List<Line> lines = new ArrayList<>(); - String title; - int wrapWidth = Math.max(20, area.width() - 4); - - if (focus == FOCUS_OPTIONS) { - // Show selected option detail - Integer sel = optionsTableState.selected(); - if (sel != null && sel >= 0 && sel < filteredOptions.size()) { - OptionInfo opt = filteredOptions.get(sel); - title = " " + opt.name + " "; - - List<String[]> fields = new ArrayList<>(); - fields.add(new String[] { "Name", opt.name }); - if (opt.kind != null && !opt.kind.isEmpty()) { - fields.add(new String[] { "Kind", opt.kind }); - } - fields.add(new String[] { "Type", opt.type }); - fields.add(new String[] { "Required", opt.required ? "Yes" : "No" }); - if (!opt.defaultValue.isEmpty()) { - fields.add(new String[] { "Default", opt.defaultValue }); - } - if (opt.group != null && !opt.group.isEmpty()) { - fields.add(new String[] { "Group", opt.group }); - } - if (opt.enumValues != null && !opt.enumValues.isEmpty()) { - fields.add(new String[] { "Values", opt.enumValues }); - } - flowFields(lines, fields, wrapWidth, opt); - - lines.add(Line.from(Span.raw(""))); - if (opt.description != null && !opt.description.isEmpty()) { - lines.add(Line.from(Span.raw(" " + opt.description))); - } - } else { - title = " Description "; - } - } else { - // Show selected component detail - Integer sel = listTableState.selected(); - if (sel != null && sel >= 0 && sel < filteredComponents.size()) { - ComponentInfo comp = filteredComponents.get(sel); - title = " " + comp.title + " "; - - List<String[]> fields = new ArrayList<>(); - fields.add(new String[] { "Scheme", comp.scheme }); - fields.add(new String[] { "Syntax", comp.syntax }); - fields.add(new String[] { "Label", comp.label }); - fields.add(new String[] { "Maven", comp.groupId + ":" + comp.artifactId + ":" + comp.version }); - fields.add(new String[] { - "Options", - comp.endpointOptions.size() + " endpoint, " + comp.componentOptions.size() + " component" }); - if (comp.deprecated) { - fields.add(new String[] { "Status", "DEPRECATED" }); - } - flowFields(lines, fields, wrapWidth, null); - - lines.add(Line.from(Span.raw(""))); - if (comp.description != null && !comp.description.isEmpty()) { - lines.add(Line.from(Span.raw(" " + comp.description))); - } - } else { - title = " Description "; - } - } - - frame.renderWidget( - Paragraph.builder() - .text(Text.from(lines)) - .overflow(Overflow.WRAP_WORD) - .scroll(descriptionScroll) - .block(Block.builder() - .borderType(BorderType.ROUNDED).borders(Borders.ALL) - .title(title) - .build()) - .build(), - area); - } - - private Row optionToRow(OptionInfo opt) { - Style nameStyle = opt.required - ? Style.EMPTY.fg(Theme.accent()).bold() - : Style.EMPTY.fg(Theme.accent()); - - return Row.from( - Cell.from(Span.styled(opt.name, nameStyle)), - Cell.from(Span.styled(opt.type, Style.EMPTY.dim())), - Cell.from(opt.required - ? Span.styled("*", Theme.error().bold()) - : Span.raw("")), - Cell.from(Span.styled(opt.defaultValue, Style.EMPTY.dim())), - Cell.from(Span.styled(opt.kind != null ? opt.kind : "", Style.EMPTY.dim()))); - } - - private void renderFooter(Frame frame, Rect area) { - Line footer = Line.from( - Span.styled(" Type", Theme.label().bold()), - Span.raw(" name filter "), - Span.styled("/", Theme.label().bold()), - Span.raw(" full-text "), - Span.styled("Esc", Theme.label().bold()), - Span.raw(" clear/back/quit "), - Span.styled("\u2191\u2193", Theme.label().bold()), - Span.raw(" navigate "), - Span.styled("\u2190\u2192", Theme.label().bold()), - Span.raw("/"), - Span.styled("Tab", Theme.label().bold()), - Span.raw(" panels "), - Span.styled("PgUp/Dn", Theme.label().bold()), - Span.raw(" scroll")); - - frame.renderWidget(Paragraph.from(footer), area); - } - - // ---- Helpers ---- - - private static void flowFields(List<Line> lines, List<String[]> fields, int maxWidth, OptionInfo opt) { - List<Span> currentSpans = new ArrayList<>(); - int currentLen = 1; // leading space - String gap = " "; - - for (String[] field : fields) { - String label = field[0] + ": "; - String value = field[1]; - int fieldLen = CharWidth.of(label) + CharWidth.of(value); - - // If adding this field would exceed width, flush current line - if (!currentSpans.isEmpty() && currentLen + CharWidth.of(gap) + fieldLen > maxWidth) { - lines.add(Line.from(currentSpans)); - currentSpans = new ArrayList<>(); - currentLen = 1; - } - - if (!currentSpans.isEmpty()) { - currentSpans.add(Span.raw(gap)); - currentLen += CharWidth.of(gap); - } else { - currentSpans.add(Span.raw(" ")); - } - - currentSpans.add(Span.styled(label, Theme.label().bold())); - - // Apply special styling for certain values - Style valueStyle; - if ("DEPRECATED".equals(value)) { - valueStyle = Theme.error().bold(); - } else if (opt != null && "Required".equals(field[0]) && opt.required) { - valueStyle = Theme.error().bold(); - } else if (opt != null && "Name".equals(field[0])) { - valueStyle = Style.EMPTY.fg(Theme.accent()).bold(); - } else if ("Label".equals(field[0]) || "Values".equals(field[0])) { - valueStyle = Style.EMPTY.fg(Theme.accent()); - } else { - valueStyle = Style.EMPTY; - } - currentSpans.add(Span.styled(value, valueStyle)); - currentLen += fieldLen; - } - - if (!currentSpans.isEmpty()) { - lines.add(Line.from(currentSpans)); - } - } - - // ---- Data Classes ---- - - static class ComponentInfo { - String name; - String title; - String description; - String label; - String groupId; - String artifactId; - String version; - String scheme; - String syntax; - boolean deprecated; - final List<OptionInfo> componentOptions = new ArrayList<>(); - final List<OptionInfo> endpointOptions = new ArrayList<>(); - } - - static class OptionInfo { - String name; - String kind; - String type; - boolean required; - String defaultValue; - String description; - String group; - String enumValues; - } -} diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CatalogTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CatalogTab.java new file mode 100644 index 000000000000..ea88d61c8153 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CatalogTab.java @@ -0,0 +1,535 @@ +/* + * 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.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; + +import dev.tamboui.layout.Constraint; +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.text.Text; +import dev.tamboui.tui.event.KeyCode; +import dev.tamboui.tui.event.KeyEvent; +import dev.tamboui.widgets.block.Block; +import dev.tamboui.widgets.block.BorderType; +import dev.tamboui.widgets.block.Borders; +import dev.tamboui.widgets.input.TextInputState; +import dev.tamboui.widgets.paragraph.Paragraph; +import dev.tamboui.widgets.table.Cell; +import dev.tamboui.widgets.table.Row; +import dev.tamboui.widgets.table.Table; +import org.apache.camel.catalog.CamelCatalog; +import org.apache.camel.dsl.jbang.core.common.CatalogLoader; +import org.apache.camel.tooling.model.ArtifactModel; +import org.apache.camel.util.json.JsonArray; +import org.apache.camel.util.json.JsonObject; + +import static org.apache.camel.dsl.jbang.core.commands.tui.TuiHelper.*; + +/** + * TUI tab showing Camel catalog artifacts (components, data formats, languages, others) that the running integration + * uses, based on its declared Maven dependencies. + */ +class CatalogTab extends AbstractTableTab { + + private static final String[] SCOPES = { "all", "component", "dataformat", "language", "other" }; + + private final AtomicBoolean loading = new AtomicBoolean(false); + + private boolean filterInputActive; + private TextInputState filterInputState = new TextInputState(""); + private String filterTerm; + private int scopeIndex; + private List<CatalogEntry> allEntries = Collections.emptyList(); + private List<CatalogEntry> filteredEntries = Collections.emptyList(); + private String lastPid; + private String errorMessage; + private boolean dataLoaded; + + CatalogTab(MonitorContext ctx) { + super(ctx, "name", "kind", "title"); + } + + @Override + public void onTabSelected() { + String pid = ctx.selectedPid; + if (pid != null && !pid.equals(lastPid)) { + lastPid = pid; + allEntries = Collections.emptyList(); + dataLoaded = false; + } + if (!dataLoaded) { + loadCatalogData(); + } + } + + @Override + public void onIntegrationChanged() { + allEntries = Collections.emptyList(); + filteredEntries = Collections.emptyList(); + filterTerm = null; + filterInputActive = false; + scopeIndex = 0; + lastPid = null; + errorMessage = null; + dataLoaded = false; + loading.set(false); + if (ctx.selectedPid != null) { + lastPid = ctx.selectedPid; + loadCatalogData(); + } + } + + @Override + public boolean handleKeyEvent(KeyEvent ke) { + if (filterInputActive) { + return handleFilterInput(ke); + } + return super.handleKeyEvent(ke); + } + + @Override + protected boolean handleTabKeyEvent(KeyEvent ke) { + if (ke.isChar('/')) { + filterInputActive = true; + filterInputState = new TextInputState(filterTerm != null ? filterTerm : ""); + return true; + } + if (ke.isCharIgnoreCase('f')) { + scopeIndex = (scopeIndex + 1) % SCOPES.length; + refilter(); + return true; + } + return false; + } + + private boolean handleFilterInput(KeyEvent ke) { + if (ke.isKey(KeyCode.ESCAPE)) { + filterInputActive = false; + return true; + } + if (ke.isConfirm()) { + String text = filterInputState.text().trim(); + filterTerm = text.isEmpty() ? null : text; + filterInputActive = false; + refilter(); + return true; + } + FormHelper.handleTextInput(ke, filterInputState); + return true; + } + + @Override + public boolean handleEscape() { + if (filterTerm != null) { + filterTerm = null; + refilter(); + return true; + } + return false; + } + + @Override + protected int getRowCount() { + return filteredEntries.size(); + } + + @Override + public void render(Frame frame, Rect area) { + IntegrationInfo info = ctx.findSelectedIntegration(); + if (info == null) { + renderNoSelection(frame, area); + return; + } + + if (loading.get() && allEntries.isEmpty()) { + frame.renderWidget( + Paragraph.builder() + .text(Text.from(Line.from(Span.styled(" Loading catalog...", Style.EMPTY.dim())))) + .block(Block.builder().borderType(BorderType.ROUNDED).borders(Borders.ALL) + .title(" Catalog ").build()) + .build(), + area); + return; + } + + if (errorMessage != null && allEntries.isEmpty()) { + frame.renderWidget( + Paragraph.builder() + .text(Text.from(Line.from( + Span.styled(" " + errorMessage, Theme.error())))) + .block(Block.builder().borderType(BorderType.ROUNDED).borders(Borders.ALL) + .title(" Catalog ").build()) + .build(), + area); + return; + } + + renderContent(frame, area, info); + } + + @Override + protected void renderContent(Frame frame, Rect area, IntegrationInfo info) { + List<CatalogEntry> sorted = new ArrayList<>(filteredEntries); + sorted.sort(this::sortEntry); + + List<Row> rows = new ArrayList<>(); + for (CatalogEntry entry : sorted) { + Style nameStyle = entry.deprecated + ? Theme.error().dim() + : Style.EMPTY.fg(Theme.accent()); + String name = entry.deprecated ? entry.name + " (deprecated)" : entry.name; + Style kindStyle = kindStyle(entry.kind); + rows.add(Row.from( + Cell.from(Span.styled(" " + name, nameStyle)), + Cell.from(Span.styled(entry.kind, kindStyle)), + Cell.from(entry.title), + Cell.from(Span.styled(entry.label != null ? entry.label : "", Style.EMPTY.dim())))); + } + + if (rows.isEmpty() && dataLoaded) { + rows.add(emptyRow("No catalog entries found", 4)); + } + + String scope = SCOPES[scopeIndex]; + boolean filtered = filterTerm != null || !"all".equals(scope); + StringBuilder title = new StringBuilder(" Catalog "); + title.append('['); + if (filtered) { + title.append(filteredEntries.size()).append('/').append(allEntries.size()); + } else { + title.append(filteredEntries.size()); + } + title.append(']'); + if (!"all".equals(scope)) { + title.append(" scope:").append(scope); + } + if (filterTerm != null) { + title.append(" filter:\"").append(filterTerm).append('"'); + } + title.append(' '); + + Table table = Table.builder() + .rows(rows) + .header(Row.from( + Cell.from(Span.styled(" " + sortLabel("NAME", "name"), sortStyle("name"))), + Cell.from(Span.styled(sortLabel("KIND", "kind"), sortStyle("kind"))), + Cell.from(Span.styled(sortLabel("TITLE", "title"), sortStyle("title"))), + Cell.from(Span.styled("LABEL", Style.EMPTY.bold())))) + .widths( + Constraint.length(30), + Constraint.length(12), + Constraint.length(30), + Constraint.fill()) + .highlightStyle(Theme.selectionBg()) + .block(Block.builder().borderType(BorderType.ROUNDED).borders(Borders.ALL) + .title(title.toString()).build()) + .build(); + + lastTableArea = area; + frame.renderStatefulWidget(table, area, tableState); + renderScrollbar(frame, sorted.size()); + } + + @Override + public void renderFooter(List<Span> spans) { + if (filterInputActive) { + spans.add(Span.styled(" /", Theme.label().bold())); + spans.add(Span.raw(filterInputState.text() + "█ ")); + hint(spans, "Enter", "filter"); + hintLast(spans, "Esc", "cancel"); + return; + } + hint(spans, "Esc", filterTerm != null ? "clear" : "back"); + hint(spans, "s", "sort"); + hint(spans, "f", "scope [" + SCOPES[scopeIndex] + "]"); + if (filterTerm != null) { + spans.add(Span.styled(" /", Theme.label().bold())); + spans.add(Span.raw("\"" + filterTerm + "\" ")); + } else { + hint(spans, "/", "filter"); + } + hintLast(spans, TuiIcons.HINT_SCROLL, "navigate"); + } + + private int sortEntry(CatalogEntry a, CatalogEntry b) { + int result = switch (sort) { + case "kind" -> a.kind.compareToIgnoreCase(b.kind); + case "title" -> a.title.compareToIgnoreCase(b.title); + default -> a.name.compareToIgnoreCase(b.name); // "name" + }; + return sortReversed ? -result : result; + } + + private static Style kindStyle(String kind) { + return switch (kind) { + case "component" -> Style.EMPTY.fg(Theme.accent()); + case "dataformat" -> Theme.success(); + case "language" -> Theme.warning(); + default -> Style.EMPTY.dim(); + }; + } + + // ---- Data Loading ---- + + private void loadCatalogData() { + IntegrationInfo info = ctx.findSelectedIntegration(); + if (info == null || ctx.runner == null) { + return; + } + if (!loading.compareAndSet(false, true)) { + return; + } + + ctx.runner.scheduler().execute(() -> { + try { + DependencyLoader.LoadResult result = DependencyLoader.loadDependencies(info); + if (result.error() != null && result.entries().isEmpty()) { + applyResult(Collections.emptyList(), result.error()); + return; + } + + Set<String> appArtifacts = new HashSet<>(); + for (DependencyLoader.DepEntry dep : result.entries()) { + if (dep.isCamel()) { + appArtifacts.add(dep.groupId() + ":" + dep.artifactId()); + } + } + + CamelCatalog catalog = CatalogLoader.loadCatalog(null, info.camelVersion, true); + if (catalog == null) { + applyResult(Collections.emptyList(), "Could not load catalog for version " + info.camelVersion); + return; + } + + List<CatalogEntry> entries = new ArrayList<>(); + collectArtifacts(catalog, "component", catalog.findComponentNames(), appArtifacts, entries); + collectArtifacts(catalog, "dataformat", catalog.findDataFormatNames(), appArtifacts, entries); + collectArtifacts(catalog, "language", catalog.findLanguageNames(), appArtifacts, entries); + collectArtifacts(catalog, "other", catalog.findOtherNames(), appArtifacts, entries); + + entries.sort((a, b) -> a.name.compareToIgnoreCase(b.name)); + applyResult(entries, null); + } catch (Exception e) { + applyResult(Collections.emptyList(), "Error: " + e.getMessage()); + } finally { + loading.set(false); + } + }); + } + + @SuppressWarnings("unchecked") + private static void collectArtifacts( + CamelCatalog catalog, String kind, List<String> names, + Set<String> appArtifacts, List<CatalogEntry> entries) { + for (String name : names) { + try { + ArtifactModel<?> model = (ArtifactModel<?>) switch (kind) { + case "component" -> catalog.componentModel(name); + case "dataformat" -> catalog.dataFormatModel(name); + case "language" -> catalog.languageModel(name); + case "other" -> catalog.otherModel(name); + default -> null; + }; + if (model == null) { + continue; + } + String ga = model.getGroupId() + ":" + model.getArtifactId(); + if (appArtifacts.contains(ga)) { + CatalogEntry entry = new CatalogEntry(); + entry.name = model.getName(); + entry.kind = kind; + entry.title = model.getTitle() != null ? model.getTitle() : name; + entry.description = model.getDescription() != null ? model.getDescription() : ""; + entry.label = model.getLabel(); + entry.artifactId = model.getArtifactId(); + entry.deprecated = model.isDeprecated(); + entries.add(entry); + } + } catch (Exception e) { + // skip unparseable entries + } + } + } + + private void applyResult(List<CatalogEntry> entries, String error) { + if (ctx.runner == null) { + return; + } + ctx.runner.runOnRenderThread(() -> { + allEntries = entries; + errorMessage = error; + dataLoaded = true; + refilter(); + }); + } + + private void refilter() { + List<CatalogEntry> result = new ArrayList<>(); + String ft = filterTerm != null ? filterTerm.toLowerCase() : null; + String scope = SCOPES[scopeIndex]; + for (CatalogEntry entry : allEntries) { + if (!"all".equals(scope) && !scope.equals(entry.kind)) { + continue; + } + if (ft != null + && !entry.name.toLowerCase().contains(ft) + && !entry.title.toLowerCase().contains(ft) + && !(entry.label != null && entry.label.toLowerCase().contains(ft))) { + continue; + } + result.add(entry); + } + filteredEntries = result; + if (!filteredEntries.isEmpty()) { + tableState.select(0); + } + } + + @Override + public boolean setFilter(String filter) { + filterTerm = filter != null && !filter.isEmpty() ? filter : null; + refilter(); + return true; + } + + @Override + public boolean setInputValue(String field, String value) { + if ("filter".equals(field)) { + return setFilter(value); + } + return false; + } + + @Override + public SelectionContext getSelectionContext() { + if (filteredEntries.isEmpty()) { + return null; + } + List<CatalogEntry> sorted = new ArrayList<>(filteredEntries); + sorted.sort(this::sortEntry); + List<String> items = sorted.stream().map(e -> e.name).toList(); + Integer sel = tableState.selected(); + return new SelectionContext("table", items, sel != null ? sel : -1, items.size(), "Catalog"); + } + + @Override + public String description() { + return "Camel catalog artifacts used by the integration"; + } + + @Override + public String getHelpText() { + return """ + # Catalog + + The Catalog tab shows which Camel catalog artifacts (components, data + formats, languages, and others) the running integration uses, based on + its declared Maven dependencies. + + For each Camel dependency in the integration, the tab cross-references + the Camel catalog to identify all artifacts provided by that dependency. + For example, `camel-core` provides the `direct`, `seda`, `timer`, `bean`, + `log`, and `mock` components, while `camel-kafka` provides the `kafka` + component. + + ## Table Columns + + - **NAME** — The catalog artifact name (e.g., `kafka`, `timer`, `json-jackson`) + - **KIND** — The artifact type: `component`, `dataformat`, `language`, or `other` + - **TITLE** — Human-readable title (e.g., "Apache Kafka", "Timer") + - **LABEL** — Category labels (e.g., "messaging", "scheduling", "transformation") + + Deprecated artifacts are shown dimmed with a "(deprecated)" suffix. + + ## Scope + + Press `f` to cycle the scope filter: + + - **all** — show all catalog artifacts (default) + - **component** — show only components + - **dataformat** — show only data formats + - **language** — show only expression languages + - **other** — show only miscellaneous artifacts + + ## Filter + + Press `/` to open the filter input. Type a search term and press + `Enter` to filter by substring match on name, title, or label. + + ## Keys + + - `s` — cycle sort column (name, kind, title) + - `S` — reverse sort order + - `f` — cycle scope + - `/` — open filter + - `Esc` — clear filter or back + """; + } + + @Override + public JsonObject getTableDataAsJson() { + if (filteredEntries.isEmpty()) { + return null; + } + List<CatalogEntry> sorted = new ArrayList<>(filteredEntries); + sorted.sort(this::sortEntry); + JsonObject result = new JsonObject(); + result.put("tab", "Catalog"); + JsonArray rows = new JsonArray(); + for (CatalogEntry e : sorted) { + JsonObject row = new JsonObject(); + row.put("name", e.name); + row.put("kind", e.kind); + row.put("title", e.title); + row.put("description", e.description); + if (e.label != null) { + row.put("label", e.label); + } + row.put("artifactId", e.artifactId); + if (e.deprecated) { + row.put("deprecated", true); + } + rows.add(row); + } + result.put("rows", rows); + result.put("totalRows", sorted.size()); + Integer sel = tableState.selected(); + result.put("selectedIndex", sel != null ? sel : -1); + return result; + } + + // ---- Data Class ---- + + static class CatalogEntry { + String name; + String kind; + String title; + String description; + String label; + String artifactId; + boolean deprecated; + } +} diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TabRegistry.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TabRegistry.java index 1a62be602b91..51a991c8404e 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TabRegistry.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TabRegistry.java @@ -82,6 +82,7 @@ class TabRegistry { private ConfigurationTab configurationTab; private BeansTab beansTab; private BrowseTab browseTab; + private CatalogTab catalogTab; private ClasspathTab classpathTab; private MavenDependenciesTab mavenDependenciesTab; private CveAuditTab cveAuditTab; @@ -132,6 +133,7 @@ class TabRegistry { configurationTab = new ConfigurationTab(ctx); beansTab = new BeansTab(ctx); browseTab = new BrowseTab(ctx); + catalogTab = new CatalogTab(ctx); classpathTab = new ClasspathTab(ctx); mavenDependenciesTab = new MavenDependenciesTab(ctx); cveAuditTab = new CveAuditTab(ctx); @@ -154,6 +156,7 @@ class TabRegistry { moreTabs = List.of( new MoreTab(TuiIcons.TAB_BEANS, "Beans", "&Beans", beansTab), new MoreTab(TuiIcons.TAB_BROWSE, "Browse", "Bro&wse", browseTab), + new MoreTab(TuiIcons.TAB_CATALOG, "Catalog", "Catal&og", catalogTab), new MoreTab(TuiIcons.TAB_CIRCUIT_BREAKER, "Circuit Breaker", "&Circuit Breaker", circuitBreakerTab), new MoreTab(TuiIcons.TAB_CLASSPATH, "Classpath", "Cl&asspath", classpathTab), new MoreTab(TuiIcons.TAB_CONFIGURATION, "Configuration", "Confi&guration", configurationTab), diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiIcons.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiIcons.java index 95237e6e990e..395d0a118149 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiIcons.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiIcons.java @@ -126,6 +126,7 @@ final class TuiIcons { // ---- More submenu tabs ---- static final String TAB_BEANS = JAVA; static final String TAB_BROWSE = MESSAGE; + static final String TAB_CATALOG = "📖"; static final String TAB_CIRCUIT_BREAKER = "⚡"; static final String TAB_CLASSPATH = BUNDLED; static final String TAB_CONFIGURATION = DOCUMENT; diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiPlugin.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiPlugin.java index e8eb93466796..d51a01e23aa4 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiPlugin.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiPlugin.java @@ -34,8 +34,7 @@ public class TuiPlugin implements Plugin { @Override public void customize(CommandLine commandLine, CamelJBangMain main) { var cmd = new CommandLine(new TuiCommand(main, classLoader)) - .addSubcommand("monitor", new CommandLine(new CamelMonitor(main, classLoader))) - .addSubcommand("catalog", new CommandLine(new CamelCatalogTui(main, classLoader))); + .addSubcommand("monitor", new CommandLine(new CamelMonitor(main, classLoader))); commandLine.addSubcommand("tui", cmd); }
