Compare commits

...

29 Commits

Author SHA1 Message Date
binekrasik
b5f53473ed sync: actually sync everything 2026-05-18 19:25:03 +02:00
binekrasik
22d00aa0b3 sync: wip advanced command parser 2026-05-18 19:22:34 +02:00
binekrasik
d8ebbbe9e1 chore: fix build 2026-05-18 00:14:49 +02:00
binekrasik
4f4b72b915 chore: make webfs usable 2026-05-17 23:52:31 +02:00
binekrasik
f8335dcf5f docs: update tested browsers 2026-05-15 14:29:06 +02:00
binekrasik
063f704e69 chore: add temporary files to .gitignore 2026-05-15 14:27:13 +02:00
d3bfbdf836 chore: remove slop prompt 2026-05-15 14:24:06 +02:00
binekrasik
4c67f2aee3 feat: working filesystem 2026-05-15 14:16:11 +02:00
97b9994594 sync: sync wip stuff 2026-05-12 10:38:30 +02:00
dc706d6262 chore: fix chrome 2026-04-15 11:40:45 +02:00
ea968b8492 sync: wip shell and info fetch 2026-03-27 17:52:58 +01:00
83830c7717 feat: add neofetch, fix buffer backspacing 2026-03-27 09:20:21 +01:00
91eeb33d9e feat: simple loadprg implementation 2026-03-26 23:58:09 +01:00
983cf59476 feat: add some utilities 2026-03-26 22:33:04 +01:00
21a1a34309 feat: streams, 'clear' command 2026-03-26 21:05:35 +01:00
686abeafb4 docs: remove chrome from tested browsers 2026-03-26 15:40:07 +01:00
ed15d94255 feat: working-ish shell and terminal 2026-03-26 13:57:58 +01:00
1d54c8b650 chore: cleanup Terminal.ts 2026-03-26 11:57:11 +01:00
141204164a sync: umm progress 2026-03-25 23:07:44 +01:00
e03aa97716 sync: wip terminal cell system 2026-03-24 23:49:51 +01:00
475ba9d8ab sync: wip changes 2026-03-24 13:58:59 +01:00
6cc4837a8e feat: basic terminal printing 2026-03-23 23:42:24 +01:00
64717cc652 chore: tidy up things 2026-03-23 21:59:36 +01:00
ca5d60b714 chore: add sass 2026-03-23 21:55:11 +01:00
d8863654f9 docs: what in the legal he- 2026-03-23 17:16:25 +01:00
0152e4da73 docs: clarify dependency installation 2026-03-23 16:24:01 +01:00
8e0db465a7 chore: create app base 2026-03-23 16:21:20 +01:00
788edc6d61 chore: add stuff to gitignore 2026-03-23 16:20:31 +01:00
9a90da9cc9 docs: add testing notice in README.md 2026-03-23 16:15:28 +01:00
37 changed files with 1736 additions and 37 deletions

11
.gitignore vendored
View File

