init
This commit is contained in:
commit
be0ce1a8b3
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
dist/
|
9
banner.txt
Normal file
9
banner.txt
Normal file
@ -0,0 +1,9 @@
|
||||
BROwser TERMinal. Knock yourself out.
|
||||
|
||||
Here is a list of available commands:
|
||||
touch [file]
|
||||
mkdir [dir]
|
||||
ls [dir]
|
||||
cat [file]
|
||||
pwd
|
||||
echo [text]
|
12
bundle.ts
Normal file
12
bundle.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import * as esbuild from "npm:esbuild";
|
||||
import { denoPlugins } from "jsr:@luca/esbuild-deno-loader";
|
||||
|
||||
await esbuild.build({
|
||||
plugins: [...denoPlugins()],
|
||||
entryPoints: ["./src/main.ts"],
|
||||
outfile: "./dist/bundle.js",
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
});
|
||||
|
||||
esbuild.stop();
|
12
deno.jsonc
Normal file
12
deno.jsonc
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"tasks": {
|
||||
"bundle": "deno run --check --allow-read --allow-write --allow-env --allow-run bundle.ts"
|
||||
},
|
||||
"compilerOptions": {
|
||||
"checkJs": false,
|
||||
"lib": ["dom", "dom.iterable", "dom.asynciterable", "deno.ns"]
|
||||
},
|
||||
"fmt": {
|
||||
"indentWidth": 4
|
||||
}
|
||||
}
|
132
deno.lock
Normal file
132
deno.lock
Normal file
@ -0,0 +1,132 @@
|
||||
{
|
||||
"version": "4",
|
||||
"specifiers": {
|
||||
"jsr:@luca/esbuild-deno-loader@*": "0.11.1",
|
||||
"jsr:@std/bytes@^1.0.2": "1.0.4",
|
||||
"jsr:@std/encoding@^1.0.5": "1.0.5",
|
||||
"jsr:@std/path@^1.0.6": "1.0.8",
|
||||
"npm:esbuild@*": "0.24.0"
|
||||
},
|
||||
"jsr": {
|
||||
"@luca/esbuild-deno-loader@0.11.1": {
|
||||
"integrity": "dc020d16d75b591f679f6b9288b10f38bdb4f24345edb2f5732affa1d9885267",
|
||||
"dependencies": [
|
||||
"jsr:@std/bytes",
|
||||
"jsr:@std/encoding",
|
||||
"jsr:@std/path"
|
||||
]
|
||||
},
|
||||
"@std/bytes@1.0.4": {
|
||||
"integrity": "11a0debe522707c95c7b7ef89b478c13fb1583a7cfb9a85674cd2cc2e3a28abc"
|
||||
},
|
||||
"@std/encoding@1.0.5": {
|
||||
"integrity": "ecf363d4fc25bd85bd915ff6733a7e79b67e0e7806334af15f4645c569fefc04"
|
||||
},
|
||||
"@std/path@1.0.8": {
|
||||
"integrity": "548fa456bb6a04d3c1a1e7477986b6cffbce95102d0bb447c67c4ee70e0364be"
|
||||
}
|
||||
},
|
||||
"npm": {
|
||||
"@esbuild/aix-ppc64@0.24.0": {
|
||||
"integrity": "sha512-WtKdFM7ls47zkKHFVzMz8opM7LkcsIp9amDUBIAWirg70RM71WRSjdILPsY5Uv1D42ZpUfaPILDlfactHgsRkw=="
|
||||
},
|
||||
"@esbuild/android-arm64@0.24.0": {
|
||||
"integrity": "sha512-Vsm497xFM7tTIPYK9bNTYJyF/lsP590Qc1WxJdlB6ljCbdZKU9SY8i7+Iin4kyhV/KV5J2rOKsBQbB77Ab7L/w=="
|
||||
},
|
||||
"@esbuild/android-arm@0.24.0": {
|
||||
"integrity": "sha512-arAtTPo76fJ/ICkXWetLCc9EwEHKaeya4vMrReVlEIUCAUncH7M4bhMQ+M9Vf+FFOZJdTNMXNBrWwW+OXWpSew=="
|
||||
},
|
||||
"@esbuild/android-x64@0.24.0": {
|
||||
"integrity": "sha512-t8GrvnFkiIY7pa7mMgJd7p8p8qqYIz1NYiAoKc75Zyv73L3DZW++oYMSHPRarcotTKuSs6m3hTOa5CKHaS02TQ=="
|
||||
},
|
||||
"@esbuild/darwin-arm64@0.24.0": {
|
||||
"integrity": "sha512-CKyDpRbK1hXwv79soeTJNHb5EiG6ct3efd/FTPdzOWdbZZfGhpbcqIpiD0+vwmpu0wTIL97ZRPZu8vUt46nBSw=="
|
||||
},
|
||||
"@esbuild/darwin-x64@0.24.0": {
|
||||
"integrity": "sha512-rgtz6flkVkh58od4PwTRqxbKH9cOjaXCMZgWD905JOzjFKW+7EiUObfd/Kav+A6Gyud6WZk9w+xu6QLytdi2OA=="
|
||||
},
|
||||
"@esbuild/freebsd-arm64@0.24.0": {
|
||||
"integrity": "sha512-6Mtdq5nHggwfDNLAHkPlyLBpE5L6hwsuXZX8XNmHno9JuL2+bg2BX5tRkwjyfn6sKbxZTq68suOjgWqCicvPXA=="
|
||||
},
|
||||
"@esbuild/freebsd-x64@0.24.0": {
|
||||
"integrity": "sha512-D3H+xh3/zphoX8ck4S2RxKR6gHlHDXXzOf6f/9dbFt/NRBDIE33+cVa49Kil4WUjxMGW0ZIYBYtaGCa2+OsQwQ=="
|
||||
},
|
||||
"@esbuild/linux-arm64@0.24.0": {
|
||||
"integrity": "sha512-TDijPXTOeE3eaMkRYpcy3LarIg13dS9wWHRdwYRnzlwlA370rNdZqbcp0WTyyV/k2zSxfko52+C7jU5F9Tfj1g=="
|
||||
},
|
||||
"@esbuild/linux-arm@0.24.0": {
|
||||
"integrity": "sha512-gJKIi2IjRo5G6Glxb8d3DzYXlxdEj2NlkixPsqePSZMhLudqPhtZ4BUrpIuTjJYXxvF9njql+vRjB2oaC9XpBw=="
|
||||
},
|
||||
"@esbuild/linux-ia32@0.24.0": {
|
||||
"integrity": "sha512-K40ip1LAcA0byL05TbCQ4yJ4swvnbzHscRmUilrmP9Am7//0UjPreh4lpYzvThT2Quw66MhjG//20mrufm40mA=="
|
||||
},
|
||||
"@esbuild/linux-loong64@0.24.0": {
|
||||
"integrity": "sha512-0mswrYP/9ai+CU0BzBfPMZ8RVm3RGAN/lmOMgW4aFUSOQBjA31UP8Mr6DDhWSuMwj7jaWOT0p0WoZ6jeHhrD7g=="
|
||||
},
|
||||
"@esbuild/linux-mips64el@0.24.0": {
|
||||
"integrity": "sha512-hIKvXm0/3w/5+RDtCJeXqMZGkI2s4oMUGj3/jM0QzhgIASWrGO5/RlzAzm5nNh/awHE0A19h/CvHQe6FaBNrRA=="
|
||||
},
|
||||
"@esbuild/linux-ppc64@0.24.0": {
|
||||
"integrity": "sha512-HcZh5BNq0aC52UoocJxaKORfFODWXZxtBaaZNuN3PUX3MoDsChsZqopzi5UupRhPHSEHotoiptqikjN/B77mYQ=="
|
||||
},
|
||||
"@esbuild/linux-riscv64@0.24.0": {
|
||||
"integrity": "sha512-bEh7dMn/h3QxeR2KTy1DUszQjUrIHPZKyO6aN1X4BCnhfYhuQqedHaa5MxSQA/06j3GpiIlFGSsy1c7Gf9padw=="
|
||||
},
|
||||
"@esbuild/linux-s390x@0.24.0": {
|
||||
"integrity": "sha512-ZcQ6+qRkw1UcZGPyrCiHHkmBaj9SiCD8Oqd556HldP+QlpUIe2Wgn3ehQGVoPOvZvtHm8HPx+bH20c9pvbkX3g=="
|
||||
},
|
||||
"@esbuild/linux-x64@0.24.0": {
|
||||
"integrity": "sha512-vbutsFqQ+foy3wSSbmjBXXIJ6PL3scghJoM8zCL142cGaZKAdCZHyf+Bpu/MmX9zT9Q0zFBVKb36Ma5Fzfa8xA=="
|
||||
},
|
||||
"@esbuild/netbsd-x64@0.24.0": {
|
||||
"integrity": "sha512-hjQ0R/ulkO8fCYFsG0FZoH+pWgTTDreqpqY7UnQntnaKv95uP5iW3+dChxnx7C3trQQU40S+OgWhUVwCjVFLvg=="
|
||||
},
|
||||
"@esbuild/openbsd-arm64@0.24.0": {
|
||||
"integrity": "sha512-MD9uzzkPQbYehwcN583yx3Tu5M8EIoTD+tUgKF982WYL9Pf5rKy9ltgD0eUgs8pvKnmizxjXZyLt0z6DC3rRXg=="
|
||||
},
|
||||
"@esbuild/openbsd-x64@0.24.0": {
|
||||
"integrity": "sha512-4ir0aY1NGUhIC1hdoCzr1+5b43mw99uNwVzhIq1OY3QcEwPDO3B7WNXBzaKY5Nsf1+N11i1eOfFcq+D/gOS15Q=="
|
||||
},
|
||||
"@esbuild/sunos-x64@0.24.0": {
|
||||
"integrity": "sha512-jVzdzsbM5xrotH+W5f1s+JtUy1UWgjU0Cf4wMvffTB8m6wP5/kx0KiaLHlbJO+dMgtxKV8RQ/JvtlFcdZ1zCPA=="
|
||||
},
|
||||
"@esbuild/win32-arm64@0.24.0": {
|
||||
"integrity": "sha512-iKc8GAslzRpBytO2/aN3d2yb2z8XTVfNV0PjGlCxKo5SgWmNXx82I/Q3aG1tFfS+A2igVCY97TJ8tnYwpUWLCA=="
|
||||
},
|
||||
"@esbuild/win32-ia32@0.24.0": {
|
||||
"integrity": "sha512-vQW36KZolfIudCcTnaTpmLQ24Ha1RjygBo39/aLkM2kmjkWmZGEJ5Gn9l5/7tzXA42QGIoWbICfg6KLLkIw6yw=="
|
||||
},
|
||||
"@esbuild/win32-x64@0.24.0": {
|
||||
"integrity": "sha512-7IAFPrjSQIJrGsK6flwg7NFmwBoSTyF3rl7If0hNUFQU4ilTsEPL6GuMuU9BfIWVVGuRnuIidkSMC+c0Otu8IA=="
|
||||
},
|
||||
"esbuild@0.24.0": {
|
||||
"integrity": "sha512-FuLPevChGDshgSicjisSooU0cemp/sGXR841D5LHMB7mTVOmsEHcAxaH3irL53+8YDIeVNQEySh4DaYU/iuPqQ==",
|
||||
"dependencies": [
|
||||
"@esbuild/aix-ppc64",
|
||||
"@esbuild/android-arm",
|
||||
"@esbuild/android-arm64",
|
||||
"@esbuild/android-x64",
|
||||
"@esbuild/darwin-arm64",
|
||||
"@esbuild/darwin-x64",
|
||||
"@esbuild/freebsd-arm64",
|
||||
"@esbuild/freebsd-x64",
|
||||
"@esbuild/linux-arm",
|
||||
"@esbuild/linux-arm64",
|
||||
"@esbuild/linux-ia32",
|
||||
"@esbuild/linux-loong64",
|
||||
"@esbuild/linux-mips64el",
|
||||
"@esbuild/linux-ppc64",
|
||||
"@esbuild/linux-riscv64",
|
||||
"@esbuild/linux-s390x",
|
||||
"@esbuild/linux-x64",
|
||||
"@esbuild/netbsd-x64",
|
||||
"@esbuild/openbsd-arm64",
|
||||
"@esbuild/openbsd-x64",
|
||||
"@esbuild/sunos-x64",
|
||||
"@esbuild/win32-arm64",
|
||||
"@esbuild/win32-ia32",
|
||||
"@esbuild/win32-x64"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
13
index.html
Normal file
13
index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<script src="dist/bundle.js" type="module" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<canvas id="term"></canvas>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
21
src/main.ts
Normal file
21
src/main.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { CanvasTerm } from "./term.ts";
|
||||
import { KeyListenerInput } from "./program.ts";
|
||||
import { Shell } from "./shell.ts";
|
||||
import { createRootFileSystem, Session } from "./session.ts";
|
||||
|
||||
async function main() {
|
||||
const term = new CanvasTerm();
|
||||
term.init();
|
||||
|
||||
const input = new KeyListenerInput(term);
|
||||
|
||||
const username = "user";
|
||||
const root = await createRootFileSystem(username);
|
||||
const session = new Session(root, username);
|
||||
session.cd(`/home/${username}`);
|
||||
|
||||
await new Shell()
|
||||
.run({ input, term, session }, []);
|
||||
}
|
||||
|
||||
main();
|
35
src/program.ts
Normal file
35
src/program.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { Session } from "./session.ts";
|
||||
import { KeyListener, Term } from "./term.ts";
|
||||
|
||||
export interface Input {
|
||||
getChar(): Promise<string>;
|
||||
}
|
||||
|
||||
export class KeyListenerInput implements Input {
|
||||
public constructor(
|
||||
private keyListener: KeyListener,
|
||||
) {}
|
||||
|
||||
getChar(): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
const id = setInterval(() => {
|
||||
if (this.keyListener.hasPressedKey()) {
|
||||
const key = this.keyListener.nextPress();
|
||||
resolve(key);
|
||||
clearInterval(id);
|
||||
return;
|
||||
}
|
||||
}, 10);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export type IO = {
|
||||
input: Input;
|
||||
term: Term;
|
||||
session: Session;
|
||||
};
|
||||
|
||||
export interface Program {
|
||||
run(io: IO, args: string[]): Promise<number>;
|
||||
}
|
5
src/result.ts
Normal file
5
src/result.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export type Result<V, E> = Ok<V> | Err<E>;
|
||||
export type Ok<V> = { ok: true; value: V };
|
||||
export type Err<E> = { ok: false; error: E };
|
||||
export const Ok = <V>(value: V): Ok<V> => ({ ok: true, value });
|
||||
export const Err = <E>(error: E): Err<E> => ({ ok: false, error });
|
229
src/session.ts
Normal file
229
src/session.ts
Normal file
@ -0,0 +1,229 @@
|
||||
import { Err, Ok, Result } from "./result.ts";
|
||||
|
||||
export async function createRootFileSystem(username: string): Promise<Dir> {
|
||||
const root: Dir = {
|
||||
name: "/",
|
||||
dirs: dirChildren({
|
||||
"home": {
|
||||
name: "home",
|
||||
dirs: dirChildren({
|
||||
[username]: {
|
||||
name: username,
|
||||
dirs: dirChildren({}),
|
||||
files: fileChildren({
|
||||
"banner.txt": await fetch("banner.txt").then(
|
||||
(res) => res.text(),
|
||||
),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
files: new Map(),
|
||||
},
|
||||
}),
|
||||
files: new Map(),
|
||||
};
|
||||
|
||||
reverseOrphanDirTree(root);
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
export type File = {
|
||||
name: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type Dir = {
|
||||
name: string;
|
||||
parent?: Dir;
|
||||
dirs: Map<string, Dir>;
|
||||
files: Map<string, string>;
|
||||
};
|
||||
|
||||
export function dirChildren(
|
||||
children: { [key: string]: Dir },
|
||||
): Map<string, Dir> {
|
||||
const map = new Map();
|
||||
|
||||
for (const key in children) {
|
||||
map.set(key, children[key]);
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
export function fileChildren(
|
||||
children: { [key: string]: string },
|
||||
): Map<string, string> {
|
||||
const map = new Map();
|
||||
|
||||
for (const key in children) {
|
||||
map.set(key, children[key]);
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
export function reverseOrphanDirTree(node: Dir, parent?: Dir) {
|
||||
node.parent = parent;
|
||||
for (const child of node.dirs.values()) {
|
||||
reverseOrphanDirTree(child, node);
|
||||
}
|
||||
}
|
||||
|
||||
export function fullDirPathString(node: Dir): string {
|
||||
if (!node.parent) {
|
||||
return "";
|
||||
}
|
||||
return `${fullDirPathString(node.parent)}/${node.name}`;
|
||||
}
|
||||
|
||||
export class Session {
|
||||
private cwdDir: Dir;
|
||||
|
||||
constructor(
|
||||
private root: Dir,
|
||||
private username: string,
|
||||
) {
|
||||
this.cwdDir = root;
|
||||
}
|
||||
|
||||
public cd(path: string): Result<undefined, string> {
|
||||
if (path === "") {
|
||||
this.cwdDir = this.userDir();
|
||||
return Ok(undefined);
|
||||
}
|
||||
|
||||
if (path === "/") {
|
||||
this.cwdDir = this.root;
|
||||
return Ok(undefined);
|
||||
}
|
||||
|
||||
const res = this.getNodeFromPath(this.cwdDir, path);
|
||||
if (!res.ok) {
|
||||
return Err(`${path}: No such file or directory`);
|
||||
}
|
||||
|
||||
this.cwdDir = res.value;
|
||||
|
||||
return Ok(undefined);
|
||||
}
|
||||
|
||||
public mkdir(dirname: string): Result<undefined, string> {
|
||||
if (this.dirOrFileExists(dirname)) {
|
||||
return Err(`cannot create directory '${dirname}': File exists`);
|
||||
}
|
||||
|
||||
this.cwdDir.dirs.set(dirname, {
|
||||
name: dirname,
|
||||
dirs: dirChildren({}),
|
||||
parent: this.cwdDir,
|
||||
files: new Map(),
|
||||
});
|
||||
|
||||
return Ok(undefined);
|
||||
}
|
||||
public touch(filename: string): Result<undefined, string> {
|
||||
if (this.dirOrFileExists(filename)) {
|
||||
return Err(`cannot create directory '${filename}': File exists`);
|
||||
}
|
||||
|
||||
this.cwdDir.files.set(filename, "");
|
||||
|
||||
return Ok(undefined);
|
||||
}
|
||||
|
||||
public cat(filename: string): Result<string, string> {
|
||||
const content = this.cwdDir.files.get(filename);
|
||||
if (content === undefined) {
|
||||
return Err(`"${filename}": No such file or directory`);
|
||||
}
|
||||
|
||||
return Ok(content);
|
||||
}
|
||||
|
||||
public listFiles(path?: string): Result<string, string> {
|
||||
let dir: Dir;
|
||||
if (path) {
|
||||
const res = this.getNodeFromPath(this.cwdDir, path);
|
||||
|
||||
if (!res.ok) {
|
||||
return Err(`"${path}": No such file or directory`);
|
||||
}
|
||||
dir = res.value;
|
||||
} else {
|
||||
dir = this.cwdDir;
|
||||
}
|
||||
|
||||
return Ok(
|
||||
[
|
||||
...dir.dirs.keys(),
|
||||
...dir.files.keys(),
|
||||
]
|
||||
.toSorted().join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
public cwd(): string {
|
||||
return fullDirPathString(this.cwdDir);
|
||||
}
|
||||
|
||||
public cwdString(): string {
|
||||
const val = this.cwd();
|
||||
if (val === "") {
|
||||
return "/";
|
||||
}
|
||||
return val.replace(new RegExp(`^/home/${this.username}`), "~");
|
||||
}
|
||||
|
||||
public dirOrFileExists(name: string): boolean {
|
||||
return this.cwdDir.dirs.has(name) || this.cwdDir.files.has(name);
|
||||
}
|
||||
|
||||
private getNodeFromPath(dir: Dir, path: string): Result<Dir, undefined> {
|
||||
const segments = lexPath(path);
|
||||
|
||||
let node = path.startsWith("/") ? this.root : dir;
|
||||
for (const segment of segments) {
|
||||
const res = this.getNodeFromPathSegment(node, segment);
|
||||
if (!res.ok) {
|
||||
return Err(undefined);
|
||||
}
|
||||
node = res.value;
|
||||
}
|
||||
|
||||
return Ok(node);
|
||||
}
|
||||
|
||||
private getNodeFromPathSegment(
|
||||
dir: Dir,
|
||||
segment: string,
|
||||
): Result<Dir, undefined> {
|
||||
if (segment === ".") {
|
||||
return Ok(dir);
|
||||
}
|
||||
if (segment === "..") {
|
||||
if (!dir.parent) {
|
||||
return Ok(dir);
|
||||
}
|
||||
return Ok(dir.parent);
|
||||
}
|
||||
if (segment === "~") {
|
||||
return Ok(this.userDir());
|
||||
}
|
||||
if (!dir.dirs.has(segment)) {
|
||||
return Err(undefined);
|
||||
}
|
||||
return Ok(dir.dirs.get(segment)!);
|
||||
}
|
||||
|
||||
private userDir(): Dir {
|
||||
return this.root
|
||||
.dirs.get("home")!
|
||||
.dirs.get("guest")!;
|
||||
}
|
||||
}
|
||||
|
||||
function lexPath(text: string): string[] {
|
||||
return text.split("/").filter((v) => v !== "");
|
||||
}
|
164
src/shell.ts
Normal file
164
src/shell.ts
Normal file
@ -0,0 +1,164 @@
|
||||
import { Err, Ok, Result } from "./result.ts";
|
||||
import { IO, Program } from "./program.ts";
|
||||
import { Session } from "./session.ts";
|
||||
|
||||
export class Shell implements Program {
|
||||
private session!: Session;
|
||||
|
||||
async run(io: IO, _args: string[]): Promise<number> {
|
||||
this.session = io.session;
|
||||
|
||||
while (true) {
|
||||
const dir = this.session.cwdString();
|
||||
const prompt = "user" + "@term:" + dir + "$ ";
|
||||
io.term.print(prompt);
|
||||
|
||||
let line = "";
|
||||
while (true) {
|
||||
const ch = await io.input.getChar();
|
||||
if (ch === "\n") {
|
||||
io.term.print(ch);
|
||||
break;
|
||||
}
|
||||
if (ch === "\b") {
|
||||
io.term.print("\\b");
|
||||
line = line.slice(0, line.length - 1);
|
||||
continue;
|
||||
}
|
||||
io.term.print(ch);
|
||||
line += ch;
|
||||
}
|
||||
if (line === "") {
|
||||
continue;
|
||||
}
|
||||
const res = this.lexLine(line);
|
||||
if (!res.ok) {
|
||||
io.term.print(`error: ${res.error}`);
|
||||
continue;
|
||||
}
|
||||
const [cmd, ...args] = res.value;
|
||||
const resText = this.runCommand(cmd, ...args);
|
||||
console.log({ resText });
|
||||
if (resText !== "") {
|
||||
io.term.print(`${resText}\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private runCommand(...args: string[]) {
|
||||
const session = this.session;
|
||||
|
||||
switch (args[0]) {
|
||||
case "":
|
||||
return "";
|
||||
case "pwd":
|
||||
return session.cwd();
|
||||
case "cd": {
|
||||
if (args.length > 2) {
|
||||
return "cd: too many arguments";
|
||||
}
|
||||
|
||||
const res = session.cd(args[1]);
|
||||
if (!res.ok) {
|
||||
return `cd: ${res.error}`;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
case "mkdir": {
|
||||
if (args.length === 1) {
|
||||
return "mkdir: missing operand";
|
||||
}
|
||||
|
||||
for (const dir of args.slice(1)) {
|
||||
const res = session.mkdir(dir);
|
||||
if (!res.ok) {
|
||||
return `mkdir: ${res.error}`;
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
case "ls": {
|
||||
if (args.length === 1) {
|
||||
const res = session.listFiles();
|
||||
if (!res.ok) {
|
||||
return res.error;
|
||||
}
|
||||
return res.value;
|
||||
}
|
||||
return args.slice(1)
|
||||
.map((arg) => {
|
||||
const res = session.listFiles(arg);
|
||||
if (!res.ok) {
|
||||
return res.error;
|
||||
}
|
||||
return res.value;
|
||||
}).join("\n");
|
||||
}
|
||||
case "touch": {
|
||||
if (args.length === 1) {
|
||||
return "touch: missing file operand";
|
||||
}
|
||||
for (const fn of args.slice(1)) {
|
||||
session.touch(fn);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
case "cat": {
|
||||
if (args.length === 1) {
|
||||
return "cat: missing file operand";
|
||||
}
|
||||
return args.slice(1).map((v) => {
|
||||
const r = session.cat(v);
|
||||
return r.ok ? r.value : r.error;
|
||||
}).reduce((acc, v) => acc + "\n" + v);
|
||||
}
|
||||
case "echo": {
|
||||
if (args.length === 1) {
|
||||
return "\n";
|
||||
}
|
||||
return args[1];
|
||||
}
|
||||
default:
|
||||
return `${args[0]}: Command not found`;
|
||||
}
|
||||
}
|
||||
|
||||
private lexLine(text: string): Result<string[], string> {
|
||||
const segs: string[] = [];
|
||||
let seg = "";
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
let terminator: string = "";
|
||||
for (const ch of text) {
|
||||
if (!escaped && ch === "\\") {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (inString) {
|
||||
if (!escaped && ch === terminator) {
|
||||
inString = false;
|
||||
} else {
|
||||
seg += ch;
|
||||
}
|
||||
} else if (/["']/.test(ch)) {
|
||||
terminator = ch;
|
||||
inString = true;
|
||||
} else if (ch === " ") {
|
||||
segs.push(seg);
|
||||
seg = "";
|
||||
} else {
|
||||
seg += ch;
|
||||
}
|
||||
escaped = false;
|
||||
}
|
||||
if (escaped || inString) {
|
||||
return Err("malformed");
|
||||
}
|
||||
if (seg !== "") {
|
||||
segs.push(seg);
|
||||
}
|
||||
return Ok(segs);
|
||||
}
|
||||
}
|
168
src/term.ts
Normal file
168
src/term.ts
Normal file
@ -0,0 +1,168 @@
|
||||
export type Point = { x: number; y: number };
|
||||
export const Point = (x: number, y: number): Point => ({ x, y });
|
||||
|
||||
export interface Term {
|
||||
size(): Point;
|
||||
cursor(): Point;
|
||||
setCursor(point: Point): void;
|
||||
clear(): void;
|
||||
write(text: string): void;
|
||||
print(text: string): void;
|
||||
}
|
||||
|
||||
export interface KeyListener {
|
||||
hasPressedKey(): boolean;
|
||||
nextPress(): string;
|
||||
}
|
||||
|
||||
export class CanvasTerm implements Term, KeyListener {
|
||||
private canvas!: HTMLCanvasElement;
|
||||
private cx!: CanvasRenderingContext2D;
|
||||
|
||||
private _size: Point = { x: 80, y: 24 };
|
||||
private _cursor: Point = { x: 0, y: 0 };
|
||||
|
||||
private fontSize!: Point;
|
||||
|
||||
private keysPressed: string[] = [];
|
||||
|
||||
private buffer = new Array<string>(80 * 24).fill(" ");
|
||||
|
||||
public constructor() {}
|
||||
|
||||
public init() {
|
||||
this.canvas = document.querySelector<HTMLCanvasElement>("#term")!;
|
||||
this.cx = this.canvas.getContext("2d")!;
|
||||
|
||||
const fontHeight = 28;
|
||||
|
||||
this.cx.font = `${fontHeight}px monospace`;
|
||||
|
||||
this.fontSize = Point(
|
||||
this.cx.measureText("a").width,
|
||||
fontHeight,
|
||||
);
|
||||
|
||||
const w = this._size.x * this.fontSize.x;
|
||||
const h = this._size.y * this.fontSize.y;
|
||||
|
||||
this.canvas.width = w;
|
||||
this.canvas.height = h;
|
||||
|
||||
//this.canvas.style.width = w.toString();
|
||||
//this.canvas.style.height = h.toString();
|
||||
|
||||
this.cx.fillStyle = "black";
|
||||
this.cx.fillRect(0, 0, w, h);
|
||||
|
||||
document.body
|
||||
.addEventListener("keydown", (ev) => {
|
||||
switch (ev.key) {
|
||||
case "Enter":
|
||||
this.keysPressed.push("\n");
|
||||
break;
|
||||
case "Backspace":
|
||||
this.keysPressed.push("\b");
|
||||
break;
|
||||
case "Shift":
|
||||
case "Control":
|
||||
case "Alt":
|
||||
case "AltGraph":
|
||||
break;
|
||||
case "/":
|
||||
case "'":
|
||||
ev.preventDefault();
|
||||
this.keysPressed.push(ev.key);
|
||||
break;
|
||||
default:
|
||||
this.keysPressed.push(ev.key);
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
size(): Point {
|
||||
return this._size;
|
||||
}
|
||||
|
||||
cursor(): Point {
|
||||
return this._cursor;
|
||||
}
|
||||
|
||||
setCursor(point: Point): void {
|
||||
this._cursor.x = point.x;
|
||||
this._cursor.y = point.y;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.cx.fillStyle = "black";
|
||||
this.cx.fillRect(
|
||||
0,
|
||||
0,
|
||||
this._cursor.x * this.fontSize.x,
|
||||
this._cursor.y * this.fontSize.y,
|
||||
);
|
||||
}
|
||||
|
||||
private writeChar(ch: string) {
|
||||
this.buffer[this._cursor.y * 80 + this._cursor.x] = ch;
|
||||
}
|
||||
|
||||
private writeBuffer() {
|
||||
this.cx.fillStyle = "black";
|
||||
this.cx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
|
||||
this.cx.fillStyle = "white";
|
||||
this.cx.font = `28px monospace`;
|
||||
for (let y = 0; y < 24; ++y) {
|
||||
const line = this.buffer.slice(y * 80, y * 80 + 80).join("");
|
||||
|
||||
this.cx.fillText(
|
||||
line,
|
||||
0,
|
||||
y * this.fontSize.y + this.fontSize.y,
|
||||
);
|
||||
}
|
||||
this.cx.fillRect(
|
||||
this._cursor.x * this.fontSize.x,
|
||||
this._cursor.y * this.fontSize.y,
|
||||
this.fontSize.x,
|
||||
this.fontSize.y,
|
||||
);
|
||||
}
|
||||
|
||||
write(text: string): void {
|
||||
const line = text.split("\n")[0].slice(
|
||||
0,
|
||||
this._size.x - this._cursor.x,
|
||||
);
|
||||
for (const ch of line) {
|
||||
this.writeChar(ch);
|
||||
this._cursor.x += 1;
|
||||
}
|
||||
this.writeBuffer();
|
||||
}
|
||||
|
||||
print(text: string): void {
|
||||
const size = this.size();
|
||||
for (const ch of text) {
|
||||
const cursor = this.cursor();
|
||||
if (cursor.x >= size.x || ch === "\n") {
|
||||
this.setCursor(Point(0, cursor.y + 1));
|
||||
} else {
|
||||
this.write(ch);
|
||||
}
|
||||
}
|
||||
this.writeBuffer();
|
||||
}
|
||||
|
||||
hasPressedKey(): boolean {
|
||||
return this.keysPressed.length > 0;
|
||||
}
|
||||
|
||||
nextPress(): string {
|
||||
const key = this.keysPressed[0];
|
||||
this.keysPressed = this.keysPressed.slice(1);
|
||||
return key;
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user