feat: working filesystem

This commit is contained in:
binekrasik
2026-05-15 14:16:11 +02:00
parent 97b9994594
commit 4c67f2aee3
15 changed files with 449 additions and 95 deletions

8
.zed/settings.json Normal file
View File

@@ -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",
}

View File

@@ -41,7 +41,7 @@ export const SetCurrentTerminal = (terminal: Terminal) => CurrentTerminal = term
export const GetCurrentTerminal = (): Terminal => CurrentTerminal export const GetCurrentTerminal = (): Terminal => CurrentTerminal
// Initializes the app // Initializes the app
const init = () => { const init = async () => {
const localBroadcaster = new EventBroadcaster() const localBroadcaster = new EventBroadcaster()
// creates keyboard listeners for the local event broadcaster // creates keyboard listeners for the local event broadcaster
@@ -51,5 +51,5 @@ const init = () => {
) )
const terminal = new Terminal() const terminal = new Terminal()
terminal.LoadShell(new Wush(localBroadcaster, terminal)) await terminal.LoadShell(new Wush(localBroadcaster, terminal))
} }

View File

@@ -1,131 +1,211 @@
import { GetWebfsDatabase } from "../app" import { GetWebfsDatabase } from "../app"
interface ItemPayload {
isDirectory: boolean
executable: boolean
data: string | null
children: string[]
}
export class Item { export class Item {
private path: string private path: string
private isDirectory: boolean = false private isDirectory: boolean = false
private executable: boolean = false private executable: boolean = false
private data: string | null = null private data: string | null = null
private children: string[] = [] private children: string[] = []
constructor(path: string, data?: string) { private constructor(path: string) {
this.path = this.normalizePath(path) this.path = this.normalizePath(path)
}
if (this.Exists()) { // -------------------------------------------------------------------------
this.load() // 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<Item> {
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 { } else {
this.data = data !== undefined ? data : null item.data = initialData
this.isDirectory = data === undefined item.isDirectory = false
} }
return item
} }
static get Root(): 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<Item> {
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<Item> {
const root = new Item('/') const root = new Item('/')
if (!root.Exists()) { if (!(await root.Exists())) {
root.Create() root.isDirectory = true
await root.Create()
} else {
await root.load()
} }
return root return root
} }
Create(): void { // -------------------------------------------------------------------------
if (this.Exists()) throw new Error("File already exists") // Public API
// -------------------------------------------------------------------------
this.save() async Create(): Promise<void> {
if (await this.Exists()) throw new Error(`File already exists: ${this.path}`)
await this.save()
if (this.path !== '/') { if (this.path !== '/') {
const parentPath = this.getParentPath() const parentPath = this.getParentPath()
const parent = new Item(parentPath) const parent = await Item.openDir(parentPath)
// recursively creates parent directories if (!(await parent.Exists())) {
if (!parent.Exists()) { await parent.Create()
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)) { if (!parent.children.includes(this.path)) {
parent.children.push(this.path) parent.children.push(this.path)
parent.save() await parent.save()
} }
} }
} }
Exists(): boolean { async Exists(): Promise<boolean> {
// localStorage.getItem(this.storageKey) !== null const db = GetWebfsDatabase()
const store = GetWebfsDatabase()! if (!db) throw new Error("WebFS database is not initialized")
return new Promise((resolve, reject) => {
const request = db
.transaction("webfs", "readonly") .transaction("webfs", "readonly")
.objectStore("webfs") .objectStore("webfs")
.getKey(this.storageKey)
return Boolean(store.getKey(this.storageKey)) request.onsuccess = () => resolve(request.result !== undefined)
request.onerror = () => reject(request.error)
})
} }
IsDirectory(): boolean { IsDirectory(): boolean {
return this.isDirectory return this.isDirectory
} }
Append(data: string): void { async Append(data: string): Promise<void> {
if (this.isDirectory) { if (!(await this.Exists())) throw new Error(`Cannot append to a file that has not been created: ${this.path}`)
throw new Error("Cannot append data to a directory") if (this.isDirectory) throw new Error("Cannot append data to a directory")
} this.data = (this.data ?? '') + data
this.data = (this.data || '') + data await this.save()
this.save()
} }
Write(data: string): void { async Write(data: string): Promise<void> {
if (this.isDirectory) if (!(await this.Exists())) throw new Error(`Cannot write to a file that has not been created: ${this.path}`)
throw new Error("Cannot write data to a directory") if (this.isDirectory) throw new Error("Cannot write data to a directory")
this.data = data 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<void> {
this.executable = executable this.executable = executable
if (await this.Exists()) await this.save()
// update the item if it already exists in the filesystem
if (this.Exists())
this.save()
} }
GetPath(): string { GetPath(): string {
return this.path return this.path
} }
/**
* Returns just the filename/directory name, not the full path.
*/
GetName(): string { GetName(): string {
return this.path return this.path === '/' ? '/' : this.path.substring(this.path.lastIndexOf('/') + 1)
} }
ReadData(): string | null { ReadData(): string | null {
return this.data return this.data
} }
List(): Item[] { async List(): Promise<Item[]> {
if (!this.isDirectory) if (!this.isDirectory) throw new Error(`Not a directory: ${this.path}`)
throw new Error(`Not a directory: ${this.path}`)
// grab all the children // Always reload from store to ensure children list is current.
return this.children.map(childPath => new Item(childPath)) 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 { async Delete(): Promise<void> {
if (!this.Exists()) return 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) { 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 await this.remove()
localStorage.removeItem(this.storageKey)
if (this.path !== '/') { if (this.path !== '/') {
const parent = new Item(this.getParentPath()) const parent = await Item.openDir(this.getParentPath())
if (parent.Exists()) { parent.children = parent.children.filter(p => p !== this.path)
parent.children = parent.children.filter(path => path !== this.path) await parent.save()
parent.save()
}
} }
} }
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
private get storageKey(): string { private get storageKey(): string {
return this.path return this.path
} }
@@ -144,32 +224,90 @@ export class Item {
: this.path.substring(0, lastSlashIndex) : 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 { private async save(): Promise<void> {
const payload = { // 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<ItemPayload | undefined>((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, isDirectory: this.isDirectory,
executable: this.executable, executable: this.executable,
data: this.data, data: this.data,
children: this.children, 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 { private load(): Promise<void> {
const raw = localStorage.getItem(this.storageKey) return new Promise((resolve, reject) => {
const request = this.db()
.transaction("webfs", "readonly")
.objectStore("webfs")
.get(this.storageKey)
if (raw) { request.onsuccess = () => {
const parsed = JSON.parse(raw) const parsed = request.result as ItemPayload | undefined
if (parsed) {
this.isDirectory = parsed.isDirectory this.isDirectory = parsed.isDirectory
this.executable = parsed.executable this.executable = parsed.executable
this.data = parsed.data this.data = parsed.data
this.children = parsed.children this.children = parsed.children
} }
resolve()
}
request.onerror = () => reject(request.error)
})
}
/**
* Deletes this item's record from IndexedDB.
*/
private remove(): Promise<void> {
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)
})
} }
} }

28
src/program/Cat.ts Normal file
View File

@@ -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<string>, stdout: SimpleStream<string>, __: Item, args: string[]): Promise<number> {
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
}
}

