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(" ");
+ }
+ }
}
/**