Skip to content

Commit e278fb4

Browse files
javier-godoymlopezFC
authored andcommitted
feat: add support for command history
Close #13
1 parent 64874a0 commit e278fb4

File tree

6 files changed

+372
-13
lines changed

6 files changed

+372
-13
lines changed

src/main/java/com/flowingcode/vaadin/addons/xterm/ITerminalConsole.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
2727
import com.vaadin.flow.component.EventData;
2828
import com.vaadin.flow.component.HasElement;
2929
import com.vaadin.flow.shared.Registration;
30+
import elemental.json.JsonValue;
31+
import java.util.concurrent.CompletableFuture;
3032
import lombok.Getter;
3133

3234
/**
@@ -60,4 +62,10 @@ default void setInsertMode(boolean insertMode) {
6062
((XTermBase) this).executeJs("this.insertMode=$0", insertMode);
6163
}
6264

65+
/** Returns the text in the current line */
66+
default CompletableFuture<String> getCurrentLine() {
67+
return getElement().executeJs("return this.currentLine").toCompletableFuture()
68+
.thenApply(JsonValue::asString);
69+
}
70+
6371
}
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
package com.flowingcode.vaadin.addons.xterm;
2+
3+
import com.vaadin.flow.component.Key;
4+
import com.vaadin.flow.shared.Registration;
5+
import java.io.IOException;
6+
import java.io.ObjectInputStream;
7+
import java.io.Serializable;
8+
import java.util.ArrayList;
9+
import java.util.Collections;
10+
import java.util.Iterator;
11+
import java.util.LinkedList;
12+
import java.util.List;
13+
import java.util.ListIterator;
14+
import java.util.Objects;
15+
import java.util.Optional;
16+
import java.util.function.Predicate;
17+
18+
/** Manages a command history buffer for {@link XTerm}. */
19+
@SuppressWarnings("serial")
20+
public class TerminalHistory implements Serializable {
21+
22+
private LinkedList<String> history = new LinkedList<>();
23+
24+
private transient ListIterator<String> iterator;
25+
26+
private final XTermBase terminal;
27+
28+
private List<Registration> registrations;
29+
30+
private String prefix;
31+
32+
private String lastRet;
33+
34+
private Integer maxSize;
35+
36+
public <T extends XTermBase & ITerminalConsole> TerminalHistory(T terminal) {
37+
this.terminal = terminal;
38+
}
39+
40+
/**
41+
* Set the number of elements to retain. If {@code null} the history is unbounded.
42+
*
43+
* @throws IllegalArgumentException if the argument is negative.
44+
*/
45+
public void setMaxSize(Integer maxSize) {
46+
if (maxSize != null && maxSize < 0) {
47+
throw new IllegalArgumentException();
48+
}
49+
this.maxSize = maxSize;
50+
iterator = null;
51+
52+
if (maxSize != null) {
53+
while (history.size() > maxSize) {
54+
history.remove(0);
55+
}
56+
}
57+
}
58+
59+
/** Sets the enabled state of the history. */
60+
public void setEnabled(boolean enabled) {
61+
if (!enabled && registrations != null) {
62+
registrations.forEach(Registration::remove);
63+
registrations = null;
64+
} else if (enabled && registrations == null) {
65+
registrations = new ArrayList<>();
66+
registrations.add(((ITerminalConsole) terminal).addLineListener(ev -> add(ev.getLine())));
67+
registrations.add(terminal.addCustomKeyListener(ev -> handleArrowUp(), Key.ARROW_UP));
68+
registrations.add(terminal.addCustomKeyListener(ev -> handleArrowDown(), Key.ARROW_DOWN));
69+
registrations.add(terminal.addCustomKeyListener(ev -> handlePageUp(), Key.PAGE_UP));
70+
registrations.add(terminal.addCustomKeyListener(ev -> handlePageDown(), Key.PAGE_DOWN));
71+
}
72+
}
73+
74+
/** Gets the enabled state of the history. */
75+
public boolean isEnabled() {
76+
return registrations != null;
77+
}
78+
79+
private void handleArrowUp() {
80+
write(previous());
81+
}
82+
83+
private void handleArrowDown() {
84+
write(next());
85+
}
86+
87+
private void handlePageUp() {
88+
((ITerminalConsole) terminal).getCurrentLine().thenApply(this::findPrevious)
89+
.thenAccept(this::write);
90+
}
91+
92+
private void handlePageDown() {
93+
((ITerminalConsole) terminal).getCurrentLine().thenApply(this::findNext)
94+
.thenAccept(this::write);
95+
}
96+
97+
private void write(String line) {
98+
if (line != null) {
99+
// erase logical line, cursor home in logical line
100+
terminal.write("\033[<2K\033[<H" + line);
101+
lastRet = line;
102+
}
103+
}
104+
105+
private ListIterator<String> listIterator() {
106+
if (iterator == null) {
107+
iterator = history.listIterator(history.size());
108+
}
109+
return iterator;
110+
}
111+
112+
private Iterator<String> forwardIterator() {
113+
return listIterator();
114+
}
115+
116+
private Iterator<String> reverseIterator() {
117+
if (iterator == null) {
118+
iterator = history.listIterator(history.size());
119+
}
120+
return new Iterator<String>() {
121+
@Override
122+
public boolean hasNext() {
123+
return iterator.hasPrevious();
124+
}
125+
126+
@Override
127+
public String next() {
128+
return iterator.previous();
129+
}
130+
};
131+
}
132+
133+
134+
/** Add a line to the history */
135+
public void add(String line) {
136+
line = line.trim();
137+
if (!line.isEmpty()) {
138+
history.add(Objects.requireNonNull(line));
139+
iterator = null;
140+
}
141+
if (maxSize != null && history.size() > maxSize) {
142+
history.removeLast();
143+
}
144+
}
145+
146+
private void setCurrentLine(String currentLine) {
147+
if (!currentLine.equals(lastRet)) {
148+
prefix = currentLine;
149+
iterator = null;
150+
}
151+
}
152+
153+
private Optional<String> find(Iterator<String> iterator, Predicate<String> predicate) {
154+
while (iterator.hasNext()) {
155+
String line = iterator.next();
156+
if (predicate.test(line) && !line.equals(lastRet)) {
157+
return Optional.of(line);
158+
}
159+
}
160+
return Optional.empty();
161+
}
162+
163+
private String previous() {
164+
return find(reverseIterator(), line -> true).orElse(null);
165+
}
166+
167+
private String next() {
168+
return find(forwardIterator(), line -> true).orElse("");
169+
}
170+
171+
private String findPrevious(String currentLine) {
172+
setCurrentLine(currentLine);
173+
return find(reverseIterator(), line -> line.startsWith(prefix)).orElse(null);
174+
}
175+
176+
private String findNext(String currentLine) {
177+
setCurrentLine(currentLine);
178+
return find(forwardIterator(), line -> line.startsWith(prefix)).orElse("");
179+
}
180+
181+
/** Clears the history. */
182+
public void clear() {
183+
history.clear();
184+
}
185+
186+
/** Returns the lines in the history. */
187+
public List<String> getLines() {
188+
return Collections.unmodifiableList(history);
189+
}
190+
191+
private void writeObject(java.io.ObjectOutputStream out) throws IOException {
192+
int cursor = Optional.ofNullable(iterator).map(ListIterator::nextIndex).orElse(history.size());
193+
out.defaultWriteObject();
194+
out.writeInt(cursor);
195+
}
196+
197+
private void readObject(ObjectInputStream in) throws ClassNotFoundException, IOException {
198+
in.defaultReadObject();
199+
int cursor = in.readInt();
200+
if (cursor != history.size()) {
201+
iterator = history.listIterator(cursor);
202+
}
203+
}
204+
205+
}