@@ -12,6 +12,13 @@ dist
dist-ssr
*.local
# Runtime
bun.lock
# Vercel
.vercel
*.env
# Editor directories and files
.vscode/*
!.vscode/extensions.json
@@ -22,3 +29,7 @@ dist-ssr
*.njsproj
*.sln
*.sw?
# temp files
slop.md
.env*.local

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

@@ -6,16 +6,18 @@ Aplikace je zdarma hostovaná přes Vercel a je veřejně dostupná na adrese ht
## buildování
0. Pro buildování je potřeba software třetích stran:
- Bun -> JavaScriptový/TypeScriptový runtime. Stáhnout lze z více zdrojů. Po instalaci doporučuji přidat Bun executable do PATH. Je možno použít Node.JS a npm.
- Univerzálně: pomocí npm příkazem `npm i -g bun`
- Win NT:
1. ze stránky https://bun.sh/
2. přes winget powershellovým příkazem `winget install --id Oven-sh.Bun`
- Univerzálně:
1. pomocí npm příkazem `npm i -g bun`
2. z oficiální stránky https://bun.sh/
- M$ Win:
1. z powershellu příkazem `powershell -c "irm bun.sh/install.ps1 | iex"`
2. (NT 10+) přes winget powershellovým příkazem `winget install --id Oven-sh.Bun`
3. pomocí chocolatey příkazem `choco install bun`
- Arch Linux:
1. ze stránky https://bun.sh/
2. z `extra` repozitáře příkazem `pacman -S bun`
- Linux:
1. z terminálu příkazem `curl -fsSL https://bun.sh/install | bash`
2. (Arch) z `extra` repozitáře příkazem `pacman -S bun`
- MacOS:
1. ze stránky https://bun.sh/
1. z terminálu příkazem `curl -fsSL https://bun.sh/install | bash`
2. pomocí homebrew příkazem `brew tap oven-sh/bun && brew install bun`
- Vite -> Builder a packer pro webové aplikace
- Již definováno jako závislost v projektovém `package.json`
@@ -29,22 +31,11 @@ Aplikace je zdarma hostovaná přes Vercel a je veřejně dostupná na adrese ht
4. Stránka je nyní lokálně dostupná na http://localhost:3000/
5. (volitelné) Apliakci je možné buildnout pomocí `<bun/npm> run build`. Statická verze stránky je nyní dostupná v adresáři `webshell/dist/`
## testování
Aplikace je testována v následujících prohlížečích:
- `Helium 0.12.1.1 (Chromium 148.0.7778.96) (64-bit)`
## licence
Tato webová aplikace včetně jejího zdrojového kódu je veřejně dostupná pod licencí Apache 2.0 (SPDX: `Apache-2.0`). Zdrojový kód je veřejně dostupný na https://git.martinpetr.dev/binekrasik/webshell
```md
Copyright 2026 Vendelín Mžik
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
```
Tato webová aplikace včetně jejího zdrojového kódu je veřejně dostupná pod licencí Apache 2.0 (SPDX: `Apache-2.0`), jejíž celé znění je dostupné v LICENSE.md. Zdrojový kód je veřejně dostupný na https://git.martinpetr.dev/binekrasik/webshell

View File

@@ -1,15 +1,25 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>webshell</title>
</head>
<body>
<div id="app">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</div>
<script type="module" src="/src/main.ts"></script>
</body>
<!-- import main stylesheet -->
<link rel="stylesheet" href="/src/styles/app.scss" />
<title>webshell</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
</head>
<body>
<div id="app">
<div id="cursor"></div>
<div id="terminal">
<div id="lines"></div>
</div>
</div>
<!-- run main script -->
<script type="module" src="/src/app.ts"></script>
</body>
</html>

View File

@@ -10,6 +10,9 @@
},
"devDependencies": {
"typescript": "~5.9.3",
"vite": "^8.0.1"
"vite": "^8.0.8"
},
"dependencies": {
"sass": "^1.99.0"
}
}

55
src/app.ts Normal file
View File

@@ -0,0 +1,55 @@
import { Alert } from './gui/Alert'
import { CreateKeyboardListeners } from './input/keyboard'
import { Wush } from './shell/wush/Wush'
import { Terminal } from './terminal/Terminal'
import { EventBroadcaster } from './utils/EventBroadcaster'
export const WEBSHELL_VERSION = "0.1.0"
// initialize object store for webfs
let WebfsDatabase: IDBDatabase | null = null
const request = indexedDB.open("webshell")
request.onupgradeneeded = event => {
console.log("creating database")
WebfsDatabase = (event.target as any).result as IDBDatabase
WebfsDatabase.createObjectStore("webfs")
}
request.onerror = _ => {
new Alert(
"webfs error",
"Failed to initialize the webfs database using IndexedDB. Make sure webshell does not have any IndexedDB related permissions disabled and try again.",
'critical',
).Show()
}
request.onsuccess = event => {
WebfsDatabase = (event.target as any).result as IDBDatabase
console.log("database initialized")
// initialize the app after the database
init()
}
export const GetWebfsDatabase = (): IDBDatabase | null => WebfsDatabase
// terminal management
let CurrentTerminal: Terminal
export const SetCurrentTerminal = (terminal: Terminal) => CurrentTerminal = terminal
export const GetCurrentTerminal = (): Terminal => CurrentTerminal
// Initializes the app
const init = async () => {
const localBroadcaster = new EventBroadcaster()
// creates keyboard listeners for the local event broadcaster
CreateKeyboardListeners(
(key: string, isCharacter: boolean) => localBroadcaster.emit('keydown', key, isCharacter),
(key: string, isCharacter: boolean) => localBroadcaster.emit('keyup', key, isCharacter),
)
const terminal = new Terminal()
await terminal.LoadShell(new Wush(localBroadcaster, terminal))
}

328
src/fs/Item.ts Normal file
View File

@@ -0,0 +1,328 @@
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[] = []
private constructor(path: string) {
this.path = this.normalizePath(path)
}
// -------------------------------------------------------------------------
// 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 (!(await root.Exists())) {
root.isDirectory = true
await root.Create()
} else {
await root.load()
}
return root
}
// -------------------------------------------------------------------------
// Public API
// -------------------------------------------------------------------------
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 = await Item.openDir(parentPath)
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()
}
if (!parent.children.includes(this.path)) {
parent.children.push(this.path)
await parent.save()
}
}
}
async Exists(): Promise<boolean> {
const db = GetWebfsDatabase()
if (!db) throw new Error("WebFS database is not initialized")
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
}
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()
}
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
await this.save()
}
/**
* 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
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 === '/' ? '/' : this.path.substring(this.path.lastIndexOf('/') + 1)
}
ReadData(): string | null {
return this.data
}
async List(): Promise<Item[]> {
if (!this.isDirectory) throw new Error(`Not a directory: ${this.path}`)
// 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)
}
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()
if (this.isDirectory) {
const children = await this.List()
await Promise.all(children.map(child => child.Delete()))
}
await this.remove()
if (this.path !== '/') {
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
}
private normalizePath(path: string): string {
if (!path.startsWith('/')) path = '/' + path
// Resolve . and .. segments.
// Split on '/' and walk each part: skip empty strings (from leading
// slash or consecutive slashes) and '.', pop on '..', push otherwise.
const parts = path.split('/')
const resolved: string[] = []
for (const part of parts) {
if (part === '' || part === '.') {
continue
} else if (part === '..') {
if (resolved.length > 0) resolved.pop() // never climbs above root
} else {
resolved.push(part)
}
}
return '/' + resolved.join('/')
}
private getParentPath(): string {
if (this.path === '/') return '/'
const lastSlashIndex = this.path.lastIndexOf('/')
return lastSlashIndex === 0
? '/'
: this.path.substring(0, lastSlashIndex)
}
private db(): IDBDatabase {
const db = GetWebfsDatabase()
if (!db) throw new Error("WebFS database is not initialized")
return db
}
/**
* Persists this item to IndexedDB.
* Enforces that the isDirectory flag cannot be changed after creation.
*/
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,
}
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 this item's data from IndexedDB into memory.
*/
private load(): Promise<void> {
return new Promise((resolve, reject) => {
const request = this.db()
.transaction("webfs", "readonly")
.objectStore("webfs")
.get(this.storageKey)
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)
})
}
}

