diff --git a/pom.xml b/pom.xml index cad65101b..ccb5606b8 100644 --- a/pom.xml +++ b/pom.xml @@ -25,6 +25,7 @@ 3.21.0 1.81 4.3.1 + 1.2 @@ -110,6 +111,11 @@ ST4 ${antlr-st4.version} + + com.google.jimfs + jimfs + ${jimfs.version} + diff --git a/spring-shell-core/pom.xml b/spring-shell-core/pom.xml index ce87eba41..ddd52cc1c 100644 --- a/spring-shell-core/pom.xml +++ b/spring-shell-core/pom.xml @@ -52,6 +52,15 @@ assertj-core test - + + org.awaitility + awaitility + test + + + com.google.jimfs + jimfs + test + diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/MultiItemSelector.java b/spring-shell-core/src/main/java/org/springframework/shell/component/MultiItemSelector.java new file mode 100644 index 000000000..791475448 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/MultiItemSelector.java @@ -0,0 +1,162 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.shell.component; + +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.jline.terminal.Terminal; +import org.jline.utils.AttributedString; + +import org.springframework.shell.component.context.ComponentContext; +import org.springframework.shell.component.support.AbstractSelectorComponent; +import org.springframework.shell.component.support.Enableable; +import org.springframework.shell.component.support.Itemable; +import org.springframework.shell.component.support.Matchable; +import org.springframework.shell.component.support.Nameable; +import org.springframework.shell.component.support.AbstractSelectorComponent.SelectorComponentContext; +import org.springframework.shell.component.MultiItemSelector.MultiItemSelectorContext; + +/** + * Component able to pick multiple items. + * + * @author Janne Valkealahti + */ +public class MultiItemSelector> + extends AbstractSelectorComponent, I> { + + private MultiItemSelectorContext currentContext; + + public MultiItemSelector(Terminal terminal, List items, String name, Comparator comparator) { + super(terminal, name, items, false, comparator); + setRenderer(new DefaultRenderer()); + setTemplateLocation("classpath:org/springframework/shell/component/multi-item-selector-default.stg"); + } + + @Override + protected MultiItemSelectorContext getThisContext(ComponentContext context) { + if (context != null && currentContext == context) { + return currentContext; + } + currentContext = MultiItemSelectorContext.empty(getItemMapper()); + currentContext.setName(name); + if (currentContext.getItems() == null) { + currentContext.setItems(getItems()); + } + context.stream().forEach(e -> { + currentContext.put(e.getKey(), e.getValue()); + }); + return currentContext; + } + + @Override + protected MultiItemSelectorContext runInternal(MultiItemSelectorContext context) { + super.runInternal(context); + loop(context); + return context; + } + + /** + * Context {@link MultiItemSelector}. + */ + public interface MultiItemSelectorContext> + extends SelectorComponentContext> { + + /** + * Gets a values. + * + * @return a values + */ + List getValues(); + + /** + * Creates an empty {@link MultiItemSelectorContext}. + * + * @return empty context + */ + static > MultiItemSelectorContext empty() { + return new DefaultMultiItemSelectorContext<>(); + } + + /** + * Creates an {@link MultiItemSelectorContext}. + * + * @return context + */ + static > MultiItemSelectorContext empty(Function itemMapper) { + return new DefaultMultiItemSelectorContext<>(itemMapper); + } + } + + private static class DefaultMultiItemSelectorContext> extends + BaseSelectorComponentContext> implements MultiItemSelectorContext { + + private Function itemMapper = item -> item.toString(); + + DefaultMultiItemSelectorContext() { + } + + DefaultMultiItemSelectorContext(Function itemMapper) { + this.itemMapper = itemMapper; + } + + @Override + public List getValues() { + if (getResultItems() == null) { + return Collections.emptyList(); + } + return getResultItems().stream() + .map(i -> i.getItem()) + .map(i -> itemMapper.apply(i)) + .collect(Collectors.toList()); + } + + @Override + public Map toTemplateModel() { + Map attributes = super.toTemplateModel(); + attributes.put("values", getValues()); + List> rows = getItemStateView().stream() + .map(is -> { + Map map = new HashMap<>(); + map.put("name", is.getName()); + map.put("selected", is.isSelected()); + map.put("onrow", getCursorRow().intValue() == is.getIndex()); + map.put("enabled", is.isEnabled()); + return map; + }) + .collect(Collectors.toList()); + attributes.put("rows", rows); + // finally wrap it into 'model' as that's what + // we expect in stg template. + Map model = new HashMap<>(); + model.put("model", attributes); + return model; + } + } + + private class DefaultRenderer implements Function, List> { + + @Override + public List apply(MultiItemSelectorContext context) { + return renderTemplateResource(context.toTemplateModel()); + } + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/PathInput.java b/spring-shell-core/src/main/java/org/springframework/shell/component/PathInput.java new file mode 100644 index 000000000..7fa1259b3 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/PathInput.java @@ -0,0 +1,176 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.shell.component; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import org.jline.keymap.BindingReader; +import org.jline.keymap.KeyMap; +import org.jline.terminal.Terminal; +import org.jline.utils.AttributedString; + +import org.springframework.shell.component.PathInput.PathInputContext; +import org.springframework.shell.component.context.ComponentContext; +import org.springframework.shell.component.support.AbstractTextComponent; +import org.springframework.shell.component.support.AbstractTextComponent.TextComponentContext; +import org.springframework.shell.component.support.AbstractTextComponent.TextComponentContext.MessageLevel; +import org.springframework.util.StringUtils;; + +/** + * Component for a simple path input. + * + * @author Janne Valkealahti + */ +public class PathInput extends AbstractTextComponent { + + private PathInputContext currentContext; + private Function pathProvider = (path) -> Paths.get(path); + + public PathInput(Terminal terminal) { + this(terminal, null); + } + + public PathInput(Terminal terminal, String name) { + this(terminal, name, null); + } + + public PathInput(Terminal terminal, String name, Function> renderer) { + super(terminal, name, null); + setRenderer(renderer != null ? renderer : new DefaultRenderer()); + setTemplateLocation("classpath:org/springframework/shell/component/path-input-default.stg"); + } + + @Override + protected PathInputContext getThisContext(ComponentContext context) { + if (context != null && currentContext == context) { + return currentContext; + } + currentContext = PathInputContext.empty(); + currentContext.setName(getName()); + context.stream().forEach(e -> { + currentContext.put(e.getKey(), e.getValue()); + }); + return currentContext; + } + + @Override + protected boolean read(BindingReader bindingReader, KeyMap keyMap, PathInputContext context) { + String operation = bindingReader.readBinding(keyMap); + String input; + switch (operation) { + case OPERATION_CHAR: + String lastBinding = bindingReader.getLastBinding(); + input = context.getInput(); + if (input == null) { + input = lastBinding; + } + else { + input = input + lastBinding; + } + context.setInput(input); + checkPath(input, context); + break; + case OPERATION_BACKSPACE: + input = context.getInput(); + if (StringUtils.hasLength(input)) { + input = input.length() > 1 ? input.substring(0, input.length() - 1) : null; + } + context.setInput(input); + checkPath(input, context); + break; + case OPERATION_EXIT: + if (StringUtils.hasText(context.getInput())) { + context.setResultValue(Paths.get(context.getInput())); + } + return true; + default: + break; + } + return false; + } + + /** + * Sets a path provider. + * + * @param pathProvider the path provider + */ + public void setPathProvider(Function pathProvider) { + this.pathProvider = pathProvider; + } + + /** + * Resolves a {@link Path} from a given raw {@code path}. + * + * @param path the raw path + * @return a resolved path + */ + protected Path resolvePath(String path) { + return this.pathProvider.apply(path); + } + + private void checkPath(String path, PathInputContext context) { + if (!StringUtils.hasText(path)) { + context.setMessage(null); + return; + } + Path p = resolvePath(path); + boolean isDirectory = Files.isDirectory(p); + if (isDirectory) { + context.setMessage("Directory exists", MessageLevel.ERROR); + } + else { + context.setMessage("Path ok", MessageLevel.INFO); + } + } + + public interface PathInputContext extends TextComponentContext { + + /** + * Gets an empty {@link PathInputContext}. + * + * @return empty path input context + */ + public static PathInputContext empty() { + return new DefaultPathInputContext(); + } + } + + private static class DefaultPathInputContext extends BaseTextComponentContext + implements PathInputContext { + + @Override + public Map toTemplateModel() { + Map attributes = super.toTemplateModel(); + Map model = new HashMap<>(); + model.put("model", attributes); + return model; + } + } + + private class DefaultRenderer implements Function> { + + @Override + public List apply(PathInputContext context) { + return renderTemplateResource(context.toTemplateModel()); + } + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/SingleItemSelector.java b/spring-shell-core/src/main/java/org/springframework/shell/component/SingleItemSelector.java new file mode 100644 index 000000000..ce187512d --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/SingleItemSelector.java @@ -0,0 +1,176 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.shell.component; + +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.jline.terminal.Terminal; +import org.jline.utils.AttributedString; + +import org.springframework.shell.component.context.ComponentContext; +import org.springframework.shell.component.support.AbstractSelectorComponent; +import org.springframework.shell.component.support.Enableable; +import org.springframework.shell.component.support.Itemable; +import org.springframework.shell.component.support.Matchable; +import org.springframework.shell.component.support.Nameable; +import org.springframework.shell.component.support.AbstractSelectorComponent.SelectorComponentContext; +import org.springframework.shell.component.SingleItemSelector.SingleItemSelectorContext; + +/** + * Component able to pick single item. + * + * @author Janne Valkealahti + */ +public class SingleItemSelector> + extends AbstractSelectorComponent, I> { + + private SingleItemSelectorContext currentContext; + + public SingleItemSelector(Terminal terminal, List items, String name, Comparator comparator) { + super(terminal, name, items, true, comparator); + setRenderer(new DefaultRenderer()); + setTemplateLocation("classpath:org/springframework/shell/component/single-item-selector-default.stg"); + } + + @Override + protected SingleItemSelectorContext getThisContext(ComponentContext context) { + if (context != null && currentContext == context) { + return currentContext; + } + currentContext = SingleItemSelectorContext.empty(getItemMapper()); + currentContext.setName(name); + if (currentContext.getItems() == null) { + currentContext.setItems(getItems()); + } + context.stream().forEach(e -> { + currentContext.put(e.getKey(), e.getValue()); + }); + return currentContext; + } + + @Override + protected SingleItemSelectorContext runInternal(SingleItemSelectorContext context) { + super.runInternal(context); + loop(context); + return context; + } + + /** + * Context {@link SingleItemSelector}. + */ + public interface SingleItemSelectorContext> + extends SelectorComponentContext> { + + /** + * Gets a result item. + * + * @return a result item + */ + Optional getResultItem(); + + /** + * Gets a value. + * + * @return a value + */ + Optional getValue(); + + /** + * Creates an empty {@link SingleItemSelectorContext}. + * + * @return empty context + */ + static > SingleItemSelectorContext empty() { + return new DefaultSingleItemSelectorContext<>(); + } + + /** + * Creates a {@link SingleItemSelectorContext}. + * + * @return context + */ + static > SingleItemSelectorContext empty(Function itemMapper) { + return new DefaultSingleItemSelectorContext<>(itemMapper); + } + } + + private static class DefaultSingleItemSelectorContext> extends + BaseSelectorComponentContext> implements SingleItemSelectorContext { + + private Function itemMapper = item -> item.toString(); + + DefaultSingleItemSelectorContext() { + } + + DefaultSingleItemSelectorContext(Function itemMapper) { + this.itemMapper = itemMapper; + } + + @Override + public Optional getResultItem() { + if (getResultItems() == null) { + return Optional.empty(); + } + return getResultItems().stream().findFirst(); + } + + @Override + public Optional getValue() { + return getResultItem().map(item -> itemMapper.apply(item.getItem())); + } + + @Override + public Map toTemplateModel() { + Map attributes = super.toTemplateModel(); + getValue().ifPresent(value -> { + attributes.put("value", value); + }); + List> rows = getItemStateView().stream() + .map(is -> { + Map map = new HashMap<>(); + map.put("name", is.getName()); + map.put("selected", getCursorRow().intValue() == is.getIndex()); + return map; + }) + .collect(Collectors.toList()); + attributes.put("rows", rows); + // finally wrap it into 'model' as that's what + // we expect in stg template. + Map model = new HashMap<>(); + model.put("model", attributes); + return model; + } + + @Override + public String toString() { + return "DefaultSingleItemSelectorContext [super=" + super.toString() + "]"; + } + } + + private class DefaultRenderer implements Function, List> { + + @Override + public List apply(SingleItemSelectorContext context) { + return renderTemplateResource(context.toTemplateModel()); + } + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/StringInput.java b/spring-shell-core/src/main/java/org/springframework/shell/component/StringInput.java new file mode 100644 index 000000000..5271fbf05 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/StringInput.java @@ -0,0 +1,181 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.shell.component; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import org.jline.keymap.BindingReader; +import org.jline.keymap.KeyMap; +import org.jline.terminal.Terminal; +import org.jline.utils.AttributedString; + +import org.springframework.shell.component.StringInput.StringInputContext; +import org.springframework.shell.component.context.ComponentContext; +import org.springframework.shell.component.support.AbstractTextComponent; +import org.springframework.shell.component.support.AbstractTextComponent.TextComponentContext; +import org.springframework.util.StringUtils; + +/** + * Component for a simple string input. + * + * @author Janne Valkealahti + */ +public class StringInput extends AbstractTextComponent { + + private final String defaultValue; + private StringInputContext currentContext; + + public StringInput(Terminal terminal) { + this(terminal, null, null, null); + } + + public StringInput(Terminal terminal, String name, String defaultValue) { + this(terminal, name, defaultValue, null); + } + + public StringInput(Terminal terminal, String name, String defaultValue, + Function> renderer) { + super(terminal, name, null); + setRenderer(renderer != null ? renderer : new DefaultRenderer()); + setTemplateLocation("classpath:org/springframework/shell/component/string-input-default.stg"); + this.defaultValue = defaultValue; + } + + @Override + protected StringInputContext getThisContext(ComponentContext context) { + if (context != null && currentContext == context) { + return currentContext; + } + currentContext = StringInputContext.of(defaultValue); + currentContext.setName(getName()); + context.stream().forEach(e -> { + currentContext.put(e.getKey(), e.getValue()); + }); + return currentContext; + } + + @Override + protected boolean read(BindingReader bindingReader, KeyMap keyMap, StringInputContext context) { + String operation = bindingReader.readBinding(keyMap); + String input; + switch (operation) { + case OPERATION_CHAR: + String lastBinding = bindingReader.getLastBinding(); + input = context.getInput(); + if (input == null) { + input = lastBinding; + } + else { + input = input + lastBinding; + } + context.setInput(input); + break; + case OPERATION_BACKSPACE: + input = context.getInput(); + if (StringUtils.hasLength(input)) { + input = input.length() > 1 ? input.substring(0, input.length() - 1) : null; + } + context.setInput(input); + break; + case OPERATION_EXIT: + if (StringUtils.hasText(context.getInput())) { + context.setResultValue(context.getInput()); + } + else if (context.getDefaultValue() != null) { + context.setResultValue(context.getDefaultValue()); + } + return true; + default: + break; + } + return false; + } + + public interface StringInputContext extends TextComponentContext { + + /** + * Gets a default value. + * + * @return a default value + */ + String getDefaultValue(); + + /** + * Sets a default value. + * + * @param defaultValue the default value + */ + void setDefaultValue(String defaultValue); + + /** + * Gets an empty {@link StringInputContext}. + * + * @return empty path input context + */ + public static StringInputContext empty() { + return of(null); + } + + /** + * Gets an {@link StringInputContext}. + * + * @return path input context + */ + public static StringInputContext of(String defaultValue) { + return new DefaultStringInputContext(defaultValue); + } + } + + private static class DefaultStringInputContext extends BaseTextComponentContext + implements StringInputContext { + + private String defaultValue; + + public DefaultStringInputContext(String defaultValue) { + this.defaultValue = defaultValue; + } + + @Override + public String getDefaultValue() { + return defaultValue; + } + + @Override + public void setDefaultValue(String defaultValue) { + this.defaultValue = defaultValue; + } + + @Override + public Map toTemplateModel() { + Map attributes = super.toTemplateModel(); + attributes.put("defaultValue", getDefaultValue() != null ? getDefaultValue() : null); + Map model = new HashMap<>(); + model.put("model", attributes); + return model; + } + } + + private class DefaultRenderer implements Function> { + + @Override + public List apply(StringInputContext context) { + return renderTemplateResource(context.toTemplateModel()); + } + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/context/BaseComponentContext.java b/spring-shell-core/src/main/java/org/springframework/shell/component/context/BaseComponentContext.java new file mode 100644 index 000000000..a3e9e5d88 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/context/BaseComponentContext.java @@ -0,0 +1,71 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.shell.component.context; + +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.stream.Stream; + +/** + * Base implementation of a {@link ComponentContext}. + * + * @author Janne Valkealahti + */ +@SuppressWarnings("unchecked") +public class BaseComponentContext> extends LinkedHashMap + implements ComponentContext { + + @Override + public Object get(Object key) { + Object o = super.get(key); + if (o != null) { + return o; + } + throw new NoSuchElementException("Context does not contain key: " + key); + } + + @Override + public T get(Object key, Class type) { + Object value = get(key); + if (!type.isAssignableFrom(value.getClass())) { + throw new IllegalArgumentException("Incorrect type specified for key '" + + key + "'. Expected [" + type + "] but actual type is [" + value.getClass() + "]"); + } + return (T) value; + } + + @Override + public ComponentContext put(Object key, Object value) { + super.put(key, value); + return this; + } + + @Override + public Stream> stream() { + return entrySet().stream(); + } + + @Override + public Map toTemplateModel() { + Map attributes = new HashMap<>(); + // hardcoding enclosed map values into 'rawValues' + // as it may contain anything + attributes.put("rawValues", this); + return attributes; + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/context/ComponentContext.java b/spring-shell-core/src/main/java/org/springframework/shell/component/context/ComponentContext.java new file mode 100644 index 000000000..f3872f36a --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/context/ComponentContext.java @@ -0,0 +1,84 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.shell.component.context; + +import java.util.Map; +import java.util.stream.Stream; + +/** + * Contract for base component context having access to basic key/value pairs. + * This is a base context which components can extend to provide their own + * component spesific contexts. + * + * @author Janne Valkealahti + */ +public interface ComponentContext> { + + /** + * Gets an empty context. + * + * @param the type of context + * @return empty context + */ + static > ComponentContext empty() { + return new BaseComponentContext<>(); + } + + /** + * Gets a value from a context. + * + * @param the type of context + * @param key the key + * @return a value + */ + T get(Object key); + + /** + * Gets a value from a context with a given type to get cast to. + * + * @param the type of context + * @param key the key + * @param type the class type + * @return a value + */ + T get(Object key, Class type); + + /** + * Put an entry into a context. + * + * @param key the entry key + * @param value the entry value + * @return a context + */ + ComponentContext put(Object key, Object value); + + /** + * Stream key/value pairs from this {@link ComponentContext} + * + * @return a {@link Stream} of key/value pairs held by this context + */ + Stream> stream(); + + /** + * Gets context values as a map. Every context implementation can + * do their own model as essentially what matter is a one coming + * out from a last child which is one most likely to feed into + * a template engine. + * + * @return map of context values + */ + Map toTemplateModel(); +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/support/AbstractComponent.java b/spring-shell-core/src/main/java/org/springframework/shell/component/support/AbstractComponent.java new file mode 100644 index 000000000..75c0418cc --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/support/AbstractComponent.java @@ -0,0 +1,326 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.shell.component.support; + +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.Reader; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.jline.keymap.BindingReader; +import org.jline.keymap.KeyMap; +import org.jline.terminal.Attributes; +import org.jline.terminal.Size; +import org.jline.terminal.Terminal; +import org.jline.utils.AttributedString; +import org.jline.utils.Display; +import org.jline.utils.InfoCmp.Capability; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.context.ResourceLoaderAware; +import org.springframework.core.io.Resource; +import org.springframework.core.io.ResourceLoader; +import org.springframework.shell.component.context.ComponentContext; +import org.springframework.shell.style.TemplateExecutor; +import org.springframework.util.Assert; +import org.springframework.util.FileCopyUtils; +import org.springframework.util.StringUtils; + +/** + * Base class for components. + * + * @author Janne Valkealahti + */ +public abstract class AbstractComponent> implements ResourceLoaderAware { + + private final static Logger log = LoggerFactory.getLogger(AbstractComponent.class); + public final static String OPERATION_EXIT = "EXIT"; + public final static String OPERATION_BACKSPACE = "BACKSPACE"; + public final static String OPERATION_CHAR = "CHAR"; + public final static String OPERATION_SELECT = "SELECT"; + public final static String OPERATION_DOWN = "DOWN"; + public final static String OPERATION_UP = "UP"; + + private final Terminal terminal; + private final BindingReader bindingReader; + private final KeyMap keyMap = new KeyMap<>(); + private final List> preRunHandlers = new ArrayList<>(); + private final List> postRunHandlers = new ArrayList<>(); + private Function> renderer; + private boolean printResults = true; + private String templateLocation; + private TemplateExecutor templateExecutor; + private ResourceLoader resourceLoader; + + public AbstractComponent(Terminal terminal) { + Assert.notNull(terminal, "terminal must be set"); + this.terminal = terminal; + this.bindingReader = new BindingReader(terminal.reader()); + } + + @Override + public void setResourceLoader(ResourceLoader resourceLoader) { + this.resourceLoader = resourceLoader; + } + + /** + * Gets a {@link Terminal}. + * + * @return a terminal + */ + public Terminal getTerminal() { + return terminal; + } + + /** + * Sets a display renderer. + * + * @param renderer the display renderer function + */ + public void setRenderer(Function> renderer) { + this.renderer = renderer; + } + + /** + * Render to be shows content of a display with set display renderer using a + * given context. + * + * @param context the context + * @return list of attributed strings + */ + public List render(T context) { + log.debug("Rendering with context [{}] as class [{}] in [{}]", context, context.getClass(), this); + return renderer.apply(context); + } + + /** + * Adds a pre-run handler. + * + * @param handler the handler + */ + public void addPreRunHandler(Consumer handler) { + this.preRunHandlers.add(handler); + } + + /** + * Adds a post-run handler. + * + * @param handler the handler + */ + public void addPostRunHandler(Consumer handler) { + this.postRunHandlers.add(handler); + } + + /** + * Sets if results should be printed into a console, Defaults to {@code true}. + * + * @param printResults flag setting if results are printed + */ + public void setPrintResults(boolean printResults) { + this.printResults = printResults; + } + + /** + * Runs a component logic with a given context and returns updated context. + * + * @param context the context + * @return a context + */ + public final T run(ComponentContext context) { + bindKeyMap(keyMap); + context = runPreRunHandlers(getThisContext(context)); + T run = runInternal(getThisContext(context)); + context = runPostRunHandlers(getThisContext(context)); + if (printResults) { + printResults(context); + } + return run; + } + + /** + * Gets a template executor. + * + * @return a template executor + */ + public TemplateExecutor getTemplateExecutor() { + return templateExecutor; + } + + /** + * Sets a template executor. + * + * @param templateExecutor the template executor + */ + public void setTemplateExecutor(TemplateExecutor templateExecutor) { + this.templateExecutor = templateExecutor; + } + + /** + * Sets a template location. + * + * @param templateLocation the template location + */ + public void setTemplateLocation(String templateLocation) { + this.templateLocation = templateLocation; + } + + /** + * Render a given template with attributes. + * + * @param attributes the attributes + * @return rendered content as attributed strings + */ + protected List renderTemplateResource(Map attributes) { + String templateResource = resourceAsString(resourceLoader.getResource(templateLocation)); + log.debug("Rendering template: {}", templateResource); + log.debug("Rendering template attributes: {}", attributes); + AttributedString rendered; + if (templateLocation.endsWith(".stg")) { + rendered = templateExecutor.renderGroup(templateResource, attributes); + } + else { + rendered = templateExecutor.render(templateResource, attributes); + } + log.debug("Template executor result: [{}]", rendered); + List rows = rendered.columnSplitLength(Integer.MAX_VALUE); + // remove last if empty as columnsplit adds it + int lastIndex = rows.size() - 1; + if (lastIndex > 0 && rows.get(lastIndex).length() == 0) { + rows.remove(lastIndex); + } + return rows; + } + + /** + * Gets a real component context using common this trick. + * + * @param context the context + * @return a component context + */ + protected abstract T getThisContext(ComponentContext context); + + /** + * Read input. + * + * @param bindingReader the binding reader + * @param keyMap the key map + * @param context the context + * @return true if read is complete, false to stop + */ + protected abstract boolean read(BindingReader bindingReader, KeyMap keyMap, T context); + + /** + * Run internal logic called from public run method. + * + * @param context the context + * @return a context + */ + protected abstract T runInternal(T context); + + /** + * Bind key map. + * + * @param keyMap + */ + protected abstract void bindKeyMap(KeyMap keyMap); + + /** + * Enter into read loop. This should be called from a component. + * + * @param context the context + */ + protected void loop(ComponentContext context) { + Display display = new Display(terminal, false); + Attributes attr = terminal.enterRawMode(); + Size size = new Size(); + + try { + terminal.puts(Capability.keypad_xmit); + terminal.puts(Capability.cursor_invisible); + terminal.writer().flush(); + size.copy(terminal.getSize()); + display.clear(); + display.reset(); + + while (true) { + display.resize(size.getRows(), size.getColumns()); + display.update(render(getThisContext(context)), 0); + boolean exit = read(bindingReader, keyMap, getThisContext(context)); + if (exit) { + break; + } + } + } + finally { + terminal.setAttributes(attr); + terminal.puts(Capability.keypad_local); + terminal.puts(Capability.cursor_visible); + display.update(Collections.emptyList(), 0); + } + } + + /** + * Run pre-run handlers + * + * @param context the context + * @return a context + */ + protected T runPreRunHandlers(T context) { + this.preRunHandlers.stream().forEach(c -> c.accept(context)); + return context; + } + + /** + * Run post-run handlers + * + * @param context the context + * @return a context + */ + protected T runPostRunHandlers(T context) { + this.postRunHandlers.stream().forEach(c -> c.accept(context)); + return context; + } + + private void printResults(ComponentContext context) { + log.debug("About to write result with incoming context [{}] as class [{}] in [{}]", context, context.getClass(), + this); + String out = render(getThisContext(context)).stream() + .map(as -> as.toAnsi()) + .collect(Collectors.joining("\n")); + log.debug("Writing result [{}] in [{}]", out, this); + if (StringUtils.hasText(out)) { + terminal.writer().println(out); + terminal.writer().flush(); + } + } + + private static String resourceAsString(Resource resource) { + try (Reader reader = new InputStreamReader(resource.getInputStream(), StandardCharsets.UTF_8)) { + return FileCopyUtils.copyToString(reader); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/support/AbstractSelectorComponent.java b/spring-shell-core/src/main/java/org/springframework/shell/component/support/AbstractSelectorComponent.java new file mode 100644 index 000000000..3257701e5 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/support/AbstractSelectorComponent.java @@ -0,0 +1,532 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.shell.component.support; + +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.jline.keymap.BindingReader; +import org.jline.keymap.KeyMap; +import org.jline.terminal.Terminal; +import org.jline.utils.InfoCmp.Capability; + +import org.springframework.shell.component.context.BaseComponentContext; +import org.springframework.shell.component.context.ComponentContext; +import org.springframework.shell.component.support.AbstractSelectorComponent.SelectorComponentContext; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +import static org.jline.keymap.KeyMap.ctrl; +import static org.jline.keymap.KeyMap.del; +import static org.jline.keymap.KeyMap.key; + +/** + * Base component for selectors which provide selectable lists. + * + * @author Janne Valkealahti + */ +public abstract class AbstractSelectorComponent, I extends Nameable & Matchable & Enableable & Itemable> + extends AbstractComponent { + + protected final String name; + private final List items; + private Comparator comparator = (o1, o2) -> 0; + private boolean exitSelects; + private int maxItems = 5; + private Function itemMapper = item -> item.toString(); + private boolean stale = false; + private AtomicInteger start = new AtomicInteger(0); + private AtomicInteger pos = new AtomicInteger(0); + + public AbstractSelectorComponent(Terminal terminal, String name, List items, boolean exitSelects, + Comparator comparator) { + super(terminal); + this.name = name; + this.items = items; + this.exitSelects = exitSelects; + if (comparator != null) { + this.comparator = comparator; + } + } + + /** + * Set max items to show. + * + * @param maxItems max items + */ + public void setMaxItems(int maxItems) { + Assert.state(maxItems > 0 || maxItems < 33, "maxItems has to be between 1 and 32"); + this.maxItems = maxItems; + } + + /** + * Sets an item mapper. + * + * @param itemMapper the item mapper + */ + public void setItemMapper(Function itemMapper) { + Assert.notNull(itemMapper, "itemMapper cannot be null"); + this.itemMapper = itemMapper; + } + + /** + * Gets an item mapper. + * + * @return + */ + public Function getItemMapper() { + return itemMapper; + } + + /** + * Gets items. + * + * @return a list of items + */ + protected List getItems() { + return items; + } + + @Override + protected void bindKeyMap(KeyMap keyMap) { + keyMap.bind(OPERATION_SELECT, " "); + keyMap.bind(OPERATION_DOWN, ctrl('E'), key(getTerminal(), Capability.key_down)); + keyMap.bind(OPERATION_UP, ctrl('Y'), key(getTerminal(), Capability.key_up)); + keyMap.bind(OPERATION_EXIT, "\r"); + keyMap.bind(OPERATION_BACKSPACE, del(), key(getTerminal(), Capability.key_backspace)); + // skip 32 - SPACE, 127 - DEL + for (char i = 33; i < KeyMap.KEYMAP_LENGTH - 1; i++) { + keyMap.bind(OPERATION_CHAR, Character.toString(i)); + } + } + + @Override + protected C runInternal(C context) { + C thisContext = getThisContext(context); + ItemStateViewProjection buildItemStateView = buildItemStateView(start.get(), thisContext); + List> itemStateView = buildItemStateView.items; + thisContext.setItemStateView(itemStateView); + thisContext.setCursorRow(start.get() + pos.get()); + return thisContext; + } + + @Override + protected boolean read(BindingReader bindingReader, KeyMap keyMap, C context) { + + if (stale) { + start.set(0); + pos.set(0); + stale = false; + } + C thisContext = getThisContext(context); + ItemStateViewProjection buildItemStateView = buildItemStateView(start.get(), thisContext); + List> itemStateView = buildItemStateView.items; + String operation = bindingReader.readBinding(keyMap); + String input; + switch (operation) { + case OPERATION_SELECT: + if (!exitSelects) { + itemStateView.forEach(i -> { + if (i.index == start.get() + pos.get() && i.enabled) { + i.selected = !i.selected; + } + }); + } + break; + case OPERATION_DOWN: + if (start.get() + pos.get() + 1 < itemStateView.size()) { + pos.incrementAndGet(); + } + else if (start.get() + pos.get() + 1 >= buildItemStateView.total) { + start.set(0); + pos.set(0); + } + else { + start.incrementAndGet(); + } + break; + case OPERATION_UP: + if (start.get() > 0 && pos.get() == 0) { + start.decrementAndGet(); + } + else if (start.get() + pos.get() >= itemStateView.size()) { + pos.decrementAndGet(); + } + else if (start.get() + pos.get() <= 0) { + start.set(buildItemStateView.total - Math.min(maxItems, itemStateView.size())); + pos.set(itemStateView.size() - 1); + } + else { + pos.decrementAndGet(); + } + break; + case OPERATION_CHAR: + String lastBinding = bindingReader.getLastBinding(); + input = thisContext.getInput(); + if (input == null) { + input = lastBinding; + } + else { + input = input + lastBinding; + } + thisContext.setInput(input); + + stale = true; + break; + case OPERATION_BACKSPACE: + input = thisContext.getInput(); + if (StringUtils.hasLength(input)) { + input = input.length() > 1 ? input.substring(0, input.length() - 1) : null; + } + thisContext.setInput(input); + break; + case OPERATION_EXIT: + if (exitSelects) { + if (itemStateView.size() == 0) { + // filter shows nothing, prevent exit + break; + } + itemStateView.forEach(i -> { + if (i.index == start.get() + pos.get()) { + i.selected = !i.selected; + } + }); + } + List values = thisContext.getItemStates().stream() + .filter(i -> i.selected) + .map(i -> i.item) + .collect(Collectors.toList()); + thisContext.setResultItems(values); + return true; + default: + break; + } + thisContext.setCursorRow(start.get() + pos.get()); + buildItemStateView = buildItemStateView(start.get(), thisContext); + thisContext.setItemStateView(buildItemStateView.items); + return false; + } + + private ItemStateViewProjection buildItemStateView(int skip, SelectorComponentContext context) { + List> itemStates = context.getItemStates(); + if (itemStates == null) { + AtomicInteger index = new AtomicInteger(0); + itemStates = context.getItems().stream() + .sorted(comparator) + .map(item -> ItemState.of(item, item.getName(), index.getAndIncrement(), item.isEnabled())) + .collect(Collectors.toList()); + context.setItemStates(itemStates); + } + AtomicInteger reindex = new AtomicInteger(0); + List> filtered = itemStates.stream() + .filter(i -> { + return i.matches(context.getInput()); + }) + .map(i -> { + i.index = reindex.getAndIncrement(); + return i; + }) + .collect(Collectors.toList()); + List> items = filtered.stream() + .skip(skip) + .limit(maxItems) + .collect(Collectors.toList()); + return new ItemStateViewProjection(items, filtered.size()); + } + + private class ItemStateViewProjection { + List> items; + int total; + ItemStateViewProjection(List> items, int total) { + this.items = items; + this.total = total; + } + } + + /** + * Context interface on a selector component sharing content. + */ + public interface SelectorComponentContext, C extends SelectorComponentContext> + extends ComponentContext { + + /** + * Gets a name. + * + * @return a name + */ + String getName(); + + /** + * Sets a name + * + * @param name the name + */ + void setName(String name); + + /** + * Gets an input. + * + * @return an input + */ + String getInput(); + + /** + * Sets an input. + * + * @param input the input + */ + void setInput(String input); + + /** + * Gets an item states + * + * @return an item states + */ + List> getItemStates(); + + /** + * Sets an item states. + * + * @param itemStateView the input state + */ + void setItemStates(List> itemStateView); + + /** + * Gets an item state view. + * + * @return an item state view + */ + List> getItemStateView(); + + /** + * Sets an item state view + * + * @param itemStateView the item state view + */ + void setItemStateView(List> itemStateView); + + /** + * Return if there is a result. + * + * @return true if context represents result + */ + boolean isResult(); + + /** + * Gets a cursor row. + * + * @return a cursor row. + */ + Integer getCursorRow(); + + /** + * Sets a cursor row. + * + * @param cursorRow the cursor row + */ + void setCursorRow(Integer cursorRow); + + /** + * Gets an items. + * + * @return an items + */ + List getItems(); + + /** + * Sets an items. + * + * @param items the items + */ + void setItems(List items); + + /** + * Gets a result items. + * + * @return a result items + */ + List getResultItems(); + + /** + * Sets a result items. + * + * @param items the result items + */ + void setResultItems(List items); + + /** + * Creates an empty {@link SelectorComponentContext}. + * + * @return empty context + */ + static , C extends SelectorComponentContext> SelectorComponentContext empty() { + return new BaseSelectorComponentContext<>(); + } + } + + /** + * Base implementation of a {@link SelectorComponentContext}. + */ + protected static class BaseSelectorComponentContext, C extends SelectorComponentContext> + extends BaseComponentContext implements SelectorComponentContext { + + private String name; + private String input; + private List> itemStates; + private List> itemStateView; + private Integer cursorRow; + private List items; + private List resultItems; + + @Override + public String getName() { + return name; + } + + @Override + public void setName(String name) { + this.name = name; + } + + @Override + public String getInput() { + return input; + } + + @Override + public void setInput(String input) { + this.input = input; + } + + @Override + public List> getItemStates() { + return itemStates; + } + + @Override + public void setItemStates(List> itemStates) { + this.itemStates = itemStates; + }; + + @Override + public List> getItemStateView() { + return itemStateView; + } + + @Override + public void setItemStateView(List> itemStateView) { + this.itemStateView = itemStateView; + }; + + @Override + public boolean isResult() { + return resultItems != null; + } + + @Override + public Integer getCursorRow() { + return cursorRow; + } + + @Override + public java.util.Map toTemplateModel() { + Map attributes = super.toTemplateModel(); + attributes.put("name", getName()); + attributes.put("input", getInput()); + attributes.put("itemStates", getItemStates()); + attributes.put("itemStateView", getItemStateView()); + attributes.put("isResult", isResult()); + attributes.put("cursorRow", getCursorRow()); + return attributes; + }; + + public void setCursorRow(Integer cursorRow) { + this.cursorRow = cursorRow; + }; + + @Override + public List getItems() { + return items; + } + + @Override + public void setItems(List items) { + this.items = items; + } + + @Override + public List getResultItems() { + return resultItems; + } + + @Override + public void setResultItems(List resultItems) { + this.resultItems = resultItems; + } + + @Override + public String toString() { + return "DefaultSelectorComponentContext [cursorRow=" + cursorRow + "]"; + } + } + + /** + * Class keeping item state. + */ + public static class ItemState implements Matchable { + I item; + String name; + boolean selected; + boolean enabled; + int index; + + ItemState(I item, String name, int index, boolean enabled) { + this.item = item; + this.name = name; + this.index = index; + this.enabled = enabled; + } + + public boolean matches(String match) { + return item.matches(match); + }; + + public int getIndex() { + return index; + } + + public String getName() { + return name; + } + + public boolean isSelected() { + return selected; + } + + public boolean isEnabled() { + return enabled; + } + + static ItemState of(I item, String name, int index, boolean enabled) { + return new ItemState(item, name, index, enabled); + } + } + +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/support/AbstractTextComponent.java b/spring-shell-core/src/main/java/org/springframework/shell/component/support/AbstractTextComponent.java new file mode 100644 index 000000000..1dfa7bd23 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/support/AbstractTextComponent.java @@ -0,0 +1,251 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.shell.component.support; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import org.jline.keymap.KeyMap; +import org.jline.terminal.Terminal; +import org.jline.utils.AttributedString; +import org.jline.utils.InfoCmp.Capability; + +import org.springframework.shell.component.context.BaseComponentContext; +import org.springframework.shell.component.context.ComponentContext; +import org.springframework.shell.component.support.AbstractTextComponent.TextComponentContext; + +import static org.jline.keymap.KeyMap.del; +import static org.jline.keymap.KeyMap.key; + +/** + * Base class for components which work on a simple text input. + * + * @author Janne Valkealahti + */ +public abstract class AbstractTextComponent> extends AbstractComponent { + + private final String name; + + public AbstractTextComponent(Terminal terminal) { + this(terminal, null); + } + + public AbstractTextComponent(Terminal terminal, String name) { + this(terminal, name, null); + } + + public AbstractTextComponent(Terminal terminal, String name, Function> renderer) { + super(terminal); + this.name = name; + setRenderer(renderer); + } + + @Override + protected void bindKeyMap(KeyMap keyMap) { + keyMap.bind(OPERATION_EXIT, "\r"); + keyMap.bind(OPERATION_BACKSPACE, del(), key(getTerminal(), Capability.key_backspace)); + // skip 127 - DEL + for (char i = 32; i < KeyMap.KEYMAP_LENGTH - 1; i++) { + keyMap.bind(OPERATION_CHAR, Character.toString(i)); + } + } + + @Override + protected C runInternal(C context) { + loop(context); + return context; + } + + /** + * Gets a name. + * + * @return a name + */ + protected String getName() { + return name; + } + + public interface TextComponentContext> extends ComponentContext { + + /** + * Gets a name. + * + * @return a name + */ + String getName(); + + /** + * Sets a name. + * + * @param name the name + */ + void setName(String name); + + /** + * Gets an input. + * + * @return an input + */ + String getInput(); + + /** + * Sets an input. + * + * @param input the input + */ + void setInput(String input); + + /** + * Sets a result value. + * + * @return a result value + */ + T getResultValue(); + + /** + * Sets a result value. + * + * @param resultValue the result value + */ + void setResultValue(T resultValue); + + /** + * Sets a message. + * + * @return a message + */ + String getMessage(); + + /** + * Sets a message. + * + * @param message the message + */ + void setMessage(String message); + + /** + * Sets a message with level. + * + * @param message the message + * @param level the message level + */ + void setMessage(String message, MessageLevel level); + + /** + * Gets a {@link MessageLevel}. + * + * @return a message level + */ + MessageLevel getMessageLevel(); + + /** + * Sets a {@link MessageLevel}. + * + * @param level the message level + */ + void setMessageLevel(MessageLevel level); + + /** + * Message levels which can be used to alter how message is shown. + */ + public enum MessageLevel { + INFO, + WARN, + ERROR + } + } + + public static class BaseTextComponentContext> extends BaseComponentContext + implements TextComponentContext { + + private String name; + private String input; + private T resultValue; + private String message; + private MessageLevel messageLevel = MessageLevel.INFO; + + @Override + public String getName() { + return name; + } + + @Override + public void setName(String name) { + this.name = name; + } + + @Override + public String getInput() { + return input; + } + + @Override + public void setInput(String input) { + this.input = input; + } + + @Override + public T getResultValue() { + return resultValue; + } + + @Override + public void setResultValue(T resultValue) { + this.resultValue = resultValue; + } + + @Override + public String getMessage() { + return message; + } + + @Override + public void setMessage(String message) { + this.message = message; + } + + @Override + public void setMessage(String message, MessageLevel level) { + setMessage(message); + setMessageLevel(level); + } + + @Override + public MessageLevel getMessageLevel() { + return messageLevel; + } + + @Override + public void setMessageLevel(MessageLevel messageLevel) { + this.messageLevel = messageLevel; + } + + @Override + public Map toTemplateModel() { + Map attributes = super.toTemplateModel(); + attributes.put("resultValue", getResultValue() != null ? getResultValue().toString() : null); + attributes.put("name", getName()); + attributes.put("message", getMessage()); + attributes.put("messageLevel", getMessageLevel()); + attributes.put("hasMessageLevelInfo", getMessageLevel() == MessageLevel.INFO); + attributes.put("hasMessageLevelWarn", getMessageLevel() == MessageLevel.WARN); + attributes.put("hasMessageLevelError", getMessageLevel() == MessageLevel.ERROR); + attributes.put("input", getInput()); + return attributes; + } + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/support/Enableable.java b/spring-shell-core/src/main/java/org/springframework/shell/component/support/Enableable.java new file mode 100644 index 000000000..ae6afb02e --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/support/Enableable.java @@ -0,0 +1,21 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.shell.component.support; + +public interface Enableable { + + boolean isEnabled(); +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/support/Itemable.java b/spring-shell-core/src/main/java/org/springframework/shell/component/support/Itemable.java new file mode 100644 index 000000000..aaa0c166c --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/support/Itemable.java @@ -0,0 +1,21 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.shell.component.support; + +public interface Itemable { + + T getItem(); +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/support/Matchable.java b/spring-shell-core/src/main/java/org/springframework/shell/component/support/Matchable.java new file mode 100644 index 000000000..6c688cef6 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/support/Matchable.java @@ -0,0 +1,21 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.shell.component.support; + +public interface Matchable { + + boolean matches(String match); +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/support/Nameable.java b/spring-shell-core/src/main/java/org/springframework/shell/component/support/Nameable.java new file mode 100644 index 000000000..39f1f8cf2 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/support/Nameable.java @@ -0,0 +1,21 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.shell.component.support; + +public interface Nameable { + + String getName(); +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/component/support/SelectorItem.java b/spring-shell-core/src/main/java/org/springframework/shell/component/support/SelectorItem.java new file mode 100644 index 000000000..0ef0df5b1 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/component/support/SelectorItem.java @@ -0,0 +1,67 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.shell.component.support; + +import org.springframework.util.StringUtils; + +public interface SelectorItem extends Nameable, Matchable, Enableable, Itemable { + + static SelectorItem of(String name, T item) { + return of(name, item, true); + } + + static SelectorItem of(String name, T item, boolean enabled) { + return new SelectorItemWrapper(name, item, enabled); + } + + public static class SelectorItemWrapper implements SelectorItem { + private String name; + private boolean enabled; + private T item; + + public SelectorItemWrapper(String name, T item) { + this(name, item, true); + } + + public SelectorItemWrapper(String name, T item, boolean enabled) { + this.name = name; + this.item = item; + this.enabled = enabled; + } + + @Override + public String getName() { + return name; + } + + @Override + public boolean matches(String match) { + if (!StringUtils.hasText(match)) { + return true; + }; + return name.toLowerCase().contains(match.toLowerCase()); + } + + @Override + public boolean isEnabled() { + return enabled; + } + + public T getItem() { + return item; + } + } +} diff --git a/spring-shell-core/src/main/resources/org/springframework/shell/component/multi-item-selector-default.stg b/spring-shell-core/src/main/resources/org/springframework/shell/component/multi-item-selector-default.stg new file mode 100644 index 000000000..5c2986595 --- /dev/null +++ b/spring-shell-core/src/main/resources/org/springframework/shell/component/multi-item-selector-default.stg @@ -0,0 +1,64 @@ +// used to select style if item is selected/unselected +selected_style(flag) ::= <% +item-selecteditem-unselected +%> + +// selector rows +select_item(item) ::= <% + + <("> "); format="item-selector"> + + <(" ")> + + + + + <("[x]"); format=selected_style(item.selected)> + + <("[ ]"); format=selected_style(item.selected)> + + + <("[ ]"); format="item-disabled"> + +%> + +// start '? xxx' shows both running and result +question_name(model) ::= << +<("?"); format="list-value"> +>> + +// within info section, dedicated instructions for user +info_filter(model) ::= <% + +, filtering '' + +, type to filter + +%> + +// info section after '? xxx' +info(model) ::= << +[Use arrows to move] +>> + +// get comma delited string +comma_delimited(values) ::= <% + +%> + +// component result +result(model) ::= << + <(comma_delimited(model.values)); format="value"> +>> + +// component is running +running(model) ::= << + +}; separator="\n"> +>> + +// main - hardcoded name +// model - model built by MultiItemSelectorContext +main(model) ::= << + +>> diff --git a/spring-shell-core/src/main/resources/org/springframework/shell/component/path-input-default.stg b/spring-shell-core/src/main/resources/org/springframework/shell/component/path-input-default.stg new file mode 100644 index 000000000..a71e9a98f --- /dev/null +++ b/spring-shell-core/src/main/resources/org/springframework/shell/component/path-input-default.stg @@ -0,0 +1,38 @@ +// message +message(model) ::= <% + +<(">>>"); format="level-error"> + +<(">>"); format="level-warn"> + +<(">"); format="level-info"> + +%> + +// info section after '? xxx' +info(model) ::= <% + + + +%> + +// start '? xxx' shows both running and result +question_name(model) ::= << +<("?"); format="list-value"> +>> + +// component result +result(model) ::= << + +>> + +// component is running +running(model) ::= << + + +>> + +// main +main(model) ::= << + +>> diff --git a/spring-shell-core/src/main/resources/org/springframework/shell/component/single-item-selector-default.stg b/spring-shell-core/src/main/resources/org/springframework/shell/component/single-item-selector-default.stg new file mode 100644 index 000000000..b5bcf00c2 --- /dev/null +++ b/spring-shell-core/src/main/resources/org/springframework/shell/component/single-item-selector-default.stg @@ -0,0 +1,44 @@ +// selector rows +select_item(item) ::= <% + +<("> "); format="item-selector"> + +<(" ")> + +%> + +// start '? xxx' shows both running and result +question_name(model) ::= << +<("?"); format="list-value"> +>> + +// within info section, dedicated instructions for user +info_filter(model) ::= <% + +, filtering '' + +, type to filter + +%> + +// info section after '? xxx' +info(model) ::= << +[Use arrows to move] +>> + +// component result +result(model) ::= << + +>> + +// component is running +running(model) ::= << + +}; separator="\n"> +>> + +// main - hardcoded name +// model - model built by SingleItemSelectorContext +main(model) ::= << + +>> diff --git a/spring-shell-core/src/main/resources/org/springframework/shell/component/string-input-default.stg b/spring-shell-core/src/main/resources/org/springframework/shell/component/string-input-default.stg new file mode 100644 index 000000000..be71d74c9 --- /dev/null +++ b/spring-shell-core/src/main/resources/org/springframework/shell/component/string-input-default.stg @@ -0,0 +1,28 @@ +// info section after '? xxx' +info(model) ::= <% + + + +<("[Default "); format="value"><("]"); format="value"> + +%> + +// start '? xxx' shows both running and result +question_name(model) ::= << +<("?"); format="list-value"> +>> + +// component result +result(model) ::= << + +>> + +// component is running +running(model) ::= << + +>> + +// main +main(model) ::= << + +>> diff --git a/spring-shell-core/src/test/java/org/springframework/shell/component/AbstractShellTests.java b/spring-shell-core/src/test/java/org/springframework/shell/component/AbstractShellTests.java new file mode 100644 index 000000000..5e7285df4 --- /dev/null +++ b/spring-shell-core/src/test/java/org/springframework/shell/component/AbstractShellTests.java @@ -0,0 +1,196 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.shell.component; + +import java.io.ByteArrayOutputStream; +import java.io.PipedInputStream; +import java.io.PipedOutputStream; +import java.io.UnsupportedEncodingException; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.LinkedBlockingQueue; + +import org.jline.keymap.KeyMap; +import org.jline.terminal.Terminal; +import org.jline.terminal.impl.DumbTerminal; +import org.jline.utils.AttributedString; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; + +import org.springframework.core.io.DefaultResourceLoader; +import org.springframework.core.io.ResourceLoader; +import org.springframework.shell.style.TemplateExecutor; +import org.springframework.shell.style.Theme; +import org.springframework.shell.style.ThemeRegistry; +import org.springframework.shell.style.ThemeResolver; +import org.springframework.shell.style.ThemeSettings; + +import static org.jline.keymap.KeyMap.del; + +public abstract class AbstractShellTests { + + private ExecutorService executorService; + private PipedInputStream pipedInputStream; + private PipedOutputStream pipedOutputStream; + private LinkedBlockingQueue bytesQueue; + private ByteArrayOutputStream consoleOut; + private Terminal terminal; + private TemplateExecutor templateExecutor; + private ResourceLoader resourceLoader; + + @BeforeEach + public void setup() throws Exception { + executorService = Executors.newFixedThreadPool(1); + pipedInputStream = new PipedInputStream(); + pipedOutputStream = new PipedOutputStream(); + bytesQueue = new LinkedBlockingQueue<>(); + consoleOut = new ByteArrayOutputStream(); + + ThemeRegistry themeRegistry = new ThemeRegistry(); + themeRegistry.register(new Theme() { + @Override + public String getName() { + return "default"; + } + + @Override + public ThemeSettings getSettings() { + return ThemeSettings.themeSettings(); + } + }); + ThemeResolver themeResolver = new ThemeResolver(themeRegistry, "default"); + templateExecutor = new TemplateExecutor(themeResolver); + + resourceLoader = new DefaultResourceLoader(); + + pipedInputStream.connect(pipedOutputStream); + terminal = new DumbTerminal("terminal", "ansi", pipedInputStream, consoleOut, StandardCharsets.UTF_8); + + executorService.execute(() -> { + try { + while (true) { + byte[] take = bytesQueue.take(); + pipedOutputStream.write(take); + pipedOutputStream.flush(); + } + } catch (Exception e) { + } + }); + } + + @AfterEach + public void cleanup() { + executorService.shutdown(); + } + + protected void write(byte[] bytes) { + bytesQueue.add(bytes); + } + + protected String consoleOut() { + return AttributedString.fromAnsi(consoleOut.toString()).toString(); + } + + protected Terminal getTerminal() { + return terminal; + } + + protected ResourceLoader getResourceLoader() { + return resourceLoader; + } + + protected TemplateExecutor getTemplateExecutor() { + return templateExecutor; + } + + protected class TestBuffer { + private final ByteArrayOutputStream out = new ByteArrayOutputStream(); + + public TestBuffer() { + } + + public TestBuffer(String str) { + append(str); + } + + public TestBuffer(char[] chars) { + append(new String(chars)); + } + + @Override + public String toString() { + try { + return out.toString(StandardCharsets.UTF_8.name()); + } catch (UnsupportedEncodingException e) { + throw new RuntimeException(e); + } + } + + public TestBuffer cr() { + return append("\r"); + } + + public TestBuffer backspace() { + return append(del()); + } + + public TestBuffer backspace(int count) { + TestBuffer buf = this; + for (int i = 0; i < count; i++) { + buf = backspace(); + } + return buf; + } + + public TestBuffer down() { + return append("\033[B"); + } + + public TestBuffer ctrl(char let) { + return append(KeyMap.ctrl(let)); + } + + public TestBuffer ctrlE() { + return ctrl('E'); + } + + public TestBuffer ctrlY() { + return ctrl('Y'); + } + + public TestBuffer space() { + return append(" "); + } + + public byte[] getBytes() { + return out.toByteArray(); + } + + public TestBuffer append(final String str) { + for (byte b : str.getBytes(StandardCharsets.UTF_8)) { + append(b); + } + return this; + } + + public TestBuffer append(final int i) { + // consoleOut.reset(); + out.write((byte) i); + return this; + } + } +} diff --git a/spring-shell-core/src/test/java/org/springframework/shell/component/MultiItemSelectorTests.java b/spring-shell-core/src/test/java/org/springframework/shell/component/MultiItemSelectorTests.java new file mode 100644 index 000000000..ce6e76fef --- /dev/null +++ b/spring-shell-core/src/test/java/org/springframework/shell/component/MultiItemSelectorTests.java @@ -0,0 +1,240 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.shell.component; + +import java.time.Duration; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Stream; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.core.io.DefaultResourceLoader; +import org.springframework.shell.component.context.ComponentContext; +import org.springframework.shell.component.support.SelectorItem; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +import static org.springframework.shell.component.ShellAssertions.assertStringOrderThat; + +public class MultiItemSelectorTests extends AbstractShellTests { + + private static SimplePojo SIMPLE_POJO_1 = SimplePojo.of("data1"); + private static SimplePojo SIMPLE_POJO_2 = SimplePojo.of("data2"); + private static SimplePojo SIMPLE_POJO_3 = SimplePojo.of("data3"); + private static SimplePojo SIMPLE_POJO_4 = SimplePojo.of("data4"); + private static SimplePojo SIMPLE_POJO_5 = SimplePojo.of("data5"); + private static SimplePojo SIMPLE_POJO_6 = SimplePojo.of("data6"); + private static SimplePojo SIMPLE_POJO_7 = SimplePojo.of("data7"); + private static SelectorItem SELECTOR_ITEM_1 = SelectorItem.of("simplePojo1", SIMPLE_POJO_1); + private static SelectorItem SELECTOR_ITEM_2 = SelectorItem.of("simplePojo2", SIMPLE_POJO_2); + private static SelectorItem SELECTOR_ITEM_3 = SelectorItem.of("simplePojo3", SIMPLE_POJO_3); + private static SelectorItem SELECTOR_ITEM_4 = SelectorItem.of("simplePojo4", SIMPLE_POJO_4); + private static SelectorItem SELECTOR_ITEM_5 = SelectorItem.of("simplePojo5", SIMPLE_POJO_5); + private static SelectorItem SELECTOR_ITEM_6 = SelectorItem.of("simplePojo6", SIMPLE_POJO_6); + private static SelectorItem SELECTOR_ITEM_7 = SelectorItem.of("simplePojo7", SIMPLE_POJO_7, false); + + private ExecutorService service; + private CountDownLatch latch; + private AtomicReference>> result; + + @BeforeEach + public void setupMulti() { + service = Executors.newFixedThreadPool(1); + latch = new CountDownLatch(1); + result = new AtomicReference<>(); + } + + @AfterEach + public void cleanupMulti() { + latch = null; + result = null; + if (service != null) { + service.shutdown(); + } + service = null; + } + + @Test + public void testItemsShown() { + scheduleSelect(); + await().atMost(Duration.ofSeconds(4)) + .untilAsserted(() -> assertStringOrderThat(consoleOut()).containsInOrder("simplePojo1", "simplePojo2", + "simplePojo3", "simplePojo4")); + } + + @Test + public void testMaxItems() { + scheduleSelect(Arrays.asList(SELECTOR_ITEM_1, SELECTOR_ITEM_2, SELECTOR_ITEM_3, SELECTOR_ITEM_4, + SELECTOR_ITEM_5, SELECTOR_ITEM_6), 6); + await().atMost(Duration.ofSeconds(4)) + .untilAsserted(() -> assertStringOrderThat(consoleOut()).containsInOrder("simplePojo1", "simplePojo2", + "simplePojo3", "simplePojo4", "simplePojo5", "simplePojo6")); + } + + @Test + public void testItemsShownWithDisabled() { + scheduleSelect(Arrays.asList(SELECTOR_ITEM_1, SELECTOR_ITEM_7)); + await().atMost(Duration.ofSeconds(4)) + .untilAsserted(() -> assertStringOrderThat(consoleOut()).containsInOrder("[ ] simplePojo1", "[ ] simplePojo7")); + } + + @Test + public void testDisableIsNotSelectable() throws InterruptedException { + scheduleSelect(Arrays.asList(SELECTOR_ITEM_1, SELECTOR_ITEM_7)); + TestBuffer testBuffer = new TestBuffer().space().ctrlE().space().cr(); + write(testBuffer.getBytes()); + + awaitLatch(); + + List> selected = result.get(); + assertThat(selected).hasSize(1); + Stream datas = selected.stream().map(SelectorItem::getItem).map(SimplePojo::getData); + assertThat(datas).containsExactlyInAnyOrder("data1"); + } + + @Test + public void testNoneSelected() throws InterruptedException { + scheduleSelect(); + + TestBuffer testBuffer = new TestBuffer().cr(); + write(testBuffer.getBytes()); + + awaitLatch(); + + List> selected = result.get(); + assertThat(selected).hasSize(0); + } + + @Test + public void testSelectFirst() throws InterruptedException { + scheduleSelect(); + + TestBuffer testBuffer = new TestBuffer().space().cr(); + write(testBuffer.getBytes()); + + awaitLatch(); + + List> selected = result.get(); + assertThat(selected).hasSize(1); + Stream datas = selected.stream().map(SelectorItem::getItem).map(SimplePojo::getData); + assertThat(datas).containsExactlyInAnyOrder("data1"); + assertThat(consoleOut()).contains("testSimple data1"); + } + + @Test + public void testSelectSecond() throws InterruptedException { + scheduleSelect(); + + TestBuffer testBuffer = new TestBuffer().ctrlE().space().cr(); + write(testBuffer.getBytes()); + + awaitLatch(); + + List> selected = result.get(); + assertThat(selected).hasSize(1); + Stream datas = selected.stream().map(SelectorItem::getItem).map(SimplePojo::getData); + assertThat(datas).containsExactlyInAnyOrder("data2"); + } + + @Test + public void testSelectSecondAndFourth() throws InterruptedException { + scheduleSelect(); + + TestBuffer testBuffer = new TestBuffer().ctrlE().space().ctrlE().ctrlE().space().cr(); + write(testBuffer.getBytes()); + + awaitLatch(); + + List> selected = result.get(); + assertThat(selected).hasSize(2); + Stream datas = selected.stream().map(SelectorItem::getItem).map(SimplePojo::getData); + assertThat(datas).containsExactlyInAnyOrder("data2", "data4"); + } + + @Test + public void testSelectLastBackwards() throws InterruptedException { + scheduleSelect(); + + TestBuffer testBuffer = new TestBuffer().ctrlY().space().cr(); + write(testBuffer.getBytes()); + + awaitLatch(); + + List> selected = result.get(); + assertThat(selected).hasSize(1); + Stream datas = selected.stream().map(SelectorItem::getItem).map(SimplePojo::getData); + assertThat(datas).containsExactlyInAnyOrder("data4"); + } + + private void scheduleSelect() { + scheduleSelect(Arrays.asList(SELECTOR_ITEM_1, SELECTOR_ITEM_2, SELECTOR_ITEM_3, + SELECTOR_ITEM_4)); + } + + private void scheduleSelect(List> items) { + scheduleSelect(items, null); + } + + private void scheduleSelect(List> items, Integer maxItems) { + MultiItemSelector> selector = new MultiItemSelector<>(getTerminal(), + items, "testSimple", null); + selector.setResourceLoader(new DefaultResourceLoader()); + selector.setTemplateExecutor(getTemplateExecutor()); + + selector.setPrintResults(true); + if (maxItems != null) { + selector.setMaxItems(maxItems); + } + service.execute(() -> { + ComponentContext context = ComponentContext.empty(); + result.set(selector.run(context).getResultItems()); + latch.countDown(); + }); + } + + private void awaitLatch() throws InterruptedException { + latch.await(4, TimeUnit.SECONDS); + } + + private static class SimplePojo { + String data; + + SimplePojo(String data) { + this.data = data; + } + + public String getData() { + return data; + } + + static SimplePojo of(String data) { + return new SimplePojo(data); + } + + @Override + public String toString() { + return data; + } + } +} diff --git a/spring-shell-core/src/test/java/org/springframework/shell/component/PathInputTests.java b/spring-shell-core/src/test/java/org/springframework/shell/component/PathInputTests.java new file mode 100644 index 000000000..2d819c2cc --- /dev/null +++ b/spring-shell-core/src/test/java/org/springframework/shell/component/PathInputTests.java @@ -0,0 +1,98 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.shell.component; + +import java.io.IOException; +import java.nio.file.FileSystem; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; + +import com.google.common.jimfs.Jimfs; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.core.io.DefaultResourceLoader; +import org.springframework.shell.component.PathInput.PathInputContext; +import org.springframework.shell.component.context.ComponentContext; + +import static org.assertj.core.api.Assertions.assertThat; + +public class PathInputTests extends AbstractShellTests { + + private ExecutorService service; + private CountDownLatch latch1; + private AtomicReference result1; + private FileSystem fileSystem; + private Function pathProvider; + + @BeforeEach + public void setupTests() { + service = Executors.newFixedThreadPool(1); + latch1 = new CountDownLatch(1); + result1 = new AtomicReference<>(); + fileSystem = Jimfs.newFileSystem(); + pathProvider = (path) -> fileSystem.getPath(path); + } + + @AfterEach + public void cleanupTests() throws IOException { + latch1 = null; + result1 = null; + if (service != null) { + service.shutdown(); + } + service = null; + if (fileSystem != null) { + fileSystem.close(); + } + fileSystem = null; + pathProvider = null; + } + + @Test + public void testResultUserInput() throws InterruptedException, IOException { + Path path = fileSystem.getPath("tmp"); + Files.createDirectories(path); + ComponentContext empty = ComponentContext.empty(); + PathInput component1 = new PathInput(getTerminal(), "component1"); + component1.setPathProvider(pathProvider); + component1.setResourceLoader(new DefaultResourceLoader()); + component1.setTemplateExecutor(getTemplateExecutor()); + + service.execute(() -> { + PathInputContext run1Context = component1.run(empty); + result1.set(run1Context); + latch1.countDown(); + }); + + TestBuffer testBuffer = new TestBuffer().append("tmp").cr(); + write(testBuffer.getBytes()); + + latch1.await(2, TimeUnit.SECONDS); + PathInputContext run1Context = result1.get(); + + assertThat(run1Context).isNotNull(); + assertThat(run1Context.getResultValue()).isNotNull(); + assertThat(run1Context.getResultValue().toString()).contains("tmp"); + } +} diff --git a/spring-shell-core/src/test/java/org/springframework/shell/component/ShellAssertions.java b/spring-shell-core/src/test/java/org/springframework/shell/component/ShellAssertions.java new file mode 100644 index 000000000..b6f8c9b50 --- /dev/null +++ b/spring-shell-core/src/test/java/org/springframework/shell/component/ShellAssertions.java @@ -0,0 +1,70 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.shell.component; + +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import java.util.stream.Stream; + +import org.assertj.core.api.AbstractAssert; + +/** + * Custom assertj assertions. + * + * @author Janne Valkealahti + */ +public class ShellAssertions { + + public static StringOrderAssert assertStringOrderThat(String actual) { + return new StringOrderAssert(actual); + } + + public static class StringOrderAssert extends AbstractAssert { + + protected StringOrderAssert(String out) { + super(out, StringOrderAssert.class); + } + + public StringOrderAssert containsInOrder(String... expected) { + isNotNull(); + int[] indexes = new int[expected.length]; + for (int i = 0; i < expected.length; i++) { + indexes[i] = actual.indexOf(expected[i]); + } + for (int i = 0; i < expected.length; i++) { + if (indexes[i] < 0) { + failWithMessage("Item [%s] not found from output [%s]", expected[i], actual); + } + } + if (!isSorted(indexes)) { + String expectedStr = Stream.of(expected).collect(Collectors.joining(",")); + String indexStr = IntStream.of(indexes).mapToObj(i -> ((Integer) i).toString()) + .collect(Collectors.joining(",")); + failWithMessage("Items [%s] are in wrong order, indexes are [%s], output is [%s]", expectedStr, + indexStr, actual); + } + return this; + } + + private boolean isSorted(int[] array) { + for (int i = 0; i < array.length - 1; i++) { + if (array[i] > array[i + 1]) + return false; + } + return true; + } + } +} diff --git a/spring-shell-core/src/test/java/org/springframework/shell/component/SingleItemSelectorTests.java b/spring-shell-core/src/test/java/org/springframework/shell/component/SingleItemSelectorTests.java new file mode 100644 index 000000000..c5077c43c --- /dev/null +++ b/spring-shell-core/src/test/java/org/springframework/shell/component/SingleItemSelectorTests.java @@ -0,0 +1,213 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.shell.component; + +import java.time.Duration; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.core.io.DefaultResourceLoader; +import org.springframework.shell.component.context.ComponentContext; +import org.springframework.shell.component.support.SelectorItem; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +import static org.springframework.shell.component.ShellAssertions.assertStringOrderThat; + +public class SingleItemSelectorTests extends AbstractShellTests { + + private static SimplePojo SIMPLE_POJO_1 = SimplePojo.of("data1"); + private static SimplePojo SIMPLE_POJO_2 = SimplePojo.of("data2"); + private static SimplePojo SIMPLE_POJO_3 = SimplePojo.of("data3"); + private static SimplePojo SIMPLE_POJO_4 = SimplePojo.of("data4"); + private static SimplePojo SIMPLE_POJO_5 = SimplePojo.of("data5"); + private static SimplePojo SIMPLE_POJO_6 = SimplePojo.of("data6"); + private static SelectorItem SELECTOR_ITEM_1 = SelectorItem.of("simplePojo1", SIMPLE_POJO_1); + private static SelectorItem SELECTOR_ITEM_2 = SelectorItem.of("simplePojo2", SIMPLE_POJO_2); + private static SelectorItem SELECTOR_ITEM_3 = SelectorItem.of("simplePojo3", SIMPLE_POJO_3); + private static SelectorItem SELECTOR_ITEM_4 = SelectorItem.of("simplePojo4", SIMPLE_POJO_4); + private static SelectorItem SELECTOR_ITEM_5 = SelectorItem.of("simplePojo5", SIMPLE_POJO_5); + private static SelectorItem SELECTOR_ITEM_6 = SelectorItem.of("simplePojo6", SIMPLE_POJO_6); + + private ExecutorService service; + private CountDownLatch latch; + private AtomicReference>> result; + + @BeforeEach + public void setupMulti() { + service = Executors.newFixedThreadPool(1); + latch = new CountDownLatch(1); + result = new AtomicReference<>(); + } + + @AfterEach + public void cleanupMulti() { + latch = null; + result = null; + if (service != null) { + service.shutdown(); + } + service = null; + } + + @Test + public void testItemsShownFirstHovered() { + scheduleSelect(); + await().atMost(Duration.ofSeconds(4)) + .untilAsserted(() -> { + assertStringOrderThat(consoleOut()).containsInOrder("> simplePojo1", "simplePojo2", "simplePojo3", "simplePojo4"); + }); + + } + + @Test + public void testMaxItems() { + scheduleSelect(Arrays.asList(SELECTOR_ITEM_1, SELECTOR_ITEM_2, SELECTOR_ITEM_3, SELECTOR_ITEM_4, + SELECTOR_ITEM_5, SELECTOR_ITEM_6), 6); + await().atMost(Duration.ofSeconds(4)) + .untilAsserted(() -> assertStringOrderThat(consoleOut()).containsInOrder("simplePojo1", "simplePojo2", + "simplePojo3", "simplePojo4", "simplePojo5", "simplePojo6")); + } + + @Test + public void testSelectFirst() throws InterruptedException { + scheduleSelect(); + + TestBuffer testBuffer = new TestBuffer().cr(); + write(testBuffer.getBytes()); + + awaitLatch(); + + Optional> selected = result.get(); + assertThat(selected).isNotEmpty(); + Optional datas = selected.map(SelectorItem::getItem).map(SimplePojo::getData); + assertThat(datas).contains("data1"); + assertThat(consoleOut()).contains("testSimple data1"); + } + + @Test + public void testSelectSecond() throws InterruptedException { + scheduleSelect(); + + TestBuffer testBuffer = new TestBuffer().ctrlE().cr(); + write(testBuffer.getBytes()); + + awaitLatch(); + + Optional> selected = result.get(); + assertThat(selected).isNotEmpty(); + Optional datas = selected.map(SelectorItem::getItem).map(SimplePojo::getData); + assertThat(datas).contains("data2"); + } + + @Test + public void testSelectLastBackwards() throws InterruptedException { + scheduleSelect(); + + TestBuffer testBuffer = new TestBuffer().ctrlY().cr(); + write(testBuffer.getBytes()); + + awaitLatch(); + + Optional> selected = result.get(); + assertThat(selected).isNotEmpty(); + Optional datas = selected.map(SelectorItem::getItem).map(SimplePojo::getData); + assertThat(datas).contains("data4"); + } + + @Test + public void testFilterShowsNoneThenSelect() throws InterruptedException { + scheduleSelect(); + + TestBuffer testBuffer = new TestBuffer().append("xxx").cr(); + write(testBuffer.getBytes()); + + assertThat(awaitLatch(1)).isFalse(); + + testBuffer = new TestBuffer().backspace(3).cr(); + write(testBuffer.getBytes()); + + assertThat(awaitLatch()).isTrue(); + + Optional> selected = result.get(); + assertThat(selected).isNotEmpty(); + } + + private void scheduleSelect() { + scheduleSelect(Arrays.asList(SELECTOR_ITEM_1, SELECTOR_ITEM_2, SELECTOR_ITEM_3, + SELECTOR_ITEM_4)); + } + + private void scheduleSelect(List> items) { + scheduleSelect(items, null); + } + + private void scheduleSelect(List> items, Integer maxItems) { + SingleItemSelector> selector = new SingleItemSelector<>(getTerminal(), + items, "testSimple", null); + selector.setResourceLoader(new DefaultResourceLoader()); + selector.setTemplateExecutor(getTemplateExecutor()); + + selector.setPrintResults(true); + if (maxItems != null) { + selector.setMaxItems(maxItems); + } + service.execute(() -> { + ComponentContext context = ComponentContext.empty(); + result.set(selector.run(context).getResultItem()); + latch.countDown(); + }); + } + + private boolean awaitLatch() throws InterruptedException { + return awaitLatch(4); + } + + private boolean awaitLatch(int seconds) throws InterruptedException { + return latch.await(seconds, TimeUnit.SECONDS); + } + + private static class SimplePojo { + String data; + + SimplePojo(String data) { + this.data = data; + } + + public String getData() { + return data; + } + + static SimplePojo of(String data) { + return new SimplePojo(data); + } + + @Override + public String toString() { + return data; + } + } +} diff --git a/spring-shell-core/src/test/java/org/springframework/shell/component/StringInputTests.java b/spring-shell-core/src/test/java/org/springframework/shell/component/StringInputTests.java new file mode 100644 index 000000000..0cffc0a80 --- /dev/null +++ b/spring-shell-core/src/test/java/org/springframework/shell/component/StringInputTests.java @@ -0,0 +1,164 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.shell.component; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.core.io.DefaultResourceLoader; +import org.springframework.shell.component.StringInput.StringInputContext; +import org.springframework.shell.component.context.ComponentContext; + +import static org.assertj.core.api.Assertions.assertThat; + +public class StringInputTests extends AbstractShellTests { + + private ExecutorService service; + private CountDownLatch latch1; + private CountDownLatch latch2; + private AtomicReference result1; + private AtomicReference result2; + + @BeforeEach + public void setupTests() { + service = Executors.newFixedThreadPool(1); + latch1 = new CountDownLatch(1); + latch2 = new CountDownLatch(1); + result1 = new AtomicReference<>(); + result2 = new AtomicReference<>(); + } + + @AfterEach + public void cleanupTests() { + latch1 = null; + latch2 = null; + result1 = null; + result2 = null; + if (service != null) { + service.shutdown(); + } + service = null; + } + + @Test + public void testResultBasic() throws InterruptedException { + ComponentContext empty = ComponentContext.empty(); + StringInput component1 = new StringInput(getTerminal(), "component1", "component1ResultValue"); + component1.setPrintResults(true); + component1.setResourceLoader(new DefaultResourceLoader()); + component1.setTemplateExecutor(getTemplateExecutor()); + + service.execute(() -> { + StringInputContext run1Context = component1.run(empty); + result1.set(run1Context); + latch1.countDown(); + }); + + TestBuffer testBuffer = new TestBuffer().cr(); + write(testBuffer.getBytes()); + + latch1.await(2, TimeUnit.SECONDS); + StringInputContext run1Context = result1.get(); + + assertThat(run1Context).isNotNull(); + assertThat(run1Context.getResultValue()).isEqualTo("component1ResultValue"); + assertThat(consoleOut()).contains("component1 component1ResultValue"); + } + + @Test + public void testResultUserInput() throws InterruptedException { + ComponentContext empty = ComponentContext.empty(); + StringInput component1 = new StringInput(getTerminal(), "component1", "component1ResultValue"); + component1.setResourceLoader(new DefaultResourceLoader()); + component1.setTemplateExecutor(getTemplateExecutor()); + + service.execute(() -> { + StringInputContext run1Context = component1.run(empty); + result1.set(run1Context); + latch1.countDown(); + }); + + TestBuffer testBuffer = new TestBuffer().append("test").cr(); + write(testBuffer.getBytes()); + + latch1.await(2, TimeUnit.SECONDS); + StringInputContext run1Context = result1.get(); + + assertThat(run1Context).isNotNull(); + assertThat(run1Context.getResultValue()).isEqualTo("test"); + } + + @Test + public void testPassingViaContext() throws InterruptedException { + ComponentContext empty = ComponentContext.empty(); + StringInput component1 = new StringInput(getTerminal(), "component1", "component1ResultValue"); + StringInput component2 = new StringInput(getTerminal(), "component2", "component2ResultValue"); + component1.setResourceLoader(new DefaultResourceLoader()); + component1.setTemplateExecutor(getTemplateExecutor()); + component2.setResourceLoader(new DefaultResourceLoader()); + component2.setTemplateExecutor(getTemplateExecutor()); + + component1.addPostRunHandler(context -> { + context.put("component1ResultValue", context.getResultValue()); + }); + + component2.addPreRunHandler(context -> { + String component1ResultValue = context.get("component1ResultValue"); + context.setDefaultValue(component1ResultValue); + }); + component2.addPostRunHandler(context -> { + }); + + service.execute(() -> { + StringInputContext run1Context = component1.run(empty); + result1.set(run1Context); + latch1.countDown(); + }); + + TestBuffer testBuffer = new TestBuffer().cr(); + write(testBuffer.getBytes()); + + latch1.await(2, TimeUnit.SECONDS); + + service.execute(() -> { + StringInputContext run1Context = result1.get(); + StringInputContext run2Context = component2.run(run1Context); + result2.set(run2Context); + latch2.countDown(); + }); + + write(testBuffer.getBytes()); + + latch2.await(2, TimeUnit.SECONDS); + + StringInputContext run1Context = result1.get(); + StringInputContext run2Context = result2.get(); + + assertThat(run1Context).isNotSameAs(run2Context); + + assertThat(run1Context).isNotNull(); + assertThat(run2Context).isNotNull(); + assertThat(run1Context.getResultValue()).isEqualTo("component1ResultValue"); + assertThat(run2Context.getResultValue()).isEqualTo("component1ResultValue"); + } +} diff --git a/spring-shell-core/src/test/java/org/springframework/shell/component/context/ComponentContextTests.java b/spring-shell-core/src/test/java/org/springframework/shell/component/context/ComponentContextTests.java new file mode 100644 index 000000000..95d13596e --- /dev/null +++ b/spring-shell-core/src/test/java/org/springframework/shell/component/context/ComponentContextTests.java @@ -0,0 +1,48 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.shell.component.context; + +import java.util.AbstractMap; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class ComponentContextTests { + + @Test + @SuppressWarnings("unused") + public void testBasics() { + ComponentContext context = ComponentContext.empty(); + assertThat(context.stream()).isEmpty(); + + context.put("foo", "bar"); + + assertThat(context.stream()).containsExactlyInAnyOrder(new AbstractMap.SimpleEntry<>("foo", "bar")); + + String foo = context.get("foo"); + assertThat(foo).isEqualTo("bar"); + assertThatThrownBy(() -> { + Integer unused = context.get("foo"); + }).isInstanceOf(ClassCastException.class); + + assertThat(context.get("foo", String.class)).isEqualTo("bar"); + assertThatThrownBy(() -> { + context.get("foo", Integer.class); + }).isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/spring-shell-samples/src/main/java/org/springframework/shell/samples/standard/ComponentCommands.java b/spring-shell-samples/src/main/java/org/springframework/shell/samples/standard/ComponentCommands.java new file mode 100644 index 000000000..a70a8b946 --- /dev/null +++ b/spring-shell-samples/src/main/java/org/springframework/shell/samples/standard/ComponentCommands.java @@ -0,0 +1,103 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.shell.samples.standard; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ResourceLoaderAware; +import org.springframework.core.io.ResourceLoader; +import org.springframework.shell.component.MultiItemSelector; +import org.springframework.shell.component.PathInput; +import org.springframework.shell.component.SingleItemSelector; +import org.springframework.shell.component.StringInput; +import org.springframework.shell.component.MultiItemSelector.MultiItemSelectorContext; +import org.springframework.shell.component.PathInput.PathInputContext; +import org.springframework.shell.component.SingleItemSelector.SingleItemSelectorContext; +import org.springframework.shell.component.StringInput.StringInputContext; +import org.springframework.shell.component.support.SelectorItem; +import org.springframework.shell.standard.AbstractShellComponent; +import org.springframework.shell.standard.ShellComponent; +import org.springframework.shell.standard.ShellMethod; +import org.springframework.shell.style.TemplateExecutor; + +@ShellComponent +public class ComponentCommands extends AbstractShellComponent implements ResourceLoaderAware { + + private ResourceLoader resourceLoader; + + @Autowired + private TemplateExecutor templateExecutor; + + @Override + public void setResourceLoader(ResourceLoader resourceLoader) { + this.resourceLoader = resourceLoader; + } + + @ShellMethod(key = "component string", value = "String input", group = "Components") + public String stringInput() { + StringInput component = new StringInput(getTerminal(), "Enter value", "myvalue"); + component.setResourceLoader(resourceLoader); + component.setTemplateExecutor(templateExecutor); + StringInputContext context = component.run(StringInputContext.empty()); + return "Got value " + context.getResultValue(); + } + + @ShellMethod(key = "component path", value = "Path input", group = "Components") + public String pathInput() { + PathInput component = new PathInput(getTerminal(), "Enter value"); + component.setResourceLoader(resourceLoader); + component.setTemplateExecutor(templateExecutor); + PathInputContext context = component.run(PathInputContext.empty()); + return "Got value " + context.getResultValue(); + } + + @ShellMethod(key = "component single", value = "Single selector", group = "Components") + public String singleSelector() { + List> items = new ArrayList<>(); + items.add(SelectorItem.of("key1", "value1")); + items.add(SelectorItem.of("key2", "value2")); + SingleItemSelector> component = new SingleItemSelector<>(getTerminal(), + items, "testSimple", null); + component.setResourceLoader(resourceLoader); + component.setTemplateExecutor(templateExecutor); + SingleItemSelectorContext> context = component + .run(SingleItemSelectorContext.empty()); + String result = context.getResultItem().flatMap(si -> Optional.ofNullable(si.getItem())).get(); + return "Got value " + result; + } + + @ShellMethod(key = "component multi", value = "Multi selector", group = "Components") + public String multiSelector() { + List> items = new ArrayList<>(); + items.add(SelectorItem.of("key1", "value1")); + items.add(SelectorItem.of("key2", "value2", false)); + items.add(SelectorItem.of("key3", "value3")); + MultiItemSelector> component = new MultiItemSelector<>(getTerminal(), + items, "testSimple", null); + component.setResourceLoader(resourceLoader); + component.setTemplateExecutor(templateExecutor); + MultiItemSelectorContext> context = component + .run(MultiItemSelectorContext.empty()); + String result = context.getResultItems().stream() + .map(si -> si.getItem()) + .collect(Collectors.joining(",")); + return "Got value " + result; + } +}