forked from tools/josh
1
0
Fork 0

Clean up and test input history

Fixes #75.
This commit is contained in:
Florine W. Dekker 2019-11-12 01:53:57 +01:00
parent 76cdab24df
commit 100f341c3e
Signed by: FWDekker
GPG Key ID: B1B567AF58D6EE0F
7 changed files with 314 additions and 120 deletions

View File

@ -1,6 +1,6 @@
{
"name": "fwdekker.com",
"version": "1.12.0",
"version": "1.12.1",
"description": "The source code of [my personal website](https://fwdekker.com/).",
"author": "Felix W. Dekker",
"repository": {

114
src/main/js/InputHistory.ts Normal file
View File

@ -0,0 +1,114 @@
import {IllegalArgumentError} from "./Shared";
/**
* A history of inputs that grows downwards and can be accessed in sequence relative to the newest entry.
*
* An input history keeps a "read index" that starts at `-1`. After entries have been added, calling `previous` will
* increase the read index to `0` and return the newest entry. Calling `previous` again will increase the read index to
* `1` and return the first-to-last entry. Calling `next` at this point will decrease the read index to `0` and will
* return the last entry again. Adding a new entry to the history resets the read index to `-1`. Calling `next` while
* the read index is at `-1` will return an empty string without decrementing the read index further. Calling `previous`
* at the highest possible index will return the first entry without incrementing the read index further.
*/
export class InputHistory {
/**
* The list of previous input.
*/
private readonly _entries: string[];
/**
* The current index that the history is being read from.
*/
private index: number;
/**
* Constructs a new input history.
*
* @param history the records currently in the history
*/
constructor(history: string[] = []) {
this._entries = history;
this.index = -1;
}
/**
* Returns a copy of the entries in this history.
*/
get entries(): string[] {
return this._entries.slice();
}
/**
* Adds a new input to the bottom of the history and resets the read index.
*
* @param entry the entry to add
*/
add(entry: string): void {
if (entry.trim() !== "" && entry.trim() !== this._entries[0]?.trim())
this._entries.unshift(entry);
this.resetIndex();
}
/**
* Removes all entries from the history and resets the read index.
*/
clear(): void {
this._entries.length = 0;
this.resetIndex();
}
/**
* Returns the entry at the given index, or an empty string if the index is negative.
*
* @param index the index to return the entry of, where `0` is the newest entry and `-1` returns an empty string
* @throws if the index is out of bounds and not `-1`
*/
get(index: number): string {
if (index < -1 || index >= this._entries.length)
throw new IllegalArgumentError(`Index '${index}' is out of bounds.`);
if (index === -1)
return "";
return this._entries[index];
}
/**
* Returns the next (newer) entry in the history, or an empty string if the read index has gone past the newest
* entry.
*
* The read counter is decremented if possible.
*/
next(): string {
this.index--;
if (this.index < -1)
this.index = -1;
return this.get(this.index);
}
/**
* Returns the previous (older) entry in the history, or the oldest entry if the read index is already at the oldest
* entry.
*
* The read counter is incremented if possible.
*/
previous(): string {
this.index++;
if (this.index >= this._entries.length)
this.index = this._entries.length - 1;
return this.get(this.index);
}
/**
* Resets the read index without changing any entries.
*/
resetIndex(): void {
this.index = -1;
}
}

View File

@ -1,8 +1,8 @@
import * as Cookies from "js-cookie";
import {Environment} from "./Environment";
import {Directory, FileSystem, Node} from "./FileSystem";
import {InputHistory} from "./InputHistory";
import {UserList} from "./UserList";
import {InputHistory} from "./Terminal";
/**

View File

@ -1,10 +1,11 @@
import {Commands} from "./Commands";
import {Environment} from "./Environment";
import {Directory, FileSystem, Path} from "./FileSystem";
import {InputHistory} from "./InputHistory";
import {InputParser} from "./InputParser";
import {Persistence} from "./Persistence";
import {asciiHeaderHtml, IllegalStateError} from "./Shared";
import {EscapeCharacters, InputHistory} from "./Terminal";
import {EscapeCharacters} from "./Terminal";
import {UserList} from "./UserList";
import {StreamSet} from "./Stream";
@ -144,7 +145,7 @@ export class Shell {
return 0;
}
this.inputHistory.addEntry(inputString);
this.inputHistory.add(inputString);
let input;
try {

View File

@ -1,3 +1,4 @@
import {InputHistory} from "./InputHistory";
import {Persistence} from "./Persistence";
import {escapeHtml, moveCaretToEndOf, parseCssPixels} from "./Shared";
import {Shell} from "./Shell";
@ -288,11 +289,11 @@ export class Terminal {
switch (event.key.toLowerCase()) {
case "arrowup":
this.inputText = this.inputHistory.previousEntry();
this.inputText = this.inputHistory.previous();
window.setTimeout(() => moveCaretToEndOf(this.input), 0);
break;
case "arrowdown":
this.inputText = this.inputHistory.nextEntry();
this.inputText = this.inputHistory.next();
window.setTimeout(() => moveCaretToEndOf(this.input), 0);
break;
case "tab":
@ -332,114 +333,3 @@ export enum EscapeCharacters {
*/
ShowInput = "\u0003"
}
/**
* A history of inputs that grows downwards and can be accessed in sequence relative to the newest entry.
*
* An input history keeps a "read index" that starts at `-1`. After entries have been added, calling `previousEntry`
* will increase the read index to `0` and return the newest entry. Calling `previousEntry` again will increase the read
* index to `1` and return the first-to-last entry. Calling `nextEntry` at this point will decrease the read index to
* `0` and will return the last entry again. Adding a new entry to the history resets the read index to `-1`. Calling
* `nextEntry` while the read index is at `-1` will return an empty string without decrementing the read index further.
* Calling `previousEntry` at the highest possible index will return the first entry without incrementing the read index
* further.
*/
export class InputHistory {
/**
* The list of previous input.
*/
private readonly _entries: string[];
/**
* The current index that the history is being read from.
*/
private index: number;
/**
* Constructs a new input history.
*
* @param history the records currently in the history
*/
constructor(history: string[] = []) {
this._entries = history;
this.index = -1;
}
/**
* Returns a copy of the entries in this history.
*/
get entries(): string[] {
return this._entries.slice();
}
/**
* Adds a new input to the bottom of the history and resets the read index.
*
* @param entry the entry to add
*/
addEntry(entry: string): void {
if (entry.trim() !== "" && entry.trim() !== this._entries[0]?.trim())
this._entries.unshift(entry);
this.index = -1;
}
/**
* Removes all entries from the history and resets the read index.
*/
clear(): void {
this._entries.length = 0;
this.index = -1;
}
/**
* Returns the entry at the given index, or an empty string if the index is negative.
*
* @param index the index to return the entry of, where `0` is the newest entry and `-1` returns an empty string
* @throws if the index is out of bounds and not `-1`
*/
getEntry(index: number): string {
if (index === -1)
return "";
return this._entries[index];
}
/**
* Returns the next (newer) entry in the history, or an empty string if the read index has gone past the newest
* entry.
*
* The read counter is decremented if possible.
*/
nextEntry(): string {
this.index--;
if (this.index < -1)
this.index = -1;
return this.getEntry(this.index);
}
/**
* Returns the previous (older) entry in the history, or the oldest entry if the read index is already at the oldest
* entry.
*
* The read counter is incremented if possible.
*/
previousEntry(): string {
this.index++;
if (this.index >= this._entries.length)
this.index = this._entries.length - 1;
return this.getEntry(this.index);
}
/**
* Resets the read index without changing any entries.
*/
resetIndex(): void {
this.index = -1;
}
}

View File

@ -0,0 +1,189 @@
import "mocha";
import {expect} from "chai";
import {InputHistory} from "../main/js/InputHistory";
describe("input history", () => {
let history: InputHistory;
beforeEach(() => {
history = new InputHistory();
});
describe("entries", () => {
it("returns a copy of the entries", () => {
history.add("old");
history.entries[0] = "new";
expect(history.entries[0]).to.equal("old");
});
});
describe("add", () => {
it("does not add empty entries", () => {
history.add("");
expect(history.entries.length).to.equal(0);
});
it("does not add duplicate entries", () => {
history.add("command");
history.add("command");
expect(history.entries.length).to.equal(1);
});
it("does not add duplicate entries, ignoring leading and trailing whitespace", () => {
history.add("command ");
history.add(" command");
expect(history.entries.length).to.equal(1);
});
it("adds entries including leading and trailing whitespace", () => {
history.add(" command ");
expect(history.entries[0]).to.equal(" command ");
});
it("resets the index", () => {
history.add("command1");
history.add("command2");
history.previous();
history.add("command3");
expect(history.previous()).to.equal("command3");
});
});
describe("clear", () => {
it("removes all entries", () => {
history.add("command1");
history.add("command2");
history.clear();
expect(history.entries).to.have.length(0);
});
it("resets the index", () => {
history.add("command1");
history.add("command2");
history.previous();
history.previous();
history.clear();
expect(history.previous()).to.equal("");
});
});
describe("get", () => {
it("throws an error if the index is below -1", () => {
expect(() => history.get(-2)).to.throw();
});
it("throws an error if the index is greater than or equal to the history size", () => {
history.add("command1");
history.add("command2");
expect(() => history.get(2)).to.throw();
});
it("returns an empty string if the index is -1 and the history is empty", () => {
expect(history.get(-1)).to.equal("");
});
it("returns an empty string if the index is -1 and the history is non-empty", () => {
history.add("command1");
history.add("command2");
expect(history.get(-1)).to.equal("");
});
it("returns the most recent entry at index 0", () => {
history.add("command1");
history.add("command2");
expect(history.get(0)).to.equal("command2");
});
it("returns the least recent entry at the highest index", () => {
history.add("command1");
history.add("command2");
expect(history.get(1)).to.equal("command1");
});
});
describe("next", () => {
beforeEach(() => {
history.add("command1");
history.add("command2");
history.add("command3");
});
it("returns an empty string at the first call", () => {
expect(history.next()).to.equal("");
});
it("returns an empty string if the index is -1", () => {
history.next();
history.next();
history.next();
expect(history.next()).to.equal("");
});
it("returns an empty string if the index is currently at the most recent entry", () => {
history.previous();
expect(history.next()).to.equal("");
});
it("returns the most recent entry if the index is currently at the second-most recent", () => {
history.previous();
history.previous();
expect(history.next()).to.equal("command3");
});
});
describe("previous", () => {
beforeEach(() => {
history.add("command1");
history.add("command2");
history.add("command3");
});
it("returns the newest entry at the first call", () => {
expect(history.previous()).to.equal("command3");
});
it("returns the second-newest entry at the second call", () => {
history.previous();
expect(history.previous()).to.equal("command2");
});
it("always returns the oldest entry once the index has reached the oldest entry", () => {
history.previous();
history.previous();
history.previous();
history.previous();
expect(history.previous()).to.equal("command1");
});
});
describe("resetIndex", () => {
// Covered indirectly in `add`
});
});

View File

@ -110,7 +110,7 @@ describe("paths", () => {
describe("ancestors", () => {
it("returns an empty list for the root path", () => {
expect(new Path("/").ancestors.map(it => it.toString())).to.be.empty;
expect(new Path("/").ancestors.map(it => it.toString())).to.have.length(0);
});
it("returns the root path for a path to a subdirectory of root", () => {
@ -152,11 +152,11 @@ describe("paths", () => {
describe("ancestors until", () => {
it("returns no ancestors between root and itself", () => {
expect(new Path("/").getAncestorsUntil(new Path("/"))).to.be.empty;
expect(new Path("/").getAncestorsUntil(new Path("/"))).to.have.length(0);
});
it("returns no ancestors between a non-root and itself", () => {
expect(new Path("/dir/file").getAncestorsUntil(new Path("/dir/file"))).to.be.empty;
expect(new Path("/dir/file").getAncestorsUntil(new Path("/dir/file"))).to.have.length(0);
});
it("returns the root parent if there are no other ancestors in between", () => {