23
src/gui/Alert.ts Normal file
View File

@@ -0,0 +1,23 @@
import HtmlAlertTemplate from "./alertTemplate.html?raw"
export type AlertSeverity = 'debug' | 'info' | 'warning' | 'error' | 'critical'
export class Alert {
readonly title: string
readonly description: string
readonly severity: AlertSeverity
// a html5 template
readonly template: string
constructor(title: string, description: string, severity: AlertSeverity, template: string = HtmlAlertTemplate) {
this.title = title
this.description = description
this.severity = severity
this.template = template
}
Show(time: number = -1): void {
alert(`${this.title} [${this.severity}]: ${this.description} (time: ${time})`)
}
}

View File

@@ -0,0 +1,24 @@
<div class="webshellAlert">
<style>
#webshellAlert {
border-radius: 0px;
border: 2px solid {{severity_color}};
display: flex;
flex-direction: column;
width: fit-content;
height: fit-content;
}
button {
border-radius: 0px;
border: 2px solid #86e9d5;
}
</style>
<h1>{{title}}</h1>
<p>{{description}}</p>
<button>ok</button>
<button>ok</button>
</div>

15
src/input/keyboard.ts Normal file
View File

@@ -0,0 +1,15 @@
// creates keyboard listeners
export const CreateKeyboardListeners = (
onKeyDown: (key: string, isCharacter: boolean) => void,
onKeyUp: (key: string, isCharacter: boolean) => void,
) => {
document.addEventListener('keydown', event => {
onKeyDown(event.key, event.key.length === 1)
event.preventDefault()
})
document.addEventListener('keyup', event => {
onKeyUp(event.key, event.key.length === 1)
event.preventDefault()
})
}

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

@@ -0,0 +1,24 @@
import { Item } from '../fs/Item'
import type { SimpleStream } from '../utils/SimpleStream'
import { Program } from './Program'
export class Cat extends Program {
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
}
}

11
src/program/Clear.ts Normal file
View File

@@ -0,0 +1,11 @@
import type { Item } from '../fs/Item'
import type { SimpleStream } from '../utils/SimpleStream'
import { Program } from './Program'
export class Clear extends Program {
async Exec(_: SimpleStream<string>, stdout: SimpleStream<string>, __: Item, ___: string[]): Promise<number> {
stdout.emit('\f')
return 0
}
}

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

@@ -0,0 +1,34 @@
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>, workdir: Item, args: string[]): Promise<number> {
if (args.length < 2) {
stdout.emit("echo: error: missing path argument\n")
return 1
}
const item = await Item.open(args[1].startsWith('/')
? args[1]
: `${workdir.GetPath()}${workdir.GetPath().endsWith('/') ? '' : '/'}${args[1]}`)
if (!(await item.Exists())) {
stdout.emit(`echo: error: item ${item.GetPath()} doesn't exist.\n`)
return 2
}
if (item.IsDirectory()) {
stdout.emit(`echo: error: can't write data to a directory; item ${item.GetPath()} is a directory.\n`)
return 3
}
await item.Write(args.slice(2).join(' '))
return 0
}
}

19
src/program/Eval.ts Normal file
View File

@@ -0,0 +1,19 @@
import type { Item } from '../fs/Item'
import type { SimpleStream } from '../utils/SimpleStream'
import { Program } from './Program'
export class Eval extends Program {
async Exec(_: SimpleStream<string>, stdout: SimpleStream<string>, _workdir: Item, args: string[]): Promise<number> {
const javascript = args.slice(1).join(' ')
try {
// todo: pass workdir to the program
eval(javascript)
} catch (e) {
stdout.emit(`${String(e)}\n`)
return 1
}
return 0
}
}

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

@@ -0,0 +1,15 @@
import { GetCurrentTerminal, WEBSHELL_VERSION } from '../app'
import type { Item } from '../fs/Item'
import { Terminal } from '../terminal/Terminal'
import type { SimpleStream } from '../utils/SimpleStream'
import { Program } from './Program'
export class Info extends Program {
async Exec(_: SimpleStream<string>, stdout: SimpleStream<string>, __: Item, ___: string[]): Promise<number> {
stdout.emit(`Webshell v${WEBSHELL_VERSION}\n`)
stdout.emit(`Terminal v${Terminal.Version}\n`)
stdout.emit(`Running ${GetCurrentTerminal().GetShell()?.Name} v${GetCurrentTerminal().GetShell()?.Version}\n`)
return 0
}
}

29
src/program/Loadprg.ts Normal file
View File

@@ -0,0 +1,29 @@
import type { Item } from '../fs/Item'
import type { Shell } from '../shell/Shell'
import type { SimpleStream } from '../utils/SimpleStream'
import { Program } from './Program'
export class Loadprg extends Program {
private shell: Shell
constructor(shell: Shell) {
super()
this.shell = shell
}
async Exec(_: SimpleStream<string>, stdout: SimpleStream<string>, workdir: Item, args: string[]): Promise<number> {
const javascript = args.slice(2).join(' ')
try {
const program = eval(javascript)
this.shell.LoadProgram(new program(), args[1])
} catch (e) {
stdout.emit(`${String(e)}\n`)
stdout.emit(`${String((e as Error).stack)}\n`)
return 1
}
return 0
}
}

