forked from tools/josh
1
0
Fork 0

Add suggestion list and common prefix fill

Fixes #108 and fixes #109.
This commit is contained in:
Florine W. Dekker 2020-03-18 18:18:43 +01:00
parent 04e8765229
commit 49da5b785a
Signed by: FWDekker
GPG Key ID: B1B567AF58D6EE0F
7 changed files with 113 additions and 45 deletions

View File

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

View File

@ -82,17 +82,17 @@ body {
white-space: pre-wrap;
}
#terminalCurrent {
#terminalInput {
max-width: 100%;
}
#terminalCurrentPrefix {
#terminalInputPrefix {
display: inline-block;
vertical-align: top;
white-space: pre;
}
#terminalCurrentFocusInput {
#terminalInputField {
display: inline-block;
margin: 0;
padding: 0;
@ -113,11 +113,11 @@ body {
font-size: inherit;
}
#terminalCurrentFocusInput br {
#terminalInputField br {
display: none;
}
.terminalCurrentFocusInputHidden {
.terminalInputFieldHidden {
max-width: 1px !important;
overflow: hidden !important;
overflow-wrap: normal !important;

View File

@ -35,11 +35,12 @@
<a href="https://www.microsoft.com/en-us/windows/microsoft-edge">Microsoft Edge</a>.
</div>
<div id="terminalOutput"></div>
<div id="terminalCurrent">
<span id="terminalCurrentPrefix"></span><!--
--><span id="terminalCurrentFocusInput" contenteditable="true" autocapitalize="none"
<div id="terminalInput">
<span id="terminalInputPrefix"></span><!--
--><span id="terminalInputField" contenteditable="true" autocapitalize="none"
spellcheck="false"></span>
</div>
<div id="terminalSuggestions"></div>
</div>
</main>

View File

@ -61,9 +61,10 @@ addOnLoad(() => {
addOnLoad(() => {
window.terminal = new Terminal(
q("#terminal"),
q("#terminalCurrentFocusInput"),
q("#terminalInputField"),
q("#terminalOutput"),
q("#terminalCurrentPrefix")
q("#terminalInputPrefix"),
q("#terminalSuggestions")
);
window.execute = (command: string) => window.terminal.processInput(command);

View File

@ -157,6 +157,28 @@ export function q(query: string): HTMLElement {
return element;
}
/**
* Returns the longest common prefix of the given strings, or `undefined` if an empty array is given.
*
* Taken from https://stackoverflow.com/a/1917041/.
*
* @param strings the string to find the longest common prefix of
* @return the longest common prefix of the given strings, or `undefined` if an empty array is given
*/
export function findLongestCommonPrefix(strings: string[]): string | undefined {
if (strings.length === 0) return undefined;
const A = strings.concat().sort();
const a1 = A[0];
const a2 = A[A.length - 1];
const L = a1.length;
let i = 0;
while (i < L && a1.charAt(i) === a2.charAt(i)) i++;
return a1.substring(0, i);
}
/**
* Indicates that the application will exit under normal circumstances.

View File

@ -4,7 +4,7 @@ import {Directory, FileSystem, Path} from "./FileSystem";
import {InputHistory} from "./InputHistory";
import {Globber, InputParser} from "./InputParser";
import {Persistence} from "./Persistence";
import {asciiHeaderHtml, extractWordBefore, IllegalStateError, isStandalone} from "./Shared";
import {asciiHeaderHtml, IllegalStateError, isStandalone} from "./Shared";
import {EscapeCharacters} from "./Terminal";
import {UserList} from "./UserList";
import {OutputStream, StreamSet} from "./Stream";
@ -183,25 +183,16 @@ export class Shell {
}
/**
* Tries to auto-complete the given input string at the indicated offset.
* Tries to auto-complete the given parameter.
*
* @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
* @param parameter the parameter to complete
* @return the suggestions for the given parameter
*/
autoComplete(input: string, offset: number): [string, number] {
autoComplete(parameter: string): string[] {
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];
return new Globber(this.fileSystem, cwd)
.glob(parameter + InputParser.EscapeChar + "*")
.map((it) => this.fileSystem.get(Path.interpret(cwd, it)) instanceof Directory ? it + "/" : it);
}

View File

@ -1,6 +1,14 @@
import {InputHistory} from "./InputHistory";
import {Persistence} from "./Persistence";
import {escapeHtml, extractWordBefore, isStandalone, moveCaretTo, moveCaretToEndOf, parseCssPixels} from "./Shared";
import {
escapeHtml,
extractWordBefore,
findLongestCommonPrefix,
isStandalone,
moveCaretTo,
moveCaretToEndOf,
parseCssPixels
} from "./Shared";
import {Shell} from "./Shell";
import {Buffer, StreamSet} from "./Stream";
@ -30,6 +38,10 @@ export class Terminal {
* The HTML element where the current prefix displayed.
*/
private readonly prefixDiv: HTMLElement;
/**
* The HTML element where auto-completion suggestions are displayed.
*/
private readonly suggestions: HTMLElement;
/**
* The history of the user's inputs.
@ -57,12 +69,15 @@ export class Terminal {
* @param input the HTML element where the user types
* @param output the HTML element where the output is displayed
* @param prefixDiv the HTML element where the current prefix is displayed
* @param suggestions the HTML element where auto-completion suggestions are displayed
*/
constructor(terminal: HTMLElement, input: HTMLElement, output: HTMLElement, prefixDiv: HTMLElement) {
constructor(terminal: HTMLElement, input: HTMLElement, output: HTMLElement, prefixDiv: HTMLElement,
suggestions: HTMLElement) {
this.terminal = terminal;
this.input = input;
this.output = output;
this.prefixDiv = prefixDiv;
this.suggestions = suggestions;
this.inputHistory = Persistence.getHistory();
this.shell = new Shell(this.inputHistory);
@ -70,6 +85,7 @@ export class Terminal {
document.addEventListener("click", this.onclick.bind(this));
document.addEventListener("keypress", this.onkeypress.bind(this));
document.addEventListener("keydown", this.onkeydown.bind(this));
this.input.addEventListener("input", () => this.suggestionsText = "");
let scrollStartPosition: number = 0;
this.terminal.addEventListener("wheel", (event: WheelEvent) => {
@ -106,6 +122,7 @@ export class Terminal {
*/
private set inputText(inputText: string) {
this.input.innerText = inputText;
this.suggestionsText = "";
}
/**
@ -140,6 +157,15 @@ export class Terminal {
this.prefixDiv.innerHTML = prefixText;
}
/**
* Sets the suggestions text.
*
* @param suggestionsText the suggestions text to set
*/
private set suggestionsText(suggestionsText: string) {
this.suggestions.innerHTML = suggestionsText;
}
/**
* Returns how many lines the user has scrolled up in the terminal.
*/
@ -172,7 +198,7 @@ export class Terminal {
* Returns `true` if and only if the input field does not display the user's input.
*/
private get isInputHidden(): boolean {
return this.input.classList.contains("terminalCurrentFocusInputHidden");
return this.input.classList.contains("terminalInputFieldHidden");
}
/**
@ -182,9 +208,9 @@ export class Terminal {
*/
private set isInputHidden(isInputHidden: boolean) {
if (isInputHidden)
this.input.classList.add("terminalCurrentFocusInputHidden");
this.input.classList.add("terminalInputFieldHidden");
else
this.input.classList.remove("terminalCurrentFocusInputHidden");
this.input.classList.remove("terminalInputFieldHidden");
}
@ -246,7 +272,7 @@ export class Terminal {
/**
* Handles click events.
* Handles click events of the document.
*/
private onclick(event: MouseEvent): void {
// Do not focus on input if user clicked a link
@ -262,7 +288,7 @@ export class Terminal {
}
/**
* Handles key press events.
* Handles key press events of the document.
*
* @param event the event to handle
*/
@ -285,7 +311,7 @@ export class Terminal {
}
/**
* Handles key down events.
* Handles key down events of the document.
*
* @param event the event to handle
*/
@ -318,18 +344,18 @@ export class Terminal {
break;
}
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);
// Auto complete, with auto fill
this.autoComplete(true);
event.preventDefault();
break;
}
case "i":
// Auto complete, without auto fill
if (event.ctrlKey) {
this.autoComplete(false);
event.preventDefault();
}
break;
case "c":
// Only if focused on the input as to not prevent copying of selected text
if (event.ctrlKey) {
@ -368,6 +394,33 @@ export class Terminal {
this.scroll = 0;
}
/**
* Invokes the auto-completion functionality of this terminal's shell and uses it to inform the user.
*
* @param autoFill `false` if the terminal should only provide the user with suggestions; `true` if the input should
* be altered if there is only one suggestion available
*/
private autoComplete(autoFill: boolean): void {
let offset = this.inputText.length;
if (this.input === document.activeElement)
offset = document.getSelection()?.anchorOffset ?? offset;
const [left, word, right] = extractWordBefore(this.inputText, offset, " ");
const suggestions = this.shell.autoComplete(word);
const commonPrefix = findLongestCommonPrefix(suggestions);
if (autoFill && commonPrefix !== undefined && commonPrefix !== word) {
const newOffset = offset + (commonPrefix.length - word.length);
this.inputText = left + commonPrefix + right;
setTimeout(() => moveCaretTo(this.input.firstChild, newOffset), 0);
} else if (!autoFill || suggestions.length > 1) {
this.suggestionsText = suggestions
.map((it) => it.slice(it.trimRightChar("/").lastIndexOf("/") + 1))
.join(" ");
}
}
}
/**