forked from tools/josh
parent
04e8765229
commit
49da5b785a
|
@ -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": {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -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(" ");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
Loading…
Reference in New Issue