39
src/program/Ls.ts Normal file
View File

@@ -0,0 +1,39 @@
import { Item } from '../fs/Item'
import type { SimpleStream } from '../utils/SimpleStream'
import { Program } from './Program'
export class Ls extends Program {
constructor() {
super()
}
async Exec(_: SimpleStream<string>, stdout: SimpleStream<string>, workdir: Item, args: string[]): Promise<number> {
const item = args[1]
? await Item.openDir(args[1].startsWith('/')
? args[1]
: `${workdir.GetPath()}${workdir.GetPath().endsWith('/') ? '' : '/'}${args[1]}`)
: workdir
if (args[1] && !item.IsDirectory()) {
stdout.emit("ls: error: the provided path is not a directory\n")
return 1
}
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(` [ drwx name ]\n`)
const items = await item.List()
items.forEach(entry => {
stdout.emit(item.IsDirectory().toString())
stdout.emit(` | ${(item.IsDirectory() ? 'd' : '').padEnd(4, '-').padEnd(8, ' ')} ${entry.GetName()}\n`)
})
return 0
}
}

24
src/program/Lsprg.ts Normal file
View File

@@ -0,0 +1,24 @@
import type { Item } from '../fs/Item'
import type { Wush } from '../shell/wush/Wush'
import type { SimpleStream } from '../utils/SimpleStream'
import { Program } from './Program'
export class Lsprg extends Program {
private wush: Wush
constructor(wush: Wush) {
super()
this.wush = wush
}
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`)
return 0
}
}

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

@@ -0,0 +1,31 @@
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> {
if (args.length < 2) {
stdout.emit("mkdir: error: missing path argument\n")
return 1
}
const item = await Item.openDir(args[1].startsWith('/')
? args[1]
: `${workdir.GetPath()}${workdir.GetPath().endsWith('/') ? '' : '/'}${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 2
}
item.Create()
return 0
}
}

11
src/program/Program.ts Normal file
View File

@@ -0,0 +1,11 @@
import type { Item } from "../fs/Item"
import type { SimpleStream } from "../utils/SimpleStream"
export abstract class Program {
abstract Exec(
stdin: SimpleStream<string>,
stdout: SimpleStream<string>,
workdir: Item,
args: string[]
): Promise<number>
}

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

39
src/program/Rm.ts Normal file
View File

