This commit is contained in:
sfja 2025-02-02 23:35:33 +01:00
commit be0ce1a8b3
13 changed files with 817 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
dist/

9
banner.txt Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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;
}
}

16
style.css Normal file
View File

@ -0,0 +1,16 @@
* {
box-sizing: border-box;
}
:root {
color-scheme: dark;
}
body {
margin: 0;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
font-family: "Roboto Mono";
}