From 4c67f2aee33af69d1c33817b4997c149a8c1d5e3 Mon Sep 17 00:00:00 2001 From: binekrasik Date: Fri, 15 May 2026 14:16:11 +0200 Subject: [PATCH] feat: working filesystem --- .zed/settings.json | 8 + src/app.ts | 4 +- src/fs/Item.ts | 286 +++++++++++++++++++++++++--------- src/program/Cat.ts | 28 ++++ src/program/Echo.ts | 27 ++++ src/program/Ls.ts | 21 ++- src/program/Lsprg.ts | 6 +- src/program/Mkdir.ts | 23 +++ src/program/ResetIndexedDb.ts | 44 ++++++ src/program/Rl.ts | 15 ++ src/program/Rm.ts | 35 +++++ src/program/Touch.ts | 8 +- src/shell/Shell.ts | 2 +- src/shell/Wush.ts | 33 +++- src/terminal/Terminal.ts | 4 +- 15 files changed, 449 insertions(+), 95 deletions(-) create mode 100644 .zed/settings.json create mode 100644 src/program/Cat.ts create mode 100644 src/program/Echo.ts create mode 100644 src/program/Mkdir.ts create mode 100644 src/program/ResetIndexedDb.ts create mode 100644 src/program/Rl.ts diff --git a/.zed/settings.json b/.zed/settings.json new file mode 100644 index 0000000..c9400b1 --- /dev/null +++ b/.zed/settings.json @@ -0,0 +1,8 @@ +// Folder-specific settings +// +// For a full list of overridable settings, and general information on folder-specific settings, +// see the documentation: https://zed.dev/docs/configuring-zed#settings-files +{ + "tab_size": 4, + "format_on_save": "off", +} diff --git a/src/app.ts b/src/app.ts index 8b990b6..4251edf 100644 --- a/src/app.ts +++ b/src/app.ts @@ -41,7 +41,7 @@ export const SetCurrentTerminal = (terminal: Terminal) => CurrentTerminal = term export const GetCurrentTerminal = (): Terminal => CurrentTerminal // Initializes the app -const init = () => { +const init = async () => { const localBroadcaster = new EventBroadcaster() // creates keyboard listeners for the local event broadcaster @@ -51,5 +51,5 @@ const init = () => { ) const terminal = new Terminal() - terminal.LoadShell(new Wush(localBroadcaster, terminal)) + await terminal.LoadShell(new Wush(localBroadcaster, terminal)) } diff --git a/src/fs/Item.ts b/src/fs/Item.ts index d7079d5..4e83c25 100644 --- a/src/fs/Item.ts +++ b/src/fs/Item.ts @@ -1,131 +1,211 @@ import { GetWebfsDatabase } from "../app" +interface ItemPayload { + isDirectory: boolean + executable: boolean + data: string | null + children: string[] +} + export class Item { private path: string private isDirectory: boolean = false private executable: boolean = false private data: string | null = null - private children: string[] = [] - constructor(path: string, data?: string) { + private constructor(path: string) { this.path = this.normalizePath(path) - - if (this.Exists()) { - this.load() - } else { - this.data = data !== undefined ? data : null - this.isDirectory = data === undefined - } } - static get Root(): Item { + // ------------------------------------------------------------------------- + // Static factories + // ------------------------------------------------------------------------- + + /** + * Opens an existing file or prepares a new one in memory. + * Call Create() afterwards if the item does not yet exist. + * Use openDir() to prepare a directory instead. + * Throws if the path exists but is a directory. + * Note: initialData is ignored (with a warning) if the file already exists. + */ + static async open(path: string, initialData: string | null = null): Promise { + const item = new Item(path) + if (await item.Exists()) { + await item.load() + if (item.isDirectory) { + throw new Error(`Path is a directory, not a file: ${path}`) + } + if (initialData !== null) { + console.warn(`Item.open(): initialData ignored because file already exists: ${path}`) + } + } else { + item.data = initialData + item.isDirectory = false + } + return item + } + + /** + * Opens an existing directory or prepares a new one in memory. + * Call Create() afterwards if the item does not yet exist. + * Throws if the path exists but is a file. + */ + static async openDir(path: string): Promise { + const item = new Item(path) + if (await item.Exists()) { + await item.load() + if (!item.isDirectory) { + throw new Error(`Path is a file, not a directory: ${path}`) + } + } else { + item.data = null + item.isDirectory = true + } + return item + } + + /** + * Returns the root directory, creating it if it doesn't exist yet. + */ + static async Root(): Promise { const root = new Item('/') - if (!root.Exists()) { - root.Create() + if (!(await root.Exists())) { + root.isDirectory = true + await root.Create() + } else { + await root.load() } return root } - Create(): void { - if (this.Exists()) throw new Error("File already exists") + // ------------------------------------------------------------------------- + // Public API + // ------------------------------------------------------------------------- - this.save() + async Create(): Promise { + if (await this.Exists()) throw new Error(`File already exists: ${this.path}`) + + await this.save() if (this.path !== '/') { const parentPath = this.getParentPath() - const parent = new Item(parentPath) + const parent = await Item.openDir(parentPath) - // recursively creates parent directories - if (!parent.Exists()) { - parent.Create() + if (!(await parent.Exists())) { + await parent.Create() + // Reload the parent after recursive Create() to get the + // up-to-date children list before we append to it, so we + // don't overwrite any children that Create() already added. + await parent.load() } - // adds this item to the parent's children list and saves the parent if (!parent.children.includes(this.path)) { parent.children.push(this.path) - parent.save() + await parent.save() } } } - Exists(): boolean { - // localStorage.getItem(this.storageKey) !== null - const store = GetWebfsDatabase()! - .transaction("webfs", "readonly") - .objectStore("webfs") + async Exists(): Promise { + const db = GetWebfsDatabase() + if (!db) throw new Error("WebFS database is not initialized") - return Boolean(store.getKey(this.storageKey)) + return new Promise((resolve, reject) => { + const request = db + .transaction("webfs", "readonly") + .objectStore("webfs") + .getKey(this.storageKey) + + request.onsuccess = () => resolve(request.result !== undefined) + request.onerror = () => reject(request.error) + }) } IsDirectory(): boolean { return this.isDirectory } - Append(data: string): void { - if (this.isDirectory) { - throw new Error("Cannot append data to a directory") - } - this.data = (this.data || '') + data - this.save() + async Append(data: string): Promise { + if (!(await this.Exists())) throw new Error(`Cannot append to a file that has not been created: ${this.path}`) + if (this.isDirectory) throw new Error("Cannot append data to a directory") + this.data = (this.data ?? '') + data + await this.save() } - Write(data: string): void { - if (this.isDirectory) - throw new Error("Cannot write data to a directory") - + async Write(data: string): Promise { + if (!(await this.Exists())) throw new Error(`Cannot write to a file that has not been created: ${this.path}`) + if (this.isDirectory) throw new Error("Cannot write data to a directory") this.data = data - this.save() + await this.save() } - SetExecutable(executable: boolean): void { + /** + * Sets the executable flag. Only takes effect if the item has already + * been created in the filesystem (i.e. Create() has been called). + */ + async SetExecutable(executable: boolean): Promise { this.executable = executable - - // update the item if it already exists in the filesystem - if (this.Exists()) - this.save() + if (await this.Exists()) await this.save() } GetPath(): string { return this.path } + /** + * Returns just the filename/directory name, not the full path. + */ GetName(): string { - return this.path + return this.path === '/' ? '/' : this.path.substring(this.path.lastIndexOf('/') + 1) } ReadData(): string | null { return this.data } - List(): Item[] { - if (!this.isDirectory) - throw new Error(`Not a directory: ${this.path}`) + async List(): Promise { + if (!this.isDirectory) throw new Error(`Not a directory: ${this.path}`) - // grab all the children - return this.children.map(childPath => new Item(childPath)) + // Always reload from store to ensure children list is current. + await this.load() + + const items = await Promise.all( + this.children.map(async childPath => { + const child = new Item(childPath) + if (!(await child.Exists())) return null + await child.load() + return child + }) + ) + return items.filter((item): item is Item => item !== null) } - Delete(): void { - if (!this.Exists()) return + async Delete(): Promise { + if (!(await this.Exists())) return + + // Reload from store to ensure we have the current state, + // particularly the up-to-date children list for directories. + await this.load() - // delete children recursively if this item is a directory if (this.isDirectory) { - this.List().forEach(child => child.Delete()) + const children = await this.List() + await Promise.all(children.map(child => child.Delete())) } - // clear all traces - localStorage.removeItem(this.storageKey) + await this.remove() if (this.path !== '/') { - const parent = new Item(this.getParentPath()) - if (parent.Exists()) { - parent.children = parent.children.filter(path => path !== this.path) - parent.save() - } + const parent = await Item.openDir(this.getParentPath()) + parent.children = parent.children.filter(p => p !== this.path) + await parent.save() } } + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + private get storageKey(): string { return this.path } @@ -144,32 +224,90 @@ export class Item { : this.path.substring(0, lastSlashIndex) } + private db(): IDBDatabase { + const db = GetWebfsDatabase() + if (!db) throw new Error("WebFS database is not initialized") + return db + } + /** - * Saves the item to the filesystem + * Persists this item to IndexedDB. + * Enforces that the isDirectory flag cannot be changed after creation. */ - private save(): void { - const payload = { + private async save(): Promise { + // Guard against flipping isDirectory on an already-created item. + if (await this.Exists()) { + const request = this.db() + .transaction("webfs", "readonly") + .objectStore("webfs") + .get(this.storageKey) + + const stored = await new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result as ItemPayload | undefined) + request.onerror = () => reject(request.error) + }) + + if (stored && stored.isDirectory !== this.isDirectory) { + throw new Error( + `Cannot change isDirectory flag after creation: ${this.path}` + ) + } + } + + const payload: ItemPayload = { isDirectory: this.isDirectory, executable: this.executable, data: this.data, children: this.children, } - window.localStorage.setItem(this.storageKey, JSON.stringify(payload)) + return new Promise((resolve, reject) => { + const request = this.db() + .transaction("webfs", "readwrite") + .objectStore("webfs") + .put(payload, this.storageKey) + + request.onsuccess = () => resolve() + request.onerror = () => reject(request.error) + }) } /** - * Loads data associated with the item from the filesystem + * Loads this item's data from IndexedDB into memory. */ - private load(): void { - const raw = localStorage.getItem(this.storageKey) + private load(): Promise { + return new Promise((resolve, reject) => { + const request = this.db() + .transaction("webfs", "readonly") + .objectStore("webfs") + .get(this.storageKey) - if (raw) { - const parsed = JSON.parse(raw) - this.isDirectory = parsed.isDirectory - this.executable = parsed.executable - this.data = parsed.data - this.children = parsed.children - } + request.onsuccess = () => { + const parsed = request.result as ItemPayload | undefined + if (parsed) { + this.isDirectory = parsed.isDirectory + this.executable = parsed.executable + this.data = parsed.data + this.children = parsed.children + } + resolve() + } + request.onerror = () => reject(request.error) + }) + } + + /** + * Deletes this item's record from IndexedDB. + */ + private remove(): Promise { + return new Promise((resolve, reject) => { + const request = this.db() + .transaction("webfs", "readwrite") + .objectStore("webfs") + .delete(this.storageKey) + + request.onsuccess = () => resolve() + request.onerror = () => reject(request.error) + }) } } diff --git a/src/program/Cat.ts b/src/program/Cat.ts new file mode 100644 index 0000000..cc2f74f --- /dev/null +++ b/src/program/Cat.ts @@ -0,0 +1,28 @@ +import { Item } from '../fs/Item' +import type { SimpleStream } from '../utils/SimpleStream' +import { Program } from './Program' + +export class Cat extends Program { + constructor() { + super() + } + + async Exec(_: SimpleStream, stdout: SimpleStream, __: Item, args: string[]): Promise { + let item: Item = await Item.open(args[1]) + + if (!(await item.Exists())) { + stdout.emit(`cat: error: item ${item.GetPath()} doesn't exist.\n`) + return 1 + } + + if (item.IsDirectory()) { + stdout.emit(`cat: error: can't read data from a directory; item ${item.GetPath()} is a directory.\n`) + return 2 + } + + stdout.emit(`${item.ReadData()}`) + stdout.emit('[ EOF ]\n') + + return 0 + } +} diff --git a/src/program/Echo.ts b/src/program/Echo.ts new file mode 100644 index 0000000..40a19d6 --- /dev/null +++ b/src/program/Echo.ts @@ -0,0 +1,27 @@ +import { Item } from '../fs/Item' +import type { SimpleStream } from '../utils/SimpleStream' +import { Program } from './Program' + +export class Echo extends Program { + constructor() { + super() + } + + async Exec(_: SimpleStream, stdout: SimpleStream, ___: Item, args: string[]): Promise { + let item: Item = await Item.open(args[1]) + + if (!(await item.Exists())) { + stdout.emit(`echo: error: item ${item.GetPath()} doesn't exist.\n`) + return 1 + } + + if (item.IsDirectory()) { + stdout.emit(`echo: error: can't write data to a directory; item ${item.GetPath()} is a directory.\n`) + return 2 + } + + await item.Write(args.slice(2).join(' ')) + + return 0 + } +} diff --git a/src/program/Ls.ts b/src/program/Ls.ts index e2d82fd..e5a63f0 100644 --- a/src/program/Ls.ts +++ b/src/program/Ls.ts @@ -8,15 +8,24 @@ export class Ls extends Program { } async Exec(_: SimpleStream, stdout: SimpleStream, workdir: Item, args: string[]): Promise { - if (!(new Item(args[1]).IsDirectory())) { - stdout.emit("ls: error: the provided path is not a directory") + let item: Item = args[1] ? await Item.openDir(args[1]) : workdir + if (args[1] && !item.IsDirectory()) { + stdout.emit("ls: error: the provided path is not a directory\n") return 1 } - stdout.emit(`item: '${workdir.GetPath()}'\n`) - stdout.emit(`index attr name\n`) - workdir.List().forEach((entry, i) => { - stdout.emit(`${i.toString().padEnd(8, ' ')} ${''.padEnd(4, '-').padEnd(8, ' ')} '${entry.GetName()}'\n`) + if (!(await item.Exists())) { + stdout.emit(`ls: error: path ${item.GetPath()} doesn't exist\n`) + return 2 + } + + console.log(item) + + stdout.emit(`-> Listing contents of item: '${item.GetPath()}'\n`) + stdout.emit(` [ attr name ]\n`) + const items = await item.List() + items.forEach((entry, i) => { + stdout.emit(` | ${''.padEnd(4, '-').padEnd(8, ' ')} ${entry.GetName()}\n`) }) return 0 diff --git a/src/program/Lsprg.ts b/src/program/Lsprg.ts index f9f16ad..4d23efa 100644 --- a/src/program/Lsprg.ts +++ b/src/program/Lsprg.ts @@ -12,8 +12,12 @@ export class Lsprg extends Program { } async Exec(_: SimpleStream, stdout: SimpleStream, __: Item, ___: string[]): Promise { + const programs = this.wush.GetPrograms() + + stdout.emit(`-> ${Object.keys(programs).length} loaded programs:\n`) + for (const program in this.wush.GetPrograms()) - stdout.emit(`${program}\n`) + stdout.emit(` | - ${program}\n`) return 0 } diff --git a/src/program/Mkdir.ts b/src/program/Mkdir.ts new file mode 100644 index 0000000..10b387a --- /dev/null +++ b/src/program/Mkdir.ts @@ -0,0 +1,23 @@ +import { Item } from '../fs/Item' +import type { SimpleStream } from '../utils/SimpleStream' +import { Program } from './Program' + +export class Mkdir extends Program { + constructor() { + super() + } + + async Exec(_: SimpleStream, stdout: SimpleStream, workdir: Item, args: string[]): Promise { + const item = await Item.openDir(args[1]) + stdout.emit(`does ${args[1]} exist? ${await item.Exists()}\n`) + + if (await item.Exists()) { + stdout.emit(`mkdir: directory ${item.GetPath()} already exists.\n`) + return 1 + } + + item.Create() + + return 0 + } +} diff --git a/src/program/ResetIndexedDb.ts b/src/program/ResetIndexedDb.ts new file mode 100644 index 0000000..272ba9f --- /dev/null +++ b/src/program/ResetIndexedDb.ts @@ -0,0 +1,44 @@ +import { GetWebfsDatabase } from '../app' +import { Item } from '../fs/Item' +import type { SimpleStream } from '../utils/SimpleStream' +import { Program } from './Program' + +export class ResetIndexedDb extends Program { + constructor() { + super() + } + + async Exec(_: SimpleStream, stdout: SimpleStream, ___: Item, ____: string[]): Promise { + const db = GetWebfsDatabase() + + if (!db) { + stdout.emit("rsindb: error: GetWebfsDatabase returned null") + return 1 + } + + return new Promise(resolve => { + const request = indexedDB.deleteDatabase(db.name) + + request.onerror = () => { + stdout.emit("rsindb: error: IndexedDB deletion request has failed\n") + resolve(2) + } + + request.onblocked = () => { + stdout.emit("rsindb: debug: database open, closing connection\n") + db.close() + } + + request.onupgradeneeded = () => + stdout.emit("rsindb: debug: request upgrade needed\n") + + request.onsuccess = () => { + stdout.emit("success\n") + + resolve(0) + + location.reload() + } + }) + } +} diff --git a/src/program/Rl.ts b/src/program/Rl.ts new file mode 100644 index 0000000..772c297 --- /dev/null +++ b/src/program/Rl.ts @@ -0,0 +1,15 @@ +import { Item } from '../fs/Item' +import type { SimpleStream } from '../utils/SimpleStream' +import { Program } from './Program' + +export class Rl extends Program { + constructor() { + super() + } + + async Exec(_: SimpleStream, __: SimpleStream, ___: Item, ____: string[]): Promise { + location.reload() + + return 0 + } +} diff --git a/src/program/Rm.ts b/src/program/Rm.ts index e69de29..9c98347 100644 --- a/src/program/Rm.ts +++ b/src/program/Rm.ts @@ -0,0 +1,35 @@ +import { Item } from '../fs/Item' +import type { SimpleStream } from '../utils/SimpleStream' +import { Program } from './Program' + +export class Rm extends Program { + constructor() { + super() + } + + async Exec(_: SimpleStream, stdout: SimpleStream, workdir: Item, args: string[]): Promise { + if (!args[1]) { + stdout.emit("rm: error: missing first argument\n") + return 1 + } + + let item: Item + + try { + item = await Item.openDir(args[1]) + } catch { + item = await Item.open(args[1]) + } + + if (!(await item.Exists())) { + stdout.emit(`rm: error: item ${item.GetPath()} doesn't exist.\n`) + return 1 + } + + stdout.emit(`removing: '${item.GetPath()}'\n`) + + await item.Delete() + + return 0 + } +} diff --git a/src/program/Touch.ts b/src/program/Touch.ts index f8d14a2..43f50d5 100644 --- a/src/program/Touch.ts +++ b/src/program/Touch.ts @@ -10,15 +10,15 @@ export class Touch extends Program { async Exec(_: SimpleStream, stdout: SimpleStream, workdir: Item, args: string[]): Promise { stdout.emit(`touching children, please wait...\n`) - const file = new Item(args[1]) - stdout.emit(`does ${args[1]} exist? ${file.Exists()}\n`) + const item = await Item.open(args[1]) + stdout.emit(`does ${args[1]} exist? ${await item.Exists()}\n`) - if (file.Exists()) { + if (await item.Exists()) { stdout.emit("touch: the file already exists.\n") return 1 } - file.Create() + item.Create() return 0 } diff --git a/src/shell/Shell.ts b/src/shell/Shell.ts index dc2b085..0360faa 100644 --- a/src/shell/Shell.ts +++ b/src/shell/Shell.ts @@ -15,6 +15,6 @@ export abstract class Shell { abstract LoadProgram(program: Program, name: string): void abstract ExecuteProgram(name: string, args: string[]): void - abstract Init(): void + abstract Init(): Promise abstract HandleKeyInput(key: string, isCharacter: boolean): void } diff --git a/src/shell/Wush.ts b/src/shell/Wush.ts index 2531d13..d95ef61 100644 --- a/src/shell/Wush.ts +++ b/src/shell/Wush.ts @@ -15,6 +15,12 @@ import { Ls } from '../program/Ls' import { Item } from '../fs/Item' import { Touch } from '../program/Touch' import { Sl } from '../program/Sl' +import { Rm } from '../program/Rm' +import { Rl } from '../program/Rl' +import { ResetIndexedDb } from '../program/ResetIndexedDb' +import { Cat } from '../program/Cat' +import { Echo } from '../program/Echo' +import { Mkdir } from '../program/Mkdir' export class Wush extends Shell { public readonly Version = "0.1.0" @@ -33,12 +39,15 @@ export class Wush extends Shell { */ private execExitCode: number = 0 + // workers + private workersAllowed: boolean = false + // streams readonly stdin: SimpleStream readonly stdout: SimpleStream private programs: { [name: string]: Program } = {} - private workingDirectory: Item = Item.Root + private workingDirectory: Item = null as unknown as Item // workdir is initialized in the Init so this should be safe constructor(broadcaster: EventBroadcaster, terminal: Terminal) { super(broadcaster, terminal) @@ -48,11 +57,17 @@ export class Wush extends Shell { this.stdout = new SimpleStream() } - Init() { + async Init() { this.broadcaster.on('keydown', (key: string, isCharacter: boolean) => this.HandleKeyInput(key, isCharacter), ) + // load workdir + this.workingDirectory = await Item.Root() + + if (!this.workingDirectory.Exists()) + this.workingDirectory.Create() + // load core programs this.programs['clear'] = new Clear() this.programs['eval'] = new Eval() @@ -62,6 +77,12 @@ export class Wush extends Shell { this.programs['ls'] = new Ls() this.programs['touch'] = new Touch() this.programs['sl'] = new Sl() + this.programs['rm'] = new Rm() + this.programs['rl'] = new Rl() + this.programs['rsindb'] = new ResetIndexedDb() + this.programs['cat'] = new Cat() + this.programs['echo'] = new Echo() + this.programs['mkdir'] = new Mkdir() this.stdout.on(data => this.WriteEscapedString(data)) this.Prompt() @@ -85,8 +106,8 @@ export class Wush extends Shell { this.execExitCode = code != -1 ? code : -2 }) .catch((e) => { - this.WriteEscapedString("lol") - this.WriteEscapedString(`\n${String(e)}\n`) + this.WriteEscapedString(`wush: command ${name} exited with the following exception\n`) + this.WriteEscapedString(` | ${String(e)}\n`) }) .finally(() => { // check if the exec actually exited with an exit code @@ -176,11 +197,13 @@ export class Wush extends Shell { } } + // todo: actual processing ProcessAdvancedControlCode(complex: string): boolean { if (!complex.match(/\\(.*;)/gm)) return false - const code = matches[] + return true + // const code = matches[] } HandleKeyInput(key: string, isCharacter: boolean) { diff --git a/src/terminal/Terminal.ts b/src/terminal/Terminal.ts index af81f44..c90c27f 100644 --- a/src/terminal/Terminal.ts +++ b/src/terminal/Terminal.ts @@ -30,9 +30,9 @@ export class Terminal { this.NewPage() } - LoadShell(shell: Shell) { + async LoadShell(shell: Shell) { this.shell = shell - this.shell.Init() + await this.shell.Init() } GetShell(): Shell | undefined {