@@ -0,0 +1,39 @@
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].startsWith('/')
? args[1]
: `${workdir.GetPath()}${workdir.GetPath().endsWith('/') ? '' : '/'}${args[1]}`)
} catch {
item = await Item.open(args[1].startsWith('/')
? args[1]
: `${workdir.GetPath()}${workdir.GetPath().endsWith('/') ? '' : '/'}${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
}
}

119
src/program/Sl.ts Normal file
View File

@@ -0,0 +1,119 @@
// --- `sl` Command Implementation ---
import type { Item } from "../fs/Item"
import type { SimpleStream } from "../utils/SimpleStream"
import { Program } from "./Program"
export class Sl extends Program {
// The classic D51 locomotive ASCII art with 3 frames of wheel animation.
// Notice the trailing space on each line: this acts as an automatic "eraser"
// for the previous frame as the train moves left!
private static readonly D51_FRAMES: string[][] = [
[
' ==== ________ ___________ ',
' _D _| |_______/ \\__I_I_____===__|_________| ',
' |(_)--- | H\\________/ | | =|___ ___| ',
' / | | H | | | | ||_| |_|| ',
' | | | H |__--------------------| [___] | ',
' | ________|___H__/__|_____/[][]~\\_______| | ',
' |/ | |-----------I_____I [][] [] D |=======| ',
'__/ =| o |=-O=====O=====O=====O \\ ____Y___________| ',
' |/-=|___|= || || || |_____/~\\___/ ',
' \\_/ \\__/ \\__/ \\__/ \\__/ \\_/ ',
],
[
' ==== ________ ___________ ',
' _D _| |_______/ \\__I_I_____===__|_________| ',
' |(_)--- | H\\________/ | | =|___ ___| ',
' / | | H | | | | ||_| |_|| ',
' | | | H |__--------------------| [___] | ',
' | ________|___H__/__|_____/[][]~\\_______| | ',
' |/ | |-----------I_____I [][] [] D |=======| ',
'__/ =| o |=-~O====O====O====O~ \\ ____Y___________| ',
' |/-=|___|= || || || |_____/~\\___/ ',
' \\_/ \\__/ \\__/ \\__/ \\__/ \\_/ ',
],
[
' ==== ________ ___________ ',
' _D _| |_______/ \\__I_I_____===__|_________| ',
' |(_)--- | H\\________/ | | =|___ ___| ',
' / | | H | | | | ||_| |_|| ',
' | | | H |__--------------------| [___] | ',
' | ________|___H__/__|_____/[][]~\\_______| | ',
' |/ | |-----------I_____I [][] [] D |=======| ',
'__/ =| o |=-~~O===O===O===O~~ \\ ____Y___________| ',
' |/-=|___|= || || || |_____/~\\___/ ',
' \\_/ \\__/ \\__/ \\__/ \\__/ \\_/ ',
],
]
public async Exec(
_: SimpleStream<string>,
stdout: SimpleStream<string>,
__: Item,
args: string[],
): Promise<number> {
// Original `sl` behavior flags
const isFly = args.includes('-F')
// Terminal size assumptions since they aren't provided by the API
const termWidth = 100
const trainWidth = Sl.D51_FRAMES[0][0].length
// The train starts off-screen to the right and ends completely off-screen to the left
const startX = termWidth
const endX = -trainWidth
// Clear screen (new page using form feed)
stdout.emit('\f')
let frameIdx = 0
for (let x = startX; x >= endX; x--) {
const frame = Sl.D51_FRAMES[frameIdx % Sl.D51_FRAMES.length]
// If the `-F` flag is passed, the train "flies" upwards as it moves forward
let startY = isFly ? Math.floor(x / 4) + 2 : 5
for (let y = 0; y < frame.length; y++) {
const line = frame[y]
let outLine = line
let cursorX = x
// When the train hits the left edge, we must truncate the string
// and lock the drawing cursor to X=0 to prevent terminal wrapping artifacts
if (x < 0) {
cursorX = 0
outLine = line.substring(-x)
}
const cursorY = startY + y
// Only render if within vertical bounds and there's text left to draw
if (cursorY >= 0 && outLine.length > 0) {
// Send absolute cursor positioning sequence
stdout.emit(`\0cma;${cursorX};${cursorY}\0`)
// Render the frame line
stdout.emit(outLine)
}
}
// Artificial delay to pace the animation
await this.sleep(40)
frameIdx++
console.log(frameIdx)
}
// Return the cursor back to a safe position to give shell control back seamlessly
stdout.emit(`\0cma;0;20\0\n`)
return 0 // POSIX successful exit code
}
/**
* Utility method to pause execution to pace the animation frames.
*/
private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
}

26
src/program/Touch.ts Normal file
View File

@@ -0,0 +1,26 @@
import { Item } from '../fs/Item'
import type { SimpleStream } from '../utils/SimpleStream'
import { Program } from './Program'
export class Touch extends Program {
constructor() {
super()
}
async Exec(_: SimpleStream<string>, stdout: SimpleStream<string>, workdir: Item, args: string[]): Promise<number> {
stdout.emit(`touching children, please wait...\n`)
const path = args.slice(1).join()
const item = await Item.open(path.includes('/') ? path : `${workdir.GetPath()}${workdir.GetPath().endsWith('/') ? '' : '/'}${path}`)
stdout.emit(`does ${item.GetPath()} exist? ${await item.Exists()}\n`)
if (await item.Exists()) {
stdout.emit("touch: the file already exists.\n")
return 1
}
item.Create()
return 0
}
}

22
src/shell/Shell.ts Normal file
View File

@@ -0,0 +1,22 @@
import type { Item } from '../fs/Item'
import type { Program } from '../program/Program'
import type { Terminal } from '../terminal/Terminal'
import type { EventBroadcaster } from '../utils/EventBroadcaster'
export abstract class Shell {
readonly abstract Version: string
readonly abstract Name: string
broadcaster: EventBroadcaster
terminal: Terminal
constructor(broadcaster: EventBroadcaster, terminal: Terminal) {
this.broadcaster = broadcaster
this.terminal = terminal
}
abstract LoadProgram(program: Program, name: string): void
abstract ExecuteProgram(name: string, args: string[]): void
abstract Init(): Promise<void>
abstract HandleKeyInput(key: string, isCharacter: boolean): void
abstract SetWorkingDirectory(directory: Item): Promise<void>
}

359
src/shell/wush/Wush.ts Normal file
View File

@@ -0,0 +1,359 @@
// Web-Uno Shell
// the best name I could come up with lol
import { Clear } from '../../program/Clear'
import { Eval } from '../../program/Eval'
import { Loadprg } from '../../program/Loadprg'
import { Lsprg } from '../../program/Lsprg'
import { Info } from '../../program/Info'
import { Program } from '../../program/Program'
import { Terminal } from '../../terminal/Terminal'
import { EventBroadcaster } from '../../utils/EventBroadcaster'
import { SimpleStream } from '../../utils/SimpleStream'
import { Shell } from '../Shell'
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'
import { Cd } from '../../program/Cd'
// import { Pwd } from '../program/Pwd'
// import { Cd } from '../program/Cd'
export class Wush extends Shell {
public readonly Version = "0.1.0"
public readonly Name = "wush"
// buffer
private buffer: string[] = []
private bufferPos: number = 0
private history: string[] = []
private historyPos: number = 0
// exec stuff
/**
* -1 if the exec is currently running and anything else when it's not
*/
private execExitCode: number = 0
// todo: workers
// private workersAllowed: boolean = false
// streams
readonly stdin: SimpleStream<string>
readonly stdout: SimpleStream<string>
private programs: { [name: string]: Program } = {}
private workingDirectory: Item = null as unknown as Item // workdir is initialized in Init so this should be safe
constructor(broadcaster: EventBroadcaster, terminal: Terminal) {
super(broadcaster, terminal)
// create streams
this.stdin = new SimpleStream<string>()
this.stdout = new SimpleStream<string>()
}
async Init() {
this.broadcaster.on('keydown', (key: string, isCharacter: boolean) =>
this.HandleKeyInput(key, isCharacter),
)
// load workdir
this.workingDirectory = await Item.Root()
// load core programs
this.programs['clear'] = new Clear()
this.programs['eval'] = new Eval()
this.programs['loadprg'] = new Loadprg(this)
this.programs['lsprg'] = new Lsprg(this)
this.programs['info'] = new Info()
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.programs['pwd'] = new Pwd()
this.programs['cd'] = new Cd(this)
this.stdout.on(data => this.WriteEscapedString(data))
this.Prompt()
}
GetPrograms(): { [name: string]: Program } {
return this.programs
}
LoadProgram(program: Program, name: string) {
this.programs[name] = program
}
UnloadProgram(name: string) {
delete this.programs[name]
}
ExecuteProgram(name: string, args: string[]) {
this.programs[name].Exec(this.stdin, this.stdout, this.workingDirectory, args)
.then(code => {
this.execExitCode = code != -1 ? code : -2
})
.catch((e) => {
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
// and if not, set it to -2 to indicate that it didn't exit with a valid code
if (this.execExitCode === -1)
this.execExitCode = -2
// this.terminal.Write(`The program exited with exit code ${this.execExitCode}.`)
this.Prompt()
})
}
Prompt() {
this.terminal.Write(`boykisser in ${this.workingDirectory.GetPath()} -> `)
}
/**
* Changes the working directory
* @param directory the directory to enter into
* @throws an error if the directory cannot be opened
*/
async SetWorkingDirectory(directory: Item) {
if (!(await directory.Exists()) || !directory.IsDirectory())
throw new Error(`Directory ${directory.GetPath()} doesn't exist`)
this.workingDirectory = directory
}
WriteStdin(data: string) {
this.stdin.emit(data)
}
WriteEscapedString(data: string) {
let buf = data.split('')
buf.forEach((char) => {
if (!this.ProcessSimpleControlCode(char))
this.terminal.Write(char)
})
}
PushToBuffer(text: string) {
text.split('').forEach(char => {
this.buffer.splice(this.bufferPos, 0, char)
this.bufferPos++
})
// console.log(this.buffer)
}
RemoveCharFromBuffer(amount: number, index: number) {
this.buffer.splice(index - amount, amount)
this.bufferPos -= amount
// console.log(this.buffer)
}
MoveBufferPos(index: number, absolute: boolean = false) {
this.bufferPos = Math.max(absolute ? index : this.bufferPos + index, 0)
}
FlushBuffer() {
this.buffer = []
this.bufferPos = 0
}
ExecuteBuffer() {
// parse the buffer
const state: {
inString: boolean
inHardString: boolean
inAndOperator: boolean
} = {
inString: false,
inHardString: false,
inAndOperator: false,
}
const commands: {
args: string[]
stdin: null | SimpleStream<string>
stdout: null | SimpleStream<string>
}[] = []
const textBuf =
this.buffer
.join('')
.split('')
textBuf.forEach((char, i) => {
// spacing
if (char === ' ' && !(state.inString || state.inHardString )) {
if (state.args[state.args.length - 1] !== '')
state.args.push("")
// string ("") handling
} else if (char === '"' && !state.inHardString) {
state.inString = !state.inString
// hard string ('') handling
} else if (char === "'" && !state.inString) {
state.inHardString = !state.inHardString
// && operator
} else if (char === '&' || char === '|' && !(state.inString || state.inHardString )) {
if (state.inAndOperator) {
if (state.args[state.args.length - 1] === '')
state.args[state.args.length - 1] = `${char}${char}`
else state.args.push(`${char}${char}`)
state.args.push("")
state.inAndOperator = false
} else if (this.buffer[i + 1] !== char) {
state.args[state.args.length - 1] += char
} else state.inAndOperator = true
// adding the char to the current argument
} else {
state.args[state.args.length - 1] += char
}
})
this.FlushBuffer()
console.log(`Executing ${state.args[0]} with args '${state.args}'`)
this.execExitCode = -1
if (this.programs[state.args[0]] instanceof Program) {
this.ExecuteProgram(state.args[0], state.args)
} else {
this.execExitCode = 0
// don't print anything if there are no arguments
if (state.args[0].length !== 0) {
this.terminal.Write(`wush: unknown command: ${state.args[0]}.`)
this.terminal.MoveCursor(0, 1, { x: true, y: false })
this.execExitCode = 127
}
this.Prompt()
}
}
ProcessSimpleControlCode(code: string): boolean {
switch (code) {
case '\f':
this.terminal.NewPage()
return true
case '\n':
this.terminal.MoveCursor(0, 1, { x: true, y: false })
return true
default:
return false
}
}
// todo: actual processing
ProcessAdvancedControlCode(complex: string): boolean {
if (!complex.match(/\\(.*;)/gm))
return false
return true
// const code = matches[]
}
HandleKeyInput(key: string, isCharacter: boolean) {
if (this.execExitCode === -1) console.log('program running')
if (!isCharacter) {
switch (key) {
case 'ArrowLeft':
if (this.bufferPos > 0) {
this.terminal.MoveCursor(-1, 0)
this.MoveBufferPos(-1)
}
break
case 'ArrowRight':
if (this.bufferPos < this.buffer.length) {
this.terminal.MoveCursor(1, 0)
this.MoveBufferPos(1)
}
break
case 'ArrowUp':
if (this.historyPos < this.history.length) {
if (this.historyPos === 0)
this.history.splice(0, 0, this.buffer.join(''))
this.historyPos++
console.log(this.historyPos)
console.log(this.history[this.historyPos])
}
break
case 'ArrowDown':
if (this.historyPos > 0) {
this.historyPos--
if (this.historyPos === 0)
this.history.splice(0, 1)
console.log(this.historyPos)
console.log(this.history[this.historyPos])
}
break
case 'Backspace':
// don't erase anything if there's nothing left in the buffer
if (this.bufferPos === 0)
break
this.terminal.RemoveCell()
this.RemoveCharFromBuffer(1, this.bufferPos)
// this.buffer.splice(this.bufferPos, 1)
this.terminal.MoveCursor(-1, 0)
break
case 'Delete':
if (this.bufferPos >= this.buffer.length || this.buffer.length <= 0)
break
this.bufferPos++
this.terminal.MoveCursor(1, 0)
this.terminal.RemoveCell()
this.RemoveCharFromBuffer(1, this.bufferPos)
this.terminal.MoveCursor(-1, 0)
break
case 'Enter':
// send the buffer to stdin if an exec is running
if (this.execExitCode === -1) {
this.WriteStdin(`${this.buffer.join('')}\n`)
this.FlushBuffer()
} else {
// "execute" the buffer
this.terminal.MoveCursor(0, 1, { x: true, y: false })
this.history.splice(0, 0, this.buffer.join(''))
this.ExecuteBuffer()
}
break
case 'F5':
location.reload()
break
}
} else {
this.historyPos = 0
// push the character into the buffer
this.terminal.InsertCell(key)
this.PushToBuffer(key)
this.terminal.MoveCursor(1, 0)
}
}
}

