Basic UI components

- Add first preliminary model of building and working with
  higher level components.
- Components for text input, path input, single selector and
  multi selector.
- Components renderings are based on ANTLR ST templates while
  there is support for building rendering output manually.
- Add these into sample.
- This is a base of additional work what goes to these components
  and concepts around it.
- Relates #360
This commit is contained in:
Janne Valkealahti
2022-02-04 12:44:23 +00:00
parent 65ff3820da
commit e3604a5bc5
28 changed files with 3432 additions and 1 deletions

View File

@@ -25,6 +25,7 @@
<jline.version>3.21.0</jline.version>
<jcommander.version>1.81</jcommander.version>
<antlr-st4.version>4.3.1</antlr-st4.version>
<jimfs.version>1.2</jimfs.version>
</properties>
<modules>
@@ -110,6 +111,11 @@
<artifactId>ST4</artifactId>
<version>${antlr-st4.version}</version>
</dependency>
<dependency>
<groupId>com.google.jimfs</groupId>
<artifactId>jimfs</artifactId>
<version>${jimfs.version}</version>
</dependency>
</dependencies>
</dependencyManagement>

View File

@@ -52,6 +52,15 @@
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.awaitility</groupId>
<artifactId>awaitility</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.google.jimfs</groupId>
<artifactId>jimfs</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -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<T, I extends Nameable & Matchable & Enableable & Itemable<T>>
extends AbstractSelectorComponent<T, MultiItemSelectorContext<T, I>, I> {
private MultiItemSelectorContext<T, I> currentContext;
public MultiItemSelector(Terminal terminal, List<I> items, String name, Comparator<I> comparator) {
super(terminal, name, items, false, comparator);
setRenderer(new DefaultRenderer());
setTemplateLocation("classpath:org/springframework/shell/component/multi-item-selector-default.stg");
}
@Override
protected MultiItemSelectorContext<T, I> 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<T, I> runInternal(MultiItemSelectorContext<T, I> context) {
super.runInternal(context);
loop(context);
return context;
}
/**
* Context {@link MultiItemSelector}.
*/
public interface MultiItemSelectorContext<T, I extends Nameable & Matchable & Itemable<T>>
extends SelectorComponentContext<T, I, MultiItemSelectorContext<T, I>> {
/**
* Gets a values.
*
* @return a values
*/
List<String> getValues();
/**
* Creates an empty {@link MultiItemSelectorContext}.
*
* @return empty context
*/
static <T, I extends Nameable & Matchable & Itemable<T>> MultiItemSelectorContext<T, I> empty() {
return new DefaultMultiItemSelectorContext<>();
}
/**
* Creates an {@link MultiItemSelectorContext}.
*
* @return context
*/
static <T, I extends Nameable & Matchable & Itemable<T>> MultiItemSelectorContext<T, I> empty(Function<T, String> itemMapper) {
return new DefaultMultiItemSelectorContext<>(itemMapper);
}
}
private static class DefaultMultiItemSelectorContext<T, I extends Nameable & Matchable & Itemable<T>> extends
BaseSelectorComponentContext<T, I, MultiItemSelectorContext<T, I>> implements MultiItemSelectorContext<T, I> {
private Function<T, String> itemMapper = item -> item.toString();
DefaultMultiItemSelectorContext() {
}
DefaultMultiItemSelectorContext(Function<T, String> itemMapper) {
this.itemMapper = itemMapper;
}
@Override
public List<String> 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<String, Object> toTemplateModel() {
Map<String, Object> attributes = super.toTemplateModel();
attributes.put("values", getValues());
List<Map<String, Object>> rows = getItemStateView().stream()
.map(is -> {
Map<String, Object> 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<String, Object> model = new HashMap<>();
model.put("model", attributes);
return model;
}
}
private class DefaultRenderer implements Function<MultiItemSelectorContext<T, I>, List<AttributedString>> {
@Override
public List<AttributedString> apply(MultiItemSelectorContext<T, I> context) {
return renderTemplateResource(context.toTemplateModel());
}
}
}

View File

@@ -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<Path, PathInputContext> {
private PathInputContext currentContext;
private Function<String, Path> 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<PathInputContext, List<AttributedString>> 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<String> 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<String, Path> 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<Path, PathInputContext> {
/**
* Gets an empty {@link PathInputContext}.
*
* @return empty path input context
*/
public static PathInputContext empty() {
return new DefaultPathInputContext();
}
}
private static class DefaultPathInputContext extends BaseTextComponentContext<Path, PathInputContext>
implements PathInputContext {
@Override
public Map<String, Object> toTemplateModel() {
Map<String, Object> attributes = super.toTemplateModel();
Map<String, Object> model = new HashMap<>();
model.put("model", attributes);
return model;
}
}
private class DefaultRenderer implements Function<PathInputContext, List<AttributedString>> {
@Override
public List<AttributedString> apply(PathInputContext context) {
return renderTemplateResource(context.toTemplateModel());
}
}
}

View File

@@ -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<T, I extends Nameable & Matchable & Enableable & Itemable<T>>
extends AbstractSelectorComponent<T, SingleItemSelectorContext<T, I>, I> {
private SingleItemSelectorContext<T, I> currentContext;
public SingleItemSelector(Terminal terminal, List<I> items, String name, Comparator<I> comparator) {
super(terminal, name, items, true, comparator);
setRenderer(new DefaultRenderer());
setTemplateLocation("classpath:org/springframework/shell/component/single-item-selector-default.stg");
}
@Override
protected SingleItemSelectorContext<T, I> 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<T, I> runInternal(SingleItemSelectorContext<T, I> context) {
super.runInternal(context);
loop(context);
return context;
}
/**
* Context {@link SingleItemSelector}.
*/
public interface SingleItemSelectorContext<T, I extends Nameable & Matchable & Itemable<T>>
extends SelectorComponentContext<T, I, SingleItemSelectorContext<T, I>> {
/**
* Gets a result item.
*
* @return a result item
*/
Optional<I> getResultItem();
/**
* Gets a value.
*
* @return a value
*/
Optional<String> getValue();
/**
* Creates an empty {@link SingleItemSelectorContext}.
*
* @return empty context
*/
static <C, I extends Nameable & Matchable & Itemable<C>> SingleItemSelectorContext<C, I> empty() {
return new DefaultSingleItemSelectorContext<>();
}
/**
* Creates a {@link SingleItemSelectorContext}.
*
* @return context
*/
static <C, I extends Nameable & Matchable & Itemable<C>> SingleItemSelectorContext<C, I> empty(Function<C, String> itemMapper) {
return new DefaultSingleItemSelectorContext<>(itemMapper);
}
}
private static class DefaultSingleItemSelectorContext<T, I extends Nameable & Matchable & Itemable<T>> extends
BaseSelectorComponentContext<T, I, SingleItemSelectorContext<T, I>> implements SingleItemSelectorContext<T, I> {
private Function<T, String> itemMapper = item -> item.toString();
DefaultSingleItemSelectorContext() {
}
DefaultSingleItemSelectorContext(Function<T, String> itemMapper) {
this.itemMapper = itemMapper;
}
@Override
public Optional<I> getResultItem() {
if (getResultItems() == null) {
return Optional.empty();
}
return getResultItems().stream().findFirst();
}
@Override
public Optional<String> getValue() {
return getResultItem().map(item -> itemMapper.apply(item.getItem()));
}
@Override
public Map<String, Object> toTemplateModel() {
Map<String, Object> attributes = super.toTemplateModel();
getValue().ifPresent(value -> {
attributes.put("value", value);
});
List<Map<String, Object>> rows = getItemStateView().stream()
.map(is -> {
Map<String, Object> 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<String, Object> model = new HashMap<>();
model.put("model", attributes);
return model;
}
@Override
public String toString() {
return "DefaultSingleItemSelectorContext [super=" + super.toString() + "]";
}
}
private class DefaultRenderer implements Function<SingleItemSelectorContext<T, I>, List<AttributedString>> {
@Override
public List<AttributedString> apply(SingleItemSelectorContext<T, I> context) {
return renderTemplateResource(context.toTemplateModel());
}
}
}

View File

@@ -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<String, StringInputContext> {
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<StringInputContext, List<AttributedString>> 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<String> 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<String, StringInputContext> {
/**
* 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<String, StringInputContext>
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<String, Object> toTemplateModel() {
Map<String, Object> attributes = super.toTemplateModel();
attributes.put("defaultValue", getDefaultValue() != null ? getDefaultValue() : null);
Map<String, Object> model = new HashMap<>();
model.put("model", attributes);
return model;
}
}
private class DefaultRenderer implements Function<StringInputContext, List<AttributedString>> {
@Override
public List<AttributedString> apply(StringInputContext context) {
return renderTemplateResource(context.toTemplateModel());
}
}
}

View File

@@ -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<C extends ComponentContext<C>> extends LinkedHashMap<Object, Object>
implements ComponentContext<C> {
@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> T get(Object key, Class<T> 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<C> put(Object key, Object value) {
super.put(key, value);
return this;
}
@Override
public Stream<java.util.Map.Entry<Object, Object>> stream() {
return entrySet().stream();
}
@Override
public Map<String, Object> toTemplateModel() {
Map<String, Object> attributes = new HashMap<>();
// hardcoding enclosed map values into 'rawValues'
// as it may contain anything
attributes.put("rawValues", this);
return attributes;
}
}

View File

@@ -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<C extends ComponentContext<C>> {
/**
* Gets an empty context.
*
* @param <C> the type of context
* @return empty context
*/
static <C extends ComponentContext<C>> ComponentContext<C> empty() {
return new BaseComponentContext<>();
}
/**
* Gets a value from a context.
*
* @param <T> the type of context
* @param key the key
* @return a value
*/
<T> T get(Object key);
/**
* Gets a value from a context with a given type to get cast to.
*
* @param <T> the type of context
* @param key the key
* @param type the class type
* @return a value
*/
<T> T get(Object key, Class<T> type);
/**
* Put an entry into a context.
*
* @param key the entry key
* @param value the entry value
* @return a context
*/
ComponentContext<C> 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<Map.Entry<Object, Object>> 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<String, Object> toTemplateModel();
}

View File

@@ -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<T extends ComponentContext<T>> 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<String> keyMap = new KeyMap<>();
private final List<Consumer<T>> preRunHandlers = new ArrayList<>();
private final List<Consumer<T>> postRunHandlers = new ArrayList<>();
private Function<T, List<AttributedString>> 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<T, List<AttributedString>> 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<AttributedString> 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<T> handler) {
this.preRunHandlers.add(handler);
}
/**
* Adds a post-run handler.
*
* @param handler the handler
*/
public void addPostRunHandler(Consumer<T> 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<AttributedString> renderTemplateResource(Map<String, Object> 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<AttributedString> 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<String> 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<String> 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);
}
}
}

View File

@@ -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<T, C extends SelectorComponentContext<T, I, C>, I extends Nameable & Matchable & Enableable & Itemable<T>>
extends AbstractComponent<C> {
protected final String name;
private final List<I> items;
private Comparator<I> comparator = (o1, o2) -> 0;
private boolean exitSelects;
private int maxItems = 5;
private Function<T, String> 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<I> items, boolean exitSelects,
Comparator<I> 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<T, String> itemMapper) {
Assert.notNull(itemMapper, "itemMapper cannot be null");
this.itemMapper = itemMapper;
}
/**
* Gets an item mapper.
*
* @return
*/
public Function<T, String> getItemMapper() {
return itemMapper;
}
/**
* Gets items.
*
* @return a list of items
*/
protected List<I> getItems() {
return items;
}
@Override
protected void bindKeyMap(KeyMap<String> 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<ItemState<I>> itemStateView = buildItemStateView.items;
thisContext.setItemStateView(itemStateView);
thisContext.setCursorRow(start.get() + pos.get());
return thisContext;
}
@Override
protected boolean read(BindingReader bindingReader, KeyMap<String> keyMap, C context) {
if (stale) {
start.set(0);
pos.set(0);
stale = false;
}
C thisContext = getThisContext(context);
ItemStateViewProjection buildItemStateView = buildItemStateView(start.get(), thisContext);
List<ItemState<I>> 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<I> 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<T, I, ?> context) {
List<ItemState<I>> 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<ItemState<I>> filtered = itemStates.stream()
.filter(i -> {
return i.matches(context.getInput());
})
.map(i -> {
i.index = reindex.getAndIncrement();
return i;
})
.collect(Collectors.toList());
List<ItemState<I>> items = filtered.stream()
.skip(skip)
.limit(maxItems)
.collect(Collectors.toList());
return new ItemStateViewProjection(items, filtered.size());
}
private class ItemStateViewProjection {
List<ItemState<I>> items;
int total;
ItemStateViewProjection(List<ItemState<I>> items, int total) {
this.items = items;
this.total = total;
}
}
/**
* Context interface on a selector component sharing content.
*/
public interface SelectorComponentContext<T, I extends Nameable & Matchable & Itemable<T>, C extends SelectorComponentContext<T, I, C>>
extends ComponentContext<C> {
/**
* 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<ItemState<I>> getItemStates();
/**
* Sets an item states.
*
* @param itemStateView the input state
*/
void setItemStates(List<ItemState<I>> itemStateView);
/**
* Gets an item state view.
*
* @return an item state view
*/
List<ItemState<I>> getItemStateView();
/**
* Sets an item state view
*
* @param itemStateView the item state view
*/
void setItemStateView(List<ItemState<I>> 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<I> getItems();
/**
* Sets an items.
*
* @param items the items
*/
void setItems(List<I> items);
/**
* Gets a result items.
*
* @return a result items
*/
List<I> getResultItems();
/**
* Sets a result items.
*
* @param items the result items
*/
void setResultItems(List<I> items);
/**
* Creates an empty {@link SelectorComponentContext}.
*
* @return empty context
*/
static <T, I extends Nameable & Matchable & Itemable<T>, C extends SelectorComponentContext<T, I, C>> SelectorComponentContext<T, I, C> empty() {
return new BaseSelectorComponentContext<>();
}
}
/**
* Base implementation of a {@link SelectorComponentContext}.
*/
protected static class BaseSelectorComponentContext<T, I extends Nameable & Matchable & Itemable<T>, C extends SelectorComponentContext<T, I, C>>
extends BaseComponentContext<C> implements SelectorComponentContext<T, I, C> {
private String name;
private String input;
private List<ItemState<I>> itemStates;
private List<ItemState<I>> itemStateView;
private Integer cursorRow;
private List<I> items;
private List<I> 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<ItemState<I>> getItemStates() {
return itemStates;
}
@Override
public void setItemStates(List<ItemState<I>> itemStates) {
this.itemStates = itemStates;
};
@Override
public List<ItemState<I>> getItemStateView() {
return itemStateView;
}
@Override
public void setItemStateView(List<ItemState<I>> itemStateView) {
this.itemStateView = itemStateView;
};
@Override
public boolean isResult() {
return resultItems != null;
}
@Override
public Integer getCursorRow() {
return cursorRow;
}
@Override
public java.util.Map<String,Object> toTemplateModel() {
Map<String, Object> 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<I> getItems() {
return items;
}
@Override
public void setItems(List<I> items) {
this.items = items;
}
@Override
public List<I> getResultItems() {
return resultItems;
}
@Override
public void setResultItems(List<I> resultItems) {
this.resultItems = resultItems;
}
@Override
public String toString() {
return "DefaultSelectorComponentContext [cursorRow=" + cursorRow + "]";
}
}
/**
* Class keeping item state.
*/
public static class ItemState<I extends Matchable> 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 <I extends Matchable> ItemState<I> of(I item, String name, int index, boolean enabled) {
return new ItemState<I>(item, name, index, enabled);
}
}
}

View File

@@ -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<T, C extends TextComponentContext<T, C>> extends AbstractComponent<C> {
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<C, List<AttributedString>> renderer) {
super(terminal);
this.name = name;
setRenderer(renderer);
}
@Override
protected void bindKeyMap(KeyMap<String> 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<T, C extends TextComponentContext<T, C>> extends ComponentContext<C> {
/**
* 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<T, C extends TextComponentContext<T, C>> extends BaseComponentContext<C>
implements TextComponentContext<T, C> {
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<String, Object> toTemplateModel() {
Map<String, Object> 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;
}
}
}

View File

@@ -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();
}

View File

@@ -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> {
T getItem();
}

View File

@@ -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);
}

View File

@@ -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();
}

View File

@@ -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<T> extends Nameable, Matchable, Enableable, Itemable<T> {
static <T> SelectorItem<T> of(String name, T item) {
return of(name, item, true);
}
static <T> SelectorItem<T> of(String name, T item, boolean enabled) {
return new SelectorItemWrapper<T>(name, item, enabled);
}
public static class SelectorItemWrapper<T> implements SelectorItem<T> {
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;
}
}
}

View File

@@ -0,0 +1,64 @@
// used to select style if item is selected/unselected
selected_style(flag) ::= <%
<if(flag)>item-selected<else>item-unselected<endif>
%>
// selector rows
select_item(item) ::= <%
<if(item.onrow)>
<("> "); format="item-selector">
<else>
<(" ")>
<endif>
<if(item.enabled)>
<if(item.selected)>
<("[x]"); format=selected_style(item.selected)> <item.name>
<else>
<("[ ]"); format=selected_style(item.selected)> <item.name>
<endif>
<else>
<("[ ]"); format="item-disabled"> <item.name; format="item-disabled">
<endif>
%>
// start '? xxx' shows both running and result
question_name(model) ::= <<
<("?"); format="list-value"> <model.name; format="title">
>>
// within info section, dedicated instructions for user
info_filter(model) ::= <%
<if(model.input)>
, filtering '<model.input>'
<else>
, type to filter
<endif>
%>
// info section after '? xxx'
info(model) ::= <<
[Use arrows to move]<info_filter(model)>
>>
// get comma delited string
comma_delimited(values) ::= <%
<values; separator=",">
%>
// component result
result(model) ::= <<
<question_name(model)> <(comma_delimited(model.values)); format="value">
>>
// component is running
running(model) ::= <<
<question_name(model)> <info(model)>
<model.rows:{x|<select_item(x)>}; separator="\n">
>>
// main - hardcoded name
// model - model built by MultiItemSelectorContext
main(model) ::= <<
<if(model.isResult)><result(model)><else><running(model)><endif>
>>

View File

@@ -0,0 +1,38 @@
// message
message(model) ::= <%
<if(model.message && model.hasMessageLevelError)>
<(">>>"); format="level-error"> <model.message; format="level-error">
<elseif(model.message && model.hasMessageLevelWarn)>
<(">>"); format="level-warn"> <model.message; format="level-warn">
<elseif(model.message && model.hasMessageLevelInfo)>
<(">"); format="level-info"> <model.message; format="level-info">
<endif>
%>
// info section after '? xxx'
info(model) ::= <%
<if(model.input)>
<model.input>
<endif>
%>
// start '? xxx' shows both running and result
question_name(model) ::= <<
<("?"); format="list-value"> <model.name; format="title">
>>
// component result
result(model) ::= <<
<question_name(model)> <model.resultValue; format="value">
>>
// component is running
running(model) ::= <<
<question_name(model)> <info(model)>
<message(model)>
>>
// main
main(model) ::= <<
<if(model.resultValue)><result(model)><else><running(model)><endif>
>>

View File

@@ -0,0 +1,44 @@
// selector rows
select_item(item) ::= <%
<if(item.selected)>
<("> "); format="item-selector"><item.name; format="item-selector">
<else>
<(" ")><item.name>
<endif>
%>
// start '? xxx' shows both running and result
question_name(model) ::= <<
<("?"); format="list-value"> <model.name; format="title">
>>
// within info section, dedicated instructions for user
info_filter(model) ::= <%
<if(model.input)>
, filtering '<model.input>'
<else>
, type to filter
<endif>
%>
// info section after '? xxx'
info(model) ::= <<
[Use arrows to move]<info_filter(model)>
>>
// component result
result(model) ::= <<
<question_name(model)> <model.value; format="value">
>>
// component is running
running(model) ::= <<
<question_name(model)> <info(model)>
<model.rows:{x|<select_item(x)>}; separator="\n">
>>
// main - hardcoded name
// model - model built by SingleItemSelectorContext
main(model) ::= <<
<if(model.isResult)><result(model)><else><running(model)><endif>
>>

View File

@@ -0,0 +1,28 @@
// info section after '? xxx'
info(model) ::= <%
<if(model.input)>
<model.input>
<else>
<("[Default "); format="value"><model.defaultValue; format="value"><("]"); format="value">
<endif>
%>
// start '? xxx' shows both running and result
question_name(model) ::= <<
<("?"); format="list-value"> <model.name; format="title">
>>
// component result
result(model) ::= <<
<question_name(model)> <model.resultValue; format="value">
>>
// component is running
running(model) ::= <<
<question_name(model)> <info(model)>
>>
// main
main(model) ::= <<
<if(model.resultValue)><result(model)><else><running(model)><endif>
>>

View File

@@ -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<byte[]> 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;
}
}
}

View File

@@ -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<SimplePojo> SELECTOR_ITEM_1 = SelectorItem.of("simplePojo1", SIMPLE_POJO_1);
private static SelectorItem<SimplePojo> SELECTOR_ITEM_2 = SelectorItem.of("simplePojo2", SIMPLE_POJO_2);
private static SelectorItem<SimplePojo> SELECTOR_ITEM_3 = SelectorItem.of("simplePojo3", SIMPLE_POJO_3);
private static SelectorItem<SimplePojo> SELECTOR_ITEM_4 = SelectorItem.of("simplePojo4", SIMPLE_POJO_4);
private static SelectorItem<SimplePojo> SELECTOR_ITEM_5 = SelectorItem.of("simplePojo5", SIMPLE_POJO_5);
private static SelectorItem<SimplePojo> SELECTOR_ITEM_6 = SelectorItem.of("simplePojo6", SIMPLE_POJO_6);
private static SelectorItem<SimplePojo> SELECTOR_ITEM_7 = SelectorItem.of("simplePojo7", SIMPLE_POJO_7, false);
private ExecutorService service;
private CountDownLatch latch;
private AtomicReference<List<SelectorItem<SimplePojo>>> 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<SelectorItem<SimplePojo>> selected = result.get();
assertThat(selected).hasSize(1);
Stream<String> 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<SelectorItem<SimplePojo>> 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<SelectorItem<SimplePojo>> selected = result.get();
assertThat(selected).hasSize(1);
Stream<String> 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<SelectorItem<SimplePojo>> selected = result.get();
assertThat(selected).hasSize(1);
Stream<String> 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<SelectorItem<SimplePojo>> selected = result.get();
assertThat(selected).hasSize(2);
Stream<String> 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<SelectorItem<SimplePojo>> selected = result.get();
assertThat(selected).hasSize(1);
Stream<String> 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<SelectorItem<SimplePojo>> items) {
scheduleSelect(items, null);
}
private void scheduleSelect(List<SelectorItem<SimplePojo>> items, Integer maxItems) {
MultiItemSelector<SimplePojo, SelectorItem<SimplePojo>> 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;
}
}
}

View File

@@ -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<PathInputContext> result1;
private FileSystem fileSystem;
private Function<String, Path> 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");
}
}

View File

@@ -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<StringOrderAssert, String> {
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;
}
}
}

View File

@@ -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<SimplePojo> SELECTOR_ITEM_1 = SelectorItem.of("simplePojo1", SIMPLE_POJO_1);
private static SelectorItem<SimplePojo> SELECTOR_ITEM_2 = SelectorItem.of("simplePojo2", SIMPLE_POJO_2);
private static SelectorItem<SimplePojo> SELECTOR_ITEM_3 = SelectorItem.of("simplePojo3", SIMPLE_POJO_3);
private static SelectorItem<SimplePojo> SELECTOR_ITEM_4 = SelectorItem.of("simplePojo4", SIMPLE_POJO_4);
private static SelectorItem<SimplePojo> SELECTOR_ITEM_5 = SelectorItem.of("simplePojo5", SIMPLE_POJO_5);
private static SelectorItem<SimplePojo> SELECTOR_ITEM_6 = SelectorItem.of("simplePojo6", SIMPLE_POJO_6);
private ExecutorService service;
private CountDownLatch latch;
private AtomicReference<Optional<SelectorItem<SimplePojo>>> 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<SelectorItem<SimplePojo>> selected = result.get();
assertThat(selected).isNotEmpty();
Optional<String> 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<SelectorItem<SimplePojo>> selected = result.get();
assertThat(selected).isNotEmpty();
Optional<String> 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<SelectorItem<SimplePojo>> selected = result.get();
assertThat(selected).isNotEmpty();
Optional<String> 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<SelectorItem<SimplePojo>> 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<SelectorItem<SimplePojo>> items) {
scheduleSelect(items, null);
}
private void scheduleSelect(List<SelectorItem<SimplePojo>> items, Integer maxItems) {
SingleItemSelector<SimplePojo, SelectorItem<SimplePojo>> 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;
}
}
}

View File

@@ -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<StringInputContext> result1;
private AtomicReference<StringInputContext> 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");
}
}

View File

@@ -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);
}
}

View File

@@ -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<SelectorItem<String>> items = new ArrayList<>();
items.add(SelectorItem.of("key1", "value1"));
items.add(SelectorItem.of("key2", "value2"));
SingleItemSelector<String, SelectorItem<String>> component = new SingleItemSelector<>(getTerminal(),
items, "testSimple", null);
component.setResourceLoader(resourceLoader);
component.setTemplateExecutor(templateExecutor);
SingleItemSelectorContext<String, SelectorItem<String>> 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<SelectorItem<String>> items = new ArrayList<>();
items.add(SelectorItem.of("key1", "value1"));
items.add(SelectorItem.of("key2", "value2", false));
items.add(SelectorItem.of("key3", "value3"));
MultiItemSelector<String, SelectorItem<String>> component = new MultiItemSelector<>(getTerminal(),
items, "testSimple", null);
component.setResourceLoader(resourceLoader);
component.setTemplateExecutor(templateExecutor);
MultiItemSelectorContext<String, SelectorItem<String>> context = component
.run(MultiItemSelectorContext.empty());
String result = context.getResultItems().stream()
.map(si -> si.getItem())
.collect(Collectors.joining(","));
return "Got value " + result;
}
}