Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add storage of command history #15

Merged
merged 1 commit into from
Jul 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions src/commands/builtin_command_runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,18 @@ import { Context } from "../context"

export class BuiltinCommandRunner implements ICommandRunner {
names(): string[] {
return ["cd"]
return ["cd", "history"]
}

async run(cmdName: string, context: Context): Promise<void> {
switch (cmdName) {
case "cd":
this._cd(context)
break
}
case "history":
await this._history(context)
break
}
}

private _cd(context: Context) {
Expand All @@ -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)
}
}
10 changes: 4 additions & 6 deletions src/context.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Environment } from "./environment"
import { History } from "./history"
import { IFileSystem } from "./file_system"
import { IInput, IOutput } from "./io"

Expand All @@ -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<void> {
await this.stdout.flush()
}

environment: Environment
}
55 changes: 55 additions & 0 deletions src/history.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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
}
33 changes: 28 additions & 5 deletions src/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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<void> {
// Might be a multi-char string if begins with escape code.
const code = char.charCodeAt(0)
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -102,6 +110,20 @@ export class Shell {

// Keeping this public for tests.
async _runCommands(cmdText: string): Promise<void> {
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 {
Expand Down Expand Up @@ -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)

Expand All @@ -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
Expand Down
49 changes: 49 additions & 0 deletions tests/history.test.ts
Original file line number Diff line number Diff line change
@@ -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.
})
Loading