16
src/styles/app.scss Normal file
View File

@@ -0,0 +1,16 @@
@forward "terminal.scss";
@use "./colors.scss" as colors;
body {
margin: 10px;
background: colors.$terminal-background;
}
* {
box-sizing: border-box;
}
::selection {
background: colors.$terminal-white;
color: colors.$terminal-background;
}

2
src/styles/colors.scss Normal file
View File

@@ -0,0 +1,2 @@
$terminal-background: #141414;
$terminal-white: #fff;

12
src/styles/cursor.scss Normal file
View File

@@ -0,0 +1,12 @@
@use "colors.scss" as colors;
#cursor {
top: 0;
left: 0;
width: 1px;
height: 20px;
position: absolute;
background: colors.$terminal-white;
transition: .2s cubic-bezier(0, 1, 0.3, 1);
}

30
src/styles/terminal.scss Normal file
View File

@@ -0,0 +1,30 @@
@use "colors.scss" as colors;
@forward "cursor.scss";
#terminal {
background: colors.$terminal-background;
color: colors.$terminal-white;
font-family: "CaskaydiaCove", "CaskaydiaCove Nerd Font", "JetBrains Mono", "JetBrains Mono Nerd", monospace;
::-moz-selection {
background: colors.$terminal-white;
color: colors.$terminal-background;
}
p {
margin: 0;
height: fit-content;
word-wrap: break-word;
text-wrap: nowrap;
display: flex;
span {
display: inline-block;
}
}
.line {
width: fit-content
}
}

