diff --git a/package.json b/package.json index 095eb08..0279ff4 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/main/css/main.css b/src/main/css/main.css index d101d1e..b808194 100644 --- a/src/main/css/main.css +++ b/src/main/css/main.css @@ -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; diff --git a/src/main/index.html b/src/main/index.html index ef378b1..e7af9f9 100644 --- a/src/main/index.html +++ b/src/main/index.html @@ -35,11 +35,12 @@ Microsoft Edge.
-
- +
+
diff --git a/src/main/js/Main.ts b/src/main/js/Main.ts index 816830f..6d3741f 100644 --- a/src/main/js/Main.ts +++ b/src/main/js/Main.ts @@ -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); diff --git a/src/main/js/Shared.ts b/src/main/js/Shared.ts index f9c176c..eb00c1a 100644 --- a/src/main/js/Shared.ts +++ b/src/main/js/Shared.ts @@ -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. diff --git a/src/main/js/Shell.ts b/src/main/js/Shell.ts index 7995125..4032800 100644 --- a/src/main/js/Shell.ts +++ b/src/main/js/Shell.ts @@ -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); } diff --git a/src/main/js/Terminal.ts b/src/main/js/Terminal.ts index 5c87f46..986fef9 100644 --- a/src/main/js/Terminal.ts +++ b/src/main/js/Terminal.ts @@ -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(" "); + } + } } /**