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
// 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))
}

View File

@@ -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<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 {
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<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('/')
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<void> {
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<boolean> {
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<void> {
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<void> {
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<void> {
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<Item[]> {
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<void> {
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<void> {
// 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,
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<void> {
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<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> {
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

View File

@@ -12,8 +12,12 @@ export class Lsprg extends Program {
}
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())
stdout.emit(`${program}\n`)
stdout.emit(` | - ${program}\n`)
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> {
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
}

View File

@@ -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<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 { 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<string>
readonly stdout: SimpleStream<string>
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<string>()
}
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) {

View File

@@ -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 {