View File

@@ -0,0 +1,6 @@
export type CursorPosition = {
col: number,
row: number,
}
export type CursorStyle = 'bar' | 'underline' | 'cell' | 'cell_hollow'

193
src/terminal/Terminal.ts Normal file
View File

@@ -0,0 +1,193 @@
import { SetCurrentTerminal } from '../app'
import type { Shell } from '../shell/Shell'
import sqs from '../utils/sqs'
import type { CursorPosition, CursorStyle } from './CursorProperties'
export class Terminal {
public static readonly Version = "0.1.1"
private terminal: HTMLElement
private cursor: HTMLElement
private cursorStyle: CursorStyle = 'bar'
private cellHeight = 0
private cellWidth = 0
private cursorPosition: CursorPosition = {
col: 0,
row: 0,
}
private shell?: Shell
constructor() {
SetCurrentTerminal(this)
this.terminal = sqs('#terminal')
this.cursor = sqs('#cursor')
this.SetCursorStyle('bar')
this.NewPage()
}
async LoadShell(shell: Shell) {
this.shell = shell
await this.shell.Init()
}
GetShell(): Shell | undefined {
return this.shell
}
NewPage() {
this.ResetCellSize()
this.terminal.innerHTML = ''
this.SetCursorPosition(0, 0)
}
AppendLine() {
const paragraph = document.createElement('p')
paragraph.style.height = `${this.cellHeight}px`
paragraph.id = `line-${this.cursorPosition.row}`
paragraph.className = 'line'
this.terminal.appendChild(paragraph)
this.UpdateLines()
paragraph.scrollIntoView({behavior: 'smooth'})
}
UpdateLines() {
const lines = new Array(...this.terminal.children)
lines.forEach((line, i) => line.id = `line-${i}`)
}
/**
* @returns index of the last line on the page. -1 if there are no lines
*/
GetLastLineIndex(): number {
return this.terminal.children.length - 1
}
Write(text: string) {
text.split('').forEach((char) => {
this.SetCell(char)
this.MoveCursor(1, 0)
})
}
SetCell(char: string) {
const selector = `#line-${this.cursorPosition.row} .cell-${this.cursorPosition.col}`
// adjust for the cursor
if (!document.querySelector(`#line-${this.cursorPosition.row}`)) {
for (let i = this.terminal.children.length - 1; i < this.cursorPosition.row; i++) {
this.AppendLine()
}
}
if (!document.querySelector(selector)) {
const line = sqs(`#line-${this.cursorPosition.row}`)
for (let i = line.children.length; i < this.cursorPosition.col + 1; i++) {
const cell = document.createElement('span')
cell.className = `cell-${i}`
cell.style.width = `${this.cellWidth}px`
cell.style.height = `${this.cellHeight}px`
line.appendChild(cell)
}
}
sqs(selector).innerText = char[0]
}
UpdateCells() {
const cells = new Array(...sqs(`#line-${this.cursorPosition.row}`).children)
cells.forEach((cell, i) => cell.className = `cell-${i}`)
}
InsertCell(char: string) {
const cell = document.createElement('span')
cell.className = `cell-${this.cursorPosition.col}`
cell.style.width = `${this.cellWidth}px`
cell.style.height = `${this.cellHeight}px`
cell.innerText = char[0]
sqs(`#line-${this.cursorPosition.row} .cell-${this.cursorPosition.col}`).insertAdjacentElement('beforebegin', cell)
this.UpdateCells()
}
RemoveCell() {
try {
sqs(`#line-${this.cursorPosition.row} .cell-${this.cursorPosition.col - 1}`).remove()
} catch (_) {
} finally {
this.UpdateCells()
}
}
ResetCellSize() {
// dynamically determine cell size using dom
const cell = document.createElement('span')
cell.textContent = 'A'
this.terminal.appendChild(cell)
this.cellWidth = cell.offsetWidth
this.cellHeight = cell.offsetHeight
cell.remove()
}
GetHeightCells(): number {
return this.terminal.clientHeight / this.cellHeight
}
GetWidthCells(): number {
return this.terminal.clientWidth / this.cellWidth
}
UpdateCursor() {
this.SetCursorStyle(this.cursorStyle)
this.cursor.style.left = `${this.cursorPosition.col * this.cellWidth + this.terminal.offsetLeft}px `
this.cursor.style.top = `${this.cursorPosition.row * this.cellHeight + this.terminal.offsetTop}px`
}
GetCursorPosition(): CursorPosition {
return this.cursorPosition
}
SetCursorPosition(col: number, row: number) {
this.cursorPosition.col = Math.max(col, 0)
this.cursorPosition.row = Math.max(row, 0)
try {
sqs(`#line-${this.cursorPosition.row} .cell-${this.cursorPosition.col}`)
} catch (_) {
this.SetCell(' ')
} finally {
this.UpdateCursor()
}
}
MoveCursor(col: number, row: number, absolute: { x: boolean; y: boolean } = { x: false, y: false }) {
this.SetCursorPosition(
!absolute.x ? this.cursorPosition.col + col : col,
!absolute.y ? this.cursorPosition.row + row : row,
)
}
SetCursorStyle(style: CursorStyle) {
switch (style) {
default:
this.cursor.style.width = `${this.cellWidth}px`
this.cursor.style.height = `${this.cellHeight}px`
}
}
}