27
src/program/Echo.ts Normal file
View File

@@ -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<string>, stdout: SimpleStream<string>, ___: Item, args: string[]): Promise<number> {
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
}
}

View File

@@ -8,15 +8,24 @@ export class Ls extends Program {
} }
async Exec(_: SimpleStream<string>, stdout: SimpleStream<string>, workdir: Item, args: string[]): Promise<number> { async Exec(_: SimpleStream<string>, stdout: SimpleStream<string>, workdir: Item, args: string[]): Promise<number> {
if (!(new Item(args[1]).IsDirectory())) { let item: Item = args[1] ? await Item.openDir(args[1]) : workdir
stdout.emit("ls: error: the provided path is not a directory") if (args[1] && !item.IsDirectory()) {
stdout.emit("ls: error: the provided path is not a directory\n")
return 1 return 1
} }
stdout.emit(`item: '${workdir.GetPath()}'\n`) if (!(await item.Exists())) {
stdout.emit(`index attr name\n`) stdout.emit(`ls: error: path ${item.GetPath()} doesn't exist\n`)
workdir.List().forEach((entry, i) => { return 2
stdout.emit(`${i.toString().padEnd(8, ' ')} ${''.padEnd(4, '-').padEnd(8, ' ')} '${entry.GetName()}'\n`) }
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 return 0

View File

@@ -12,8 +12,12 @@ export class Lsprg extends Program {
} }
async Exec(_: SimpleStream<string>, stdout: SimpleStream<string>, __: Item, ___: string[]): Promise<number> { async Exec(_: SimpleStream<string>, stdout: SimpleStream<string>, __: Item, ___: string[]): Promise<number> {
const programs = this.wush.GetPrograms()
stdout.emit(`-> ${Object.keys(programs).length} loaded programs:\n`)
for (const program in this.wush.GetPrograms()) for (const program in this.wush.GetPrograms())
stdout.emit(`${program}\n`) stdout.emit(` | - ${program}\n`)
return 0 return 0
} }

23
src/program/Mkdir.ts Normal file
View File

@@ -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<string>, stdout: SimpleStream<string>, workdir: Item, args: string[]): Promise<number> {
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
}
}

View File

@@ -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<string>, stdout: SimpleStream<string>, ___: Item, ____: string[]): Promise<number> {
const db = GetWebfsDatabase()
if (!db) {
stdout.emit("rsindb: error: GetWebfsDatabase returned null")
return 1
}
return new Promise<number>(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()
}
})
}
}