src/main/java/com/flowingcode/vaadin/addons/xterm/XTerm.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,18 @@
2929
public class XTerm extends XTermBase
3030
implements ITerminalFit, ITerminalConsole, ITerminalClipboard {
3131

32+
private TerminalHistory history;
33+
3234
public XTerm() {
3335
setInsertMode(true);
3436
}
3537

38+
/** Returns the terminal history. */
39+
public TerminalHistory getHistory() {
40+
if (history == null) {
41+
history = new TerminalHistory(this);
42+
}
43+
return history;
44+
}
45+
3646
}

src/main/resources/META-INF/frontend/fc-xterm/xterm-console-mixin.ts

Lines changed: 54 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,22 @@ import { TerminalMixin, TerminalAddon } from '@vaadin/flow-frontend/fc-xterm/xte
2323
interface IConsoleMixin extends TerminalMixin {
2424
escapeEnabled: Boolean;
2525
insertMode: Boolean;
26+
readonly currentLine: string;
2627
}
2728

2829
class ConsoleAddon extends TerminalAddon<IConsoleMixin> {
30+
31+
get currentLine() : string {
32+
let inputHandler = ((this.$core) as any)._inputHandler;
33+
let buffer = inputHandler._bufferService.buffer;
34+
let range = buffer.getWrappedRangeForLine(buffer.y + buffer.ybase);
35+
let line = "";
36+
for (let i=range.first; i<=range.last; i++) {
37+
line += buffer.lines.get(i).translateToString();
38+
}
39+
line = line.replace(/\s+$/,"");
40+
return line;
41+
}
2942

3043
activateCallback(terminal: Terminal): void {
3144

@@ -115,17 +128,39 @@ class ConsoleAddon extends TerminalAddon<IConsoleMixin> {
115128
}).bind(inputHandler);
116129

117130
const node = this.$node;
118-
let linefeed = (function() {
131+
let linefeed = function() {
132+
node.dispatchEvent(new CustomEvent('line', {detail: this.currentLine}));
133+
}.bind(this);
134+
135+
let eraseInLine = (function(params: any) {
119136
let buffer = this._bufferService.buffer;
137+
let x = buffer.x;
138+
let y = buffer.y;
139+
140+
this.eraseInLine({params});
120141
let range = buffer.getWrappedRangeForLine(buffer.y + buffer.ybase);
121-
let line = "";
122-
for (let i=range.first; i<=range.last; i++) {
123-
line += buffer.lines.get(i).translateToString();
142+
143+
if (params[0] == 1 || params[0] == 2) {
144+
//Start of line through cursor
145+
for (let i=range.first; i<y; i++) {
146+
buffer.y = i;
147+
this.eraseInLine({params: [2]});
148+
}
149+
}
150+
151+
if (params[0] == 0 || params[0] == 2) {
152+
//Cursor to end of line
153+
for (let i=y+1; i<=range.last; i++) {
154+
buffer.y = i;
155+
this.eraseInLine({params: [2]});
156+
buffer.lines.get(buffer.ybase+buffer.y).isWrapped=false;
157+
}
124158
}
125-
line = line.replace(/\s+$/,"");
126-
node.dispatchEvent(new CustomEvent('line', {detail: line}));
159+
160+
buffer.x = x;
161+
buffer.y = y;
127162
}).bind(inputHandler);
128-
163+
129164
this._disposables = [
130165
terminal.parser.registerCsiHandler({prefix: '<', final: 'H'}, cursorHome),
131166
this.$node.customKeyEventHandlers.register(ev=> ev.key=='Home', ()=> terminal.write('\x1b[<H')),
@@ -145,6 +180,8 @@ class ConsoleAddon extends TerminalAddon<IConsoleMixin> {
145180
terminal.parser.registerCsiHandler({prefix: '<', final: 'D'}, deleteChar),
146181
this.$node.customKeyEventHandlers.register(ev=> ev.key=='Delete', ()=> terminal.write('\x1b[<D')),
147182

183+
terminal.parser.registerCsiHandler({prefix: '<', final: 'K'}, eraseInLine),
184+
148185
this.$node.customKeyEventHandlers.register(ev=> ev.key=='Insert', ev=>{
149186
this.$.insertMode = !this.$.insertMode;
150187
}),
@@ -180,14 +217,16 @@ class ConsoleAddon extends TerminalAddon<IConsoleMixin> {
180217
type Constructor<T = {}> = new (...args: any[]) => T;
181218
export function XTermConsoleMixin<TBase extends Constructor<TerminalMixin>>(Base: TBase) {
182219
return class XTermConsoleMixin extends Base implements IConsoleMixin {
220+
221+
_addon? : ConsoleAddon;
183222
escapeEnabled: Boolean;
184223

185224
connectedCallback() {
186225
super.connectedCallback();
187226

188-
let addon = new ConsoleAddon();
189-
addon.$=this;
190-
this.node.terminal.loadAddon(addon);
227+
this._addon = new ConsoleAddon();
228+
this._addon.$=this;
229+
this.node.terminal.loadAddon(this._addon);
191230
}
192231

193232
get insertMode(): Boolean {
@@ -202,6 +241,9 @@ export function XTermConsoleMixin<TBase extends Constructor<TerminalMixin>>(Base
202241
}
203242
}
204243

205-
}
206-
}
244+
get currentLine() : string {
245+
return this._addon.currentLine;
246+
}
207247

248+
}
249+
}

src/test/java/com/flowingcode/vaadin/addons/xterm/XtermDemoView.java

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ public XtermDemoView() {
4646

4747
xterm = new XTerm();
4848
xterm.writeln("xterm add-on by Flowing Code S.A.\n\n");
49-
xterm.writeln("Commands: time, date, beep, color on, color off\n");
49+
xterm.writeln("Commands: time, date, beep, color on, color off, history\n");
5050
xterm.setCursorBlink(true);
5151
xterm.setCursorStyle(CursorStyle.UNDERLINE);
5252
xterm.setBellStyle(BellStyle.SOUND);
@@ -58,6 +58,8 @@ public XtermDemoView() {
5858
xterm.setPasteWithMiddleClick(true);
5959
xterm.setPasteWithRightClick(true);
6060

61+
xterm.getHistory().setEnabled(true);
62+
6163
xterm.addLineListener(
6264
ev -> {
6365
switch (ev.getLine().toLowerCase()) {
@@ -82,6 +84,9 @@ public XtermDemoView() {
8284
case "color off":
8385
xterm.setTheme(new TerminalTheme());
8486
break;
87+
case "history":
88+
showHistory();
89+
break;
8590
default:
8691
xterm.writeln("Bad command");
8792
Notification.show(ev.getLine());
@@ -92,4 +97,14 @@ public XtermDemoView() {
9297
xterm.fit();
9398
add(xterm);
9499
}
100+
101+
private void showHistory() {
102+
int index = 1;
103+
StringBuilder sb = new StringBuilder();
104+
for (String line : xterm.getHistory().getLines()) {
105+
sb.append(String.format("%5s %s\n", index++, line));
106+
}
107+
xterm.write(sb.toString());
108+
}
109+
95110
}

0 commit comments

Comments
 (0)