From 446e647e258630b524188da50d13b8012736a962 Mon Sep 17 00:00:00 2001 From: Ian Thomas Date: Fri, 12 Jul 2024 08:42:52 +0100 Subject: [PATCH] Add storage of command history --- src/commands/builtin_command_runner.ts | 13 +++++- src/context.ts | 10 ++--- src/history.ts | 55 ++++++++++++++++++++++++++ src/shell.ts | 33 +++++++++++++--- tests/history.test.ts | 49 +++++++++++++++++++++++ 5 files changed, 147 insertions(+), 13 deletions(-) create mode 100644 src/history.ts create mode 100644 tests/history.test.ts diff --git a/src/commands/builtin_command_runner.ts b/src/commands/builtin_command_runner.ts index 82ed0fb..167e91a 100644 --- a/src/commands/builtin_command_runner.ts +++ b/src/commands/builtin_command_runner.ts @@ -3,7 +3,7 @@ import { Context } from "../context" export class BuiltinCommandRunner implements ICommandRunner { names(): string[] { - return ["cd"] + return ["cd", "history"] } async run(cmdName: string, context: Context): Promise { @@ -11,7 +11,10 @@ export class BuiltinCommandRunner implements ICommandRunner { case "cd": this._cd(context) break - } + case "history": + await this._history(context) + break + } } private _cd(context: Context) { @@ -38,4 +41,10 @@ export class BuiltinCommandRunner implements ICommandRunner { context.environment.set("OLDPWD", oldPwd) context.environment.set("PWD", FS.cwd()) } + + private async _history(context: Context) { + // TODO: support flags to clear, etc, history. + const { history, stdout } = context + await history.write(stdout) + } } diff --git a/src/context.ts b/src/context.ts index edc921a..830bad6 100644 --- a/src/context.ts +++ b/src/context.ts @@ -1,4 +1,5 @@ import { Environment } from "./environment" +import { History } from "./history" import { IFileSystem } from "./file_system" import { IInput, IOutput } from "./io" @@ -10,16 +11,13 @@ export class Context { readonly args: string[], readonly fileSystem: IFileSystem, readonly mountpoint: string, - environment: Environment, + readonly environment: Environment, + readonly history: History, readonly stdin: IInput, readonly stdout: IOutput, - ) { - this.environment = environment - } + ) {} async flush(): Promise { await this.stdout.flush() } - - environment: Environment } diff --git a/src/history.ts b/src/history.ts new file mode 100644 index 0000000..54bc667 --- /dev/null +++ b/src/history.ts @@ -0,0 +1,55 @@ +import { IOutput } from "./io/output" + +/** + * Command history. Also maintains a current index in the history for scrolling through it. + */ +export class History { + add(command: string) { + this._current = null + + if (!command.trim()) { + // Do nothing with command that is all whitespace. + return + } + if (this._history.length >= this._maxSize) { + this._history.shift() + } + this._history.push(command) + } + + // Supports negative indexing from end. + at(index: number): string | null { + return this._history.at(index) ?? null + } + + scrollCurrent(next: boolean): string | null { + if (next) { + this._current = (this._current === null) ? null : this._current+1 + } else { + this._current = (this._current === null) ? this._history.length-1 : this._current-1 + } + + if (this._current !== null) { + if (this._current < 0) { + this._current = 0 + } else if (this._current >= this._history.length) { + this._current = null + } + } + + return this._current === null ? null : this.at(this._current) + } + + async write(output: IOutput): Promise { + for (let i = 0; i < this._history.length; i++) { + const index = String(i).padStart(5, ' ') + await output.write(`${index} ${this._history[i]}\n`) + } + } + + private _history: string[] = [] + private _current: number | null = null + + // Smnall number for debug purposes. Should probably get this from an env var. + private _maxSize: number = 5 +} diff --git a/src/shell.ts b/src/shell.ts index 7865f9f..7a1a434 100644 --- a/src/shell.ts +++ b/src/shell.ts @@ -2,6 +2,7 @@ import { CommandRegistry } from "./command_registry" import { Context } from "./context" import { Environment } from "./environment" import { IFileSystem } from "./file_system" +import { History } from "./history" import { FileInput, FileOutput, IInput, IOutput, Pipe, TerminalInput, TerminalOutput } from "./io" import { IOutputCallback } from "./output_callback" import { CommandNode, PipeNode, parse } from "./parse" @@ -16,12 +17,17 @@ export class Shell { this._mountpoint = mountpoint; this._currentLine = "" this._environment = new Environment() + this._history = new History() } get environment(): Environment { return this._environment } + get history(): History { + return this._history + } + async input(char: string): Promise { // Might be a multi-char string if begins with escape code. const code = char.charCodeAt(0) @@ -57,10 +63,12 @@ export class Shell { } } else if (code == 27) { // Escape following by 1+ more characters const remainder = char.slice(1) - if (remainder == "[A" || remainder == "[1A") { // Up arrow - - } else if (remainder == "[B" || remainder == "[1B") { // Down arrow - + if (remainder == "[A" || remainder == "[1A" || // Up arrow + remainder == "[B" || remainder == "[1B") { // Down arrow + const cmdText = this._history.scrollCurrent(remainder.endsWith("B")) + this._currentLine = cmdText !== null ? cmdText : "" + // Re-output whole line. + this.output(`\x1B[1K\r${this._environment.getPrompt()}${this._currentLine}`) } } else if (code == 4) { // EOT, usually = Ctrl-D @@ -102,6 +110,20 @@ export class Shell { // Keeping this public for tests. async _runCommands(cmdText: string): Promise { + if (cmdText.startsWith("!")) { + // Get command from history and run that. + const index = parseInt(cmdText.slice(1)) + const possibleCmd = this._history.at(index) + if (possibleCmd === null) { + await this.output("\x1b[1;31m!" + index + ": event not found\x1b[1;0m\r\n") + await this.output(this._environment.getPrompt()) + return + } + cmdText = possibleCmd + } + + this._history.add(cmdText) + const stdin = new TerminalInput() const stdout = new TerminalOutput(this._outputCallback) try { @@ -162,7 +184,7 @@ export class Shell { const args = commandNode.suffix.map((token) => token.value) const context = new Context( - args, this._fileSystem!, this._mountpoint, this._environment, input, output, + args, this._fileSystem!, this._mountpoint, this._environment, this._history, input, output, ) await runner.run(name, context) @@ -177,6 +199,7 @@ export class Shell { private readonly _outputCallback: IOutputCallback private _currentLine: string private _environment: Environment + private _history: History private _fsModule: any private _fileSystem?: IFileSystem diff --git a/tests/history.test.ts b/tests/history.test.ts new file mode 100644 index 0000000..60ae6eb --- /dev/null +++ b/tests/history.test.ts @@ -0,0 +1,49 @@ +import { shell_setup_empty } from "./shell_setup" + +// Not accessing the history object directly, here using the Shell. + +describe("history", () => { + it("should be stored", async () => { + const { shell, output } = await shell_setup_empty() + + await shell._runCommands("cat") + await shell._runCommands("echo") + await shell._runCommands("ls") + output.clear() + + await shell._runCommands("history") + expect(output.text).toEqual(" 0 cat\r\n 1 echo\r\n 2 ls\r\n 3 history\r\n") + }) + + it("should limit storage to max size", async () => { + // TODO: Max size initially hard-coded as 5, will change to use env var. + const { shell, output } = await shell_setup_empty() + + await shell._runCommands("cat") + await shell._runCommands("echo") + await shell._runCommands("ls") + await shell._runCommands("uname") + await shell._runCommands("uniq") + output.clear() + + await shell._runCommands("history") + expect(output.text).toEqual( + " 0 echo\r\n 1 ls\r\n 2 uname\r\n 3 uniq\r\n 4 history\r\n") + }) + + it("should rerun previous command using !index syntax", async () => { + const { shell, output } = await shell_setup_empty() + + await shell._runCommands("cat") + await shell._runCommands("echo hello") + await shell._runCommands("ls") + output.clear() + + await shell._runCommands("!1") + expect(output.text).toEqual("hello\r\n") + }) + + // Need ! out of bounds + + // Need up and down to be tested. +})