15
src/program/Rl.ts Normal file
View File

@@ -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<string>, __: SimpleStream<string>, ___: Item, ____: string[]): Promise<number> {
location.reload()
return 0
}
}

View File

@@ -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<string>, stdout: SimpleStream<string>, workdir: Item, args: string[]): Promise<number> {
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
}
}

View File

@@ -10,15 +10,15 @@ export class Touch extends Program {
async Exec(_: SimpleStream<string>, stdout: SimpleStream<string>, workdir: Item, args: string[]): Promise<number> { async Exec(_: SimpleStream<string>, stdout: SimpleStream<string>, workdir: Item, args: string[]): Promise<number> {
stdout.emit(`touching children, please wait...\n`) stdout.emit(`touching children, please wait...\n`)
const file = new Item(args[1]) const item = await Item.open(args[1])
stdout.emit(`does ${args[1]} exist? ${file.Exists()}\n`) 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") stdout.emit("touch: the file already exists.\n")
return 1 return 1
} }
file.Create() item.Create()
return 0 return 0
} }

View File

@@ -15,6 +15,6 @@ export abstract class Shell {
abstract LoadProgram(program: Program, name: string): void abstract LoadProgram(program: Program, name: string): void
abstract ExecuteProgram(name: string, args: string[]): void abstract ExecuteProgram(name: string, args: string[]): void
abstract Init(): void abstract Init(): Promise<void>
abstract HandleKeyInput(key: string, isCharacter: boolean): void abstract HandleKeyInput(key: string, isCharacter: boolean): void
} }

View File

@@ -15,6 +15,12 @@ import { Ls } from '../program/Ls'
import { Item } from '../fs/Item' import { Item } from '../fs/Item'
import { Touch } from '../program/Touch' import { Touch } from '../program/Touch'
import { Sl } from '../program/Sl' 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 { export class Wush extends Shell {
public readonly Version = "0.1.0" public readonly Version = "0.1.0"
@@ -33,12 +39,15 @@ export class Wush extends Shell {
*/ */
private execExitCode: number = 0 private execExitCode: number = 0
// workers
private workersAllowed: boolean = false
// streams // streams
readonly stdin: SimpleStream<string> readonly stdin: SimpleStream<string>
readonly stdout: SimpleStream<string> readonly stdout: SimpleStream<string>
private programs: { [name: string]: Program } = {} 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) { constructor(broadcaster: EventBroadcaster, terminal: Terminal) {
super(broadcaster, terminal) super(broadcaster, terminal)
@@ -48,11 +57,17 @@ export class Wush extends Shell {
this.stdout = new SimpleStream<string>() this.stdout = new SimpleStream<string>()
} }
Init() { async Init() {
this.broadcaster.on('keydown', (key: string, isCharacter: boolean) => this.broadcaster.on('keydown', (key: string, isCharacter: boolean) =>
this.HandleKeyInput(key, isCharacter), this.HandleKeyInput(key, isCharacter),
) )
// load workdir
this.workingDirectory = await Item.Root()
if (!this.workingDirectory.Exists())
this.workingDirectory.Create()
// load core programs // load core programs
this.programs['clear'] = new Clear() this.programs['clear'] = new Clear()
this.programs['eval'] = new Eval() this.programs['eval'] = new Eval()
@@ -62,6 +77,12 @@ export class Wush extends Shell {
this.programs['ls'] = new Ls() this.programs['ls'] = new Ls()
this.programs['touch'] = new Touch() this.programs['touch'] = new Touch()
this.programs['sl'] = new Sl() 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.stdout.on(data => this.WriteEscapedString(data))
this.Prompt() this.Prompt()
@@ -85,8 +106,8 @@ export class Wush extends Shell {
this.execExitCode = code != -1 ? code : -2 this.execExitCode = code != -1 ? code : -2
}) })
.catch((e) => { .catch((e) => {
this.WriteEscapedString("lol") this.WriteEscapedString(`wush: command ${name} exited with the following exception\n`)
this.WriteEscapedString(`\n${String(e)}\n`) this.WriteEscapedString(` | ${String(e)}\n`)
}) })
.finally(() => { .finally(() => {
// check if the exec actually exited with an exit code // 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 { ProcessAdvancedControlCode(complex: string): boolean {
if (!complex.match(/\\(.*;)/gm)) if (!complex.match(/\\(.*;)/gm))
return false return false
const code = matches[] return true
// const code = matches[]
} }
HandleKeyInput(key: string, isCharacter: boolean) { HandleKeyInput(key: string, isCharacter: boolean) {

View File

@@ -30,9 +30,9 @@ export class Terminal {
this.NewPage() this.NewPage()
} }
LoadShell(shell: Shell) { async LoadShell(shell: Shell) {
this.shell = shell this.shell = shell
this.shell.Init() await this.shell.Init()
} }
GetShell(): Shell | undefined { GetShell(): Shell | undefined {