forked from tools/josh
1
0
Fork 0

Implement auto completion of files

Fixes #39.
This commit is contained in:
Florine W. Dekker 2020-03-18 16:44:16 +01:00
parent 1399db3950
commit 5b28416861
Signed by: FWDekker
GPG Key ID: B1B567AF58D6EE0F
9 changed files with 124 additions and 36 deletions

View File

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

View File

@ -45,7 +45,7 @@ addOnLoad(() => {
});
/**
* Exist the application if the server is "shut down".
* Exits the application if the server is "shut down".
*/
addOnLoad(() => {
if (Persistence.getPoweroff()) {

View File

@ -1,3 +1,6 @@
import "./Extensions";
/**
* The fancy ASCII header that is displayed at the start of a terminal session.
*/
@ -50,6 +53,29 @@ export function escapeHtml(string: string): string {
.replace(/'/g, "'");
}
/**
* Given an input string, finds the word that ends at the indicated offset, and returns the string before the word, the
* word itself, and the string after the word.
*
* The word is preceded by a whitespace or forward slash, and ends at the indicated offset. Whitespace and forward
* slashes at the end of the word are ignored.
*
* @param input the input string to find the word in
* @param offset the right-most position of the word
* @param delimiters the delimiters to consider
* @return the string before the word, the word itself, and the string after the word
*/
export function extractWordBefore(input: string, offset: number, delimiters: string = " /"): [string, string, string] {
const right = input.slice(offset);
const leftPlusWord = input.slice(0, offset);
const trimmedLeftPlusWord =
delimiters.split("").reduce((acc, delimiter) => acc.trimRightChar(delimiter), leftPlusWord);
const wordStart = Math.max.apply(null, delimiters.split("").map((it) => trimmedLeftPlusWord.lastIndexOf(it)));
return [leftPlusWord.slice(0, wordStart + 1), leftPlusWord.slice(wordStart + 1, leftPlusWord.length), right];
}
/**
* Returns the extension of the given filename, or `""` if it doesn't have one.
*
@ -70,10 +96,13 @@ export function isStandalone(): boolean {
/**
* Moves the caret to the given position in the given node.
*
* @param node the node to move the caret in
* @param node the node to move the caret in; if `null`, nothing happens
* @param position the position from the left to place the caret at
*/
export function moveCaretTo(node: Node, position: number): void {
export function moveCaretTo(node: Node | null, position: number): void {
if (node === null)
return;
const range = document.createRange();
range.setStart(node, position);

View File

@ -2,9 +2,9 @@ import {Commands} from "./Commands";
import {Environment} from "./Environment";
import {Directory, FileSystem, Path} from "./FileSystem";
import {InputHistory} from "./InputHistory";
import {InputParser} from "./InputParser";
import {Globber, InputParser} from "./InputParser";
import {Persistence} from "./Persistence";
import {asciiHeaderHtml, IllegalStateError, isStandalone} from "./Shared";
import {asciiHeaderHtml, extractWordBefore, IllegalStateError, isStandalone} from "./Shared";
import {EscapeCharacters} from "./Terminal";
import {UserList} from "./UserList";
import {OutputStream, StreamSet} from "./Stream";
@ -182,6 +182,28 @@ export class Shell {
});
}
/**
* Tries to auto-complete the given input string at the indicated offset.
*
* @param input the input to auto-complete in
* @param offset the offset of the caret in the given string at which auto-completion is invoked
* @return the new input string and caret position
*/
autoComplete(input: string, offset: number): [string, number] {
const cwd = this.environment.get("cwd");
const globber = new Globber(this.fileSystem, cwd);
const [left, word, right] = extractWordBefore(input, offset, " ");
const options = globber.glob(word + InputParser.EscapeChar + "*");
if (options.length !== 1)
return [input, offset];
let replacement = options[0];
if (this.fileSystem.get(Path.interpret(cwd, replacement)) instanceof Directory)
replacement += "/";
return [left + replacement + right, (left + replacement).length];
}
/**
* Persists the shell's state.

View File

@ -1,6 +1,6 @@
import {InputHistory} from "./InputHistory";
import {Persistence} from "./Persistence";
import {escapeHtml, isStandalone, moveCaretTo, moveCaretToEndOf, parseCssPixels} from "./Shared";
import {escapeHtml, extractWordBefore, isStandalone, moveCaretTo, moveCaretToEndOf, parseCssPixels} from "./Shared";
import {Shell} from "./Shell";
import {Buffer, StreamSet} from "./Stream";
@ -297,8 +297,10 @@ export class Terminal {
case "meta":
case "os":
case "shift":
// Do nothing
return; // Return without scrolling to 0
case "arrowup": {
// Display previous entry from history
this.inputText = this.inputHistory.previous();
const inputChild = this.input.firstChild;
@ -307,6 +309,7 @@ export class Terminal {
break;
}
case "arrowdown": {
// Display next entry in history
this.inputText = this.inputHistory.next();
const inputChild = this.input.firstChild;
@ -314,9 +317,19 @@ export class Terminal {
setTimeout(() => moveCaretToEndOf(inputChild), 0);
break;
}
case "tab":
case "tab": {
// Auto complete
let offset = this.inputText.length;
if (this.input === document.activeElement)
offset = document.getSelection()?.anchorOffset ?? offset;
const [newInput, newPosition] = this.shell.autoComplete(this.inputText, offset);
this.inputText = newInput;
setTimeout(() => moveCaretTo(this.input.firstChild, newPosition), 0);
event.preventDefault();
break;
}
case "c":
// Only if focused on the input as to not prevent copying of selected text
if (event.ctrlKey) {
@ -328,6 +341,7 @@ export class Terminal {
}
break;
case "l":
// Clear screen
if (event.ctrlKey) {
this.outputText = "";
@ -337,28 +351,15 @@ export class Terminal {
break;
case "w":
case "backspace":
// Remove word before caret
if (event.ctrlKey) {
let offset = this.inputText.length;
if (this.input === document.activeElement)
offset = document.getSelection()?.anchorOffset ?? offset;
const left = this.inputText.slice(0, offset);
const right = this.inputText.slice(offset);
const delimiterIndex = Math.max(
left.trimRightChar(" ").lastIndexOf(" "),
left.trimRightChar("/").lastIndexOf("/")
);
const newLeft = delimiterIndex >= 0
? left.slice(0, delimiterIndex + 1)
: "";
const newOffset = offset - (left.length - newLeft.length);
const [newLeft, word, right] = extractWordBefore(this.inputText, offset);
this.inputText = newLeft + right;
const element = this.input.firstChild;
if (element !== null)
window.setTimeout(() => moveCaretTo(element, newOffset), 0);
window.setTimeout(() => moveCaretTo(this.input.firstChild, offset - word.length), 0);
event.preventDefault();
}

View File

@ -1,7 +1,7 @@
import "mocha";
import {expect} from "chai";
import "../main/js/Extensions"
import "../main/js/Extensions";
import {Directory, File} from "../main/js/FileSystem";
@ -130,11 +130,11 @@ describe("directory", () => {
});
const copy = directory.copy();
(<File>directory.get("file")).open("write").write("changed");
expect((<File>copy.get("file")).open("read").read()).to.equal("contents");
(<File> directory.get("file")).open("write").write("changed");
expect((<File> copy.get("file")).open("read").read()).to.equal("contents");
(<Directory>directory.get("dir")).add("file2", new File());
expect((<Directory>copy.get("dir")).nodeCount).to.equal(0);
(<Directory> directory.get("dir")).add("file2", new File());
expect((<Directory> copy.get("dir")).nodeCount).to.equal(0);
directory.remove("file");
expect(copy.nodeCount).to.equal(2);

View File

@ -1,7 +1,7 @@
import "mocha";
import {expect} from "chai";
import "../main/js/Extensions"
import "../main/js/Extensions";
import {Directory, File, FileSystem, Path} from "../main/js/FileSystem";
@ -127,7 +127,7 @@ describe("file system", () => {
fileSystem.copy(new Path("/src"), new Path("/dst"), false);
file.open("write").write("new");
expect((<File>fileSystem.get(new Path("/dst"))).open("read").read()).to.equal("old");
expect((<File> fileSystem.get(new Path("/dst"))).open("read").read()).to.equal("old");
});
});
@ -196,7 +196,7 @@ describe("file system", () => {
expect(fileSystem.has(new Path("/dir"))).to.be.true;
});
it("returns true if a directory exists at the given file path", () =>{
it("returns true if a directory exists at the given file path", () => {
fileSystem.add(new Path("/dir"), new Directory(), false);
expect(fileSystem.has(new Path("/dir"))).to.be.true;
@ -263,7 +263,7 @@ describe("file system", () => {
fileSystem.move(new Path("/src"), new Path("/dst"));
file.open("write").write("new");
expect((<File>fileSystem.get(new Path("/dst"))).open("read").read()).to.equal("new");
expect((<File> fileSystem.get(new Path("/dst"))).open("read").read()).to.equal("new");
});
it("throws an error if the destination already exists", () => {

View File

@ -1,7 +1,7 @@
import "mocha";
import {expect} from "chai";
import "../main/js/Extensions"
import "../main/js/Extensions";
import {Path} from "../main/js/FileSystem";

View File

@ -1,8 +1,7 @@
import "mocha";
import {expect} from "chai";
import "../main/js/Extensions"
import {escapeHtml, getFileExtension, parseCssPixels} from "../main/js/Shared";
import {escapeHtml, extractWordBefore, getFileExtension, parseCssPixels} from "../main/js/Shared";
describe("shared functions", () => {
@ -20,6 +19,43 @@ describe("shared functions", () => {
});
});
describe("extractWordBefore", () => {
it("returns the word", () => {
const parts = extractWordBefore("a b c", 3);
expect(parts).to.deep.equal(["a ", "b", " c"]);
});
it("returns the word including trailing whitespace", () => {
const parts = extractWordBefore("a b c", 5);
expect(parts).to.deep.equal(["a ", "b ", " c"]);
});
it("returns the word including trailing forward slashes", () => {
const parts = extractWordBefore("a b// c", 5);
expect(parts).to.deep.equal(["a ", "b//", " c"]);
});
it("returns the word including trailing whitespace and forward slashes", () => {
const parts = extractWordBefore("a /b// c", 9);
expect(parts).to.deep.equal(["a /", "b// ", " c"]);
});
it("returns the word consisting of only forward slashes, delimited by whitespace", () => {
const parts = extractWordBefore("a / c", 4);
expect(parts).to.deep.equal(["a ", "/ ", "c"]);
});
it("returns the word if there is no preceding delimiter", () => {
const parts = extractWordBefore("ab c", 2);
expect(parts).to.deep.equal(["", "ab", " c"]);
});
it("returns the word based on custom delimiters", () => {
const parts = extractWordBefore("a|b c|d", 6, "|");
expect(parts).to.deep.equal(["a|", "b c|", "d"]);
});
});
describe("getFileExtension", () => {
it("returns the extension of a file", () => {
expect(getFileExtension("file.ext")).to.equal("ext");