View File

@@ -0,0 +1,43 @@
export class EventBroadcaster {
private listeners: Map<string, Set<Function>> = new Map()
/**
* Assigns a listener for the specified event
* @param event event name
* @param listener function to call when the event is emitted
*/
on(event: string, listener: Function) {
if (!this.listeners.has(event))
this.listeners.set(event, new Set())
this.listeners.get(event)!.add(listener)
}
/**
* Removes a listener from the specified event
* @param event event name
* @param listener function to remove
*/
off(event: string, listener: Function) {
if (this.listeners.has(event))
this.listeners.get(event)!.delete(listener)
}
/**
* Removes all listeners for the specified event
* @param event event name
*/
clear(event: string) {
if (this.listeners.has(event))
this.listeners.get(event)!.clear()
}
/**
* Emits an event with the given arguments
* @param event event name
* @param args arguments to pass to the listeners
*/
emit(event: string, ...args: any[]) {
if (this.listeners.has(event))
this.listeners.get(event)!.forEach((listener) => listener(...args))
}
}

50
src/utils/SimpleStream.ts Normal file
View File

@@ -0,0 +1,50 @@
export class SimpleStream<T> {
private listeners: Set<Function> = new Set()
/**
* Attaches a listener to the stream
* @param listener the function to call when new data is streamed
*/
on(listener: (data: T) => any): void {
this.listeners.add(listener)
}
/**
* Attaches a one-time listener to the stream
* @param listener the function to call when new data is streamed
*/
once(listener: (data: T) => any): void {
const func = (data: T) => {
listener(data)
this.off(func)
}
this.on(func)
}
/**
* Patiently waits until new data is streamed, then returns it
* @returns a promise for a single chunk of streamed data
*/
wait(): Promise<T> {
return new Promise<T>(resolve => {
this.once(data => resolve(data))
})
}
/**
* Removes a listener from the stream
* @param listener the function to remove
*/
off(listener: Function): void {
this.listeners.delete(listener)
}
/**
* Streams data
* @param data the data to stream
*/
emit(data: T): void {
this.listeners.forEach((listener) => listener(data))
}
}

13
src/utils/sqs.ts Normal file
View File

@@ -0,0 +1,13 @@
/**
* a "safe" wrapper for querySelector. ugly as hell but whatever
* @param query css selector query
* @returns first element matching the query
*/
export default function sqs<T extends HTMLElement>(query: string): T {
const element = document.querySelector(query)
if (!(element instanceof HTMLElement))
throw new Error(`Failed to process a short element query: ${query}`)
return element as T
}

5
vite.config.ts Normal file
View File

@@ -0,0 +1,5 @@
export default {
server: {
port: 3000,
},
}