From 5b284168611981210128595177e81622ff8b0edd Mon Sep 17 00:00:00 2001 From: "Felix W. Dekker" Date: Wed, 18 Mar 2020 16:44:16 +0100 Subject: [PATCH] Implement auto completion of files Fixes #39. --- package.json | 2 +- src/main/js/Main.ts | 2 +- src/main/js/Shared.ts | 33 ++++++++++++++++++++++++++++-- src/main/js/Shell.ts | 26 ++++++++++++++++++++++-- src/main/js/Terminal.ts | 37 +++++++++++++++++----------------- src/test/Directory.spec.ts | 10 +++++----- src/test/FileSystem.spec.ts | 8 ++++---- src/test/Path.spec.ts | 2 +- src/test/Shared.spec.ts | 40 +++++++++++++++++++++++++++++++++++-- 9 files changed, 124 insertions(+), 36 deletions(-) diff --git a/package.json b/package.json index bebd217..69cf80e 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/main/js/Main.ts b/src/main/js/Main.ts index b08d422..816830f 100644 --- a/src/main/js/Main.ts +++ b/src/main/js/Main.ts @@ -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()) { diff --git a/src/main/js/Shared.ts b/src/main/js/Shared.ts index f4aa270..82b7a02 100644 --- a/src/main/js/Shared.ts +++ b/src/main/js/Shared.ts @@ -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); diff --git a/src/main/js/Shell.ts b/src/main/js/Shell.ts index 68b0e93..7995125 100644 --- a/src/main/js/Shell.ts +++ b/src/main/js/Shell.ts @@ -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. diff --git a/src/main/js/Terminal.ts b/src/main/js/Terminal.ts index 1a435c0..152ed61 100644 --- a/src/main/js/Terminal.ts +++ b/src/main/js/Terminal.ts @@ -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(); } diff --git a/src/test/Directory.spec.ts b/src/test/Directory.spec.ts index 2ecb25c..588d312 100644 --- a/src/test/Directory.spec.ts +++ b/src/test/Directory.spec.ts @@ -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(); - (directory.get("file")).open("write").write("changed"); - expect((copy.get("file")).open("read").read()).to.equal("contents"); + ( directory.get("file")).open("write").write("changed"); + expect(( copy.get("file")).open("read").read()).to.equal("contents"); - (directory.get("dir")).add("file2", new File()); - expect((copy.get("dir")).nodeCount).to.equal(0); + ( directory.get("dir")).add("file2", new File()); + expect(( copy.get("dir")).nodeCount).to.equal(0); directory.remove("file"); expect(copy.nodeCount).to.equal(2); diff --git a/src/test/FileSystem.spec.ts b/src/test/FileSystem.spec.ts index afb629b..26b5296 100644 --- a/src/test/FileSystem.spec.ts +++ b/src/test/FileSystem.spec.ts @@ -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((fileSystem.get(new Path("/dst"))).open("read").read()).to.equal("old"); + expect(( 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((fileSystem.get(new Path("/dst"))).open("read").read()).to.equal("new"); + expect(( fileSystem.get(new Path("/dst"))).open("read").read()).to.equal("new"); }); it("throws an error if the destination already exists", () => { diff --git a/src/test/Path.spec.ts b/src/test/Path.spec.ts index 24fd4c8..92da9ae 100644 --- a/src/test/Path.spec.ts +++ b/src/test/Path.spec.ts @@ -1,7 +1,7 @@ import "mocha"; import {expect} from "chai"; -import "../main/js/Extensions" +import "../main/js/Extensions"; import {Path} from "../main/js/FileSystem"; diff --git a/src/test/Shared.spec.ts b/src/test/Shared.spec.ts index e3ce571..51d5470 100644 --- a/src/test/Shared.spec.ts +++ b/src/test/Shared.spec.ts @@ -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");