Compare commits

...

46 Commits

Author SHA1 Message Date
binekrasik
16c81e5d07 revert: "add notice" 2026-05-23 14:55:36 +02:00
binekrasik
085cec8dc0 chore: add notice 2026-05-22 23:57:40 +02:00
binekrasik
071d4e3aa4 chore: addition to the previous commit 2026-05-22 23:55:26 +02:00
binekrasik
d76119918d chore: add info 2026-05-22 23:54:10 +02:00
binekrasik
3798a53395 chore: rewrite loadprg & fix some issues 2026-05-22 23:27:04 +02:00
binekrasik
4777552106 sync: wip changes 2026-05-22 14:48:00 +02:00
binekrasik
62038c2814 fix: build errors 2026-05-21 23:42:51 +02:00
binekrasik
646a9f6b7a chore: fix bugs & qol 2026-05-21 23:42:14 +02:00
binekrasik
255cc6a858 chore: cleanup imports 2026-05-21 19:15:33 +02:00
binekrasik
e0269c9b6a sync: wip changes 2026-05-21 13:47:27 +02:00
binekrasik
0577ee49cf chore: bump versions 2026-05-20 23:22:16 +02:00
binekrasik
729c8a5fd1 docs: update tested browsers 2026-05-20 23:21:19 +02:00
binekrasik
0649843821 chore: offload user input to InputManager 2026-05-20 22:46:59 +02:00
binekrasik
abe52acaa7 sync: sync wip changes 2026-05-20 13:10:34 +02:00
binekrasik
b5089251ff chore: fix path syntax in core programs 2026-05-19 23:19:36 +02:00
binekrasik
1d00cf6deb feat: complete shell revamp 2026-05-19 21:42:14 +02:00
binekrasik
4a484dd546 feat: improve bash-like command parsing 2026-05-19 18:22:43 +02:00
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
47 changed files with 4537 additions and 38 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,12 @@ 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.3.1 (Chromium 148.0.7778.167) (64-bit)`
- `Firefox 151.0 (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

@@ -1,7 +1,7 @@
{
"name": "webshell",
"private": true,
"version": "0.0.0",
"version": "0.2.1",
"type": "module",
"scripts": {
"dev": "vite",
@@ -10,6 +10,9 @@
},
"devDependencies": {
"typescript": "~5.9.3",
"vite": "^8.0.1"
"vite": "^8.0.8"
},
"dependencies": {
"sass": "^1.99.0"
}
}

56
src/app.ts Normal file
View File

@@ -0,0 +1,56 @@
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.2.1"
export const INFO_COMMENT: string | null = null
// 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))
}

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

@@ -0,0 +1,384 @@
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 = Item.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
}
IsReadable(): boolean {
return true
}
IsWritable(): boolean {
return true
}
IsExecutable(): boolean {
return this.executable
}
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)
}
GetData(): 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)
}
/**
* Copies this item to the destination, replacing any existing destination.
*/
async Copy(destination: Item): Promise<void> {
if (destination.path === this.path) {
throw new Error(`Cannot copy item onto itself: ${this.path}`)
}
if (!(await this.Exists())) {
throw new Error(`Cannot copy item that has not been created: ${this.path}`)
}
await this.load()
if (await destination.Exists()) {
await destination.Delete()
}
const childrenToCopy = this.isDirectory ? await this.List() : []
destination.isDirectory = this.isDirectory
destination.executable = this.executable
destination.data = this.isDirectory ? null : this.data
destination.children = []
await destination.Create()
if (this.isDirectory) {
await Promise.all(childrenToCopy.map(async child => {
const childDestinationPath = Item.NormalizePath(
`${destination.GetPath()}/${child.GetName()}`
)
const destChild = child.IsDirectory()
? await Item.openDir(childDestinationPath)
: await Item.open(childDestinationPath)
await child.Copy(destChild)
}))
await destination.load()
}
}
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
}
public static 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()
})
}

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

@@ -0,0 +1,32 @@
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>, workdir: Item, args: string[]): Promise<number> {
if (args.length < 2) {
stdout.emit("cat: error: missing path argument\n")
return 1
}
const path = Item.NormalizePath(args[1].startsWith('/') ? args[1] : `${workdir.GetPath()}/${args[1]}`)
let item: Item
try {
item = await Item.open(path)
} catch (err) {
stdout.emit(`cat: error: item ${path} is a directory\n`)
return 2
}
if (!(await item.Exists())) {
stdout.emit(`cat: error: item ${item.GetPath()} doesn't exist.\n`)
return 3
}
stdout.emit(`${item.GetData()}`)
stdout.emit('\x1B[0;30m\x1B[47m EOF \x1B[0m\n')
return 0
}
}

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

@@ -0,0 +1,39 @@
import { Item } from '../fs/Item'
import type { Shell } from '../shell/Shell'
import type { SimpleStream } from '../utils/SimpleStream'
import { Program } from './Program'
export class Cd 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> {
// open the target directory, default to workdir
const item = args[1]
? await Item.openDir(Item.NormalizePath(
args[1].startsWith('/')
? args[1]
: `${workdir.GetPath()}/${args[1]}`
))
: workdir
if (args[1] && !item.IsDirectory()) {
stdout.emit("cd: error: the provided path is not a directory\n")
return 1
}
if (!(await item.Exists())) {
stdout.emit(`cd: error: path ${item.GetPath()} doesn't exist\n`)
return 2
}
await this.shell.SetWorkingDirectory(item)
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('\x1b[2J\x1b[H')
return 0
}
}

59
src/program/Cp.ts Normal file
View File

@@ -0,0 +1,59 @@
import { Item } from '../fs/Item'
import type { SimpleStream } from '../utils/SimpleStream'
import { Program } from './Program'
export class Cp extends Program {
constructor() {
super()
}
async Exec(_: SimpleStream<string>, stdout: SimpleStream<string>, workdir: Item, args: string[]): Promise<number> {
if (args.length < 3) {
stdout.emit("cp: error: missing the first and/or second path arguments\n")
return 1
}
let item1: Item
let item2: Item
let destIsDir = false
// figure out if the items are files or directories
try {
item1 = await Item.openDir(Item.NormalizePath(args[1].startsWith('/') ? args[1] : `${workdir.GetPath()}/${args[1]}`))
} catch {
item1 = await Item.open(Item.NormalizePath(args[1].startsWith('/') ? args[1] : `${workdir.GetPath()}/${args[1]}`))
}
try {
item2 = await Item.open(Item.NormalizePath(args[2].startsWith('/') ? args[2] : `${workdir.GetPath()}/${args[2]}`))
} catch {
item2 = await Item.openDir(Item.NormalizePath(args[2].startsWith('/') ? args[2] : `${workdir.GetPath()}/${args[2]}`))
destIsDir = true
}
if (!await item1.Exists()) {
stdout.emit(`cp: error: source item ${item1.GetPath()} does not exist.\n`)
return 2
}
if (await item2.Exists() && !destIsDir) {
stdout.emit(`cp: error: destination item ${item2.GetPath()} already exists.\n`)
return 2
}
// either copy the item into a destination directory or create a new copy
if (destIsDir) {
const destChild = await Item.open(Item.NormalizePath(`${item2.GetPath()}/${item1.GetName()}`))
await item1.Copy(destChild)
stdout.emit(`-> copied ${item1.GetPath()} -> ${destChild.GetPath()}\n`)
} else {
await item2.Create()
await item1.Copy(item2)
stdout.emit(`-> copied ${item1.GetPath()} -> ${item2.GetPath()}\n`)
}
return 0
}
}

15
src/program/Echo.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 Echo extends Program {
constructor() {
super()
}
async Exec(_: SimpleStream<string>, stdout: SimpleStream<string>, __: Item, args: string[]): Promise<number> {
stdout.emit(args.slice(1).join(' ') + '\n')
return 0
}
}

390
src/program/Edit.ts Normal file
View File

@@ -0,0 +1,390 @@
import { GetCurrentTerminal } from '../app'
import { Item } from '../fs/Item'
import type { SimpleStream } from '../utils/SimpleStream'
import { Program } from './Program'
type Viewport = {
cols: number
rows: number
headerRows: number
footerRows: number
contentHeight: number
}
type PromptState =
| { active: false }
| { active: true; label: string; value: string; handler: (value: string) => Promise<void> }
export class Edit extends Program {
constructor() {
super()
}
async Exec(stdin: SimpleStream<string>, stdout: SimpleStream<string>, workdir: Item, args: string[]): Promise<number> {
if (args.length < 2) {
stdout.emit('edit: error: missing path argument\n')
return 1
}
const terminal = GetCurrentTerminal()
const resolvePath = (path: string) => Item.NormalizePath(path.startsWith('/') ? path : `${workdir.GetPath()}/${path}`)
let filePath = resolvePath(args[1])
let file = await Item.open(filePath, '')
let fileExists = await file.Exists()
let lines = this.normalizeLines(file.GetData())
let dirty = false
let cursorLine = 0
let cursorCol = 0
let preferredCol = 0
let scrollLine = 0
let scrollCol = 0
let statusMessage = ''
let exitArmed = false
let promptState: PromptState = { active: false }
const getViewport = (): Viewport => {
const cols = Math.max(1, Math.floor(terminal.GetWidthCells()))
const rows = Math.max(1, Math.floor(terminal.GetHeightCells()))
const headerRows = rows >= 3 ? 1 : 0
const footerRows = rows >= 2 ? 1 : 0
const contentHeight = Math.max(1, rows - headerRows - footerRows)
return { cols, rows, headerRows, footerRows, contentHeight }
}
const formatLine = (text: string, width: number) => {
if (text.length > width)
return text.slice(0, width)
return text.padEnd(width, ' ')
}
const clampCursor = () => {
cursorLine = Math.max(0, Math.min(cursorLine, lines.length - 1))
const lineLength = lines[cursorLine].length
cursorCol = Math.max(0, Math.min(cursorCol, lineLength))
preferredCol = cursorCol
}
const ensureCursorVisible = (viewport: Viewport) => {
if (cursorLine < scrollLine)
scrollLine = cursorLine
if (cursorLine >= scrollLine + viewport.contentHeight)
scrollLine = cursorLine - viewport.contentHeight + 1
if (cursorCol < scrollCol)
scrollCol = cursorCol
if (cursorCol >= scrollCol + viewport.cols)
scrollCol = cursorCol - viewport.cols + 1
const maxScrollLine = Math.max(0, lines.length - viewport.contentHeight)
scrollLine = Math.min(scrollLine, maxScrollLine)
scrollCol = Math.max(0, scrollCol)
}
const setStatus = (message: string) => {
statusMessage = message
}
const openFile = async (path: string) => {
try {
const resolved = resolvePath(path)
const nextFile = await Item.open(resolved, '')
const exists = await nextFile.Exists()
const content = nextFile.GetData()
filePath = resolved
file = nextFile
fileExists = exists
lines = this.normalizeLines(content)
dirty = false
cursorLine = 0
cursorCol = 0
preferredCol = 0
scrollLine = 0
scrollCol = 0
setStatus(exists ? `Opened ${filePath}` : `New file ${filePath}`)
} catch (e) {
setStatus(`Open failed: ${e instanceof Error ? e.message : String(e)}`)
}
}
const saveFile = async () => {
const data = lines.join('\n')
try {
if (fileExists) {
await file.Write(data)
} else {
const created = await Item.open(filePath, data)
await created.Create()
file = created
fileExists = true
}
dirty = false
exitArmed = false
setStatus(`Saved ${filePath}`)
} catch (e) {
setStatus(`Save failed: ${e instanceof Error ? e.message : String(e)}`)
}
}
const startPrompt = (label: string, handler: (value: string) => Promise<void>) => {
promptState = { active: true, label, value: '', handler }
}
const render = () => {
const viewport = getViewport()
const footerRow = viewport.headerRows + viewport.contentHeight + (viewport.footerRows ? 1 : 0)
ensureCursorVisible(viewport)
let output = '\x1b[2J'
if (viewport.headerRows) {
const headerText = `EDIT - ${filePath}${dirty ? ' *' : ''}`
output += `\x1b[1;1H${formatLine(headerText, viewport.cols)}`
}
const contentStartRow = viewport.headerRows + 1
for (let i = 0; i < viewport.contentHeight; i++) {
const lineIndex = scrollLine + i
const lineText = lines[lineIndex] ?? ''
const visible = lineText.slice(scrollCol, scrollCol + viewport.cols)
output += `\x1b[${contentStartRow + i};1H${formatLine(visible, viewport.cols)}`
}
if (viewport.footerRows) {
const positionText = `Ln ${cursorLine + 1}, Col ${cursorCol + 1}`
const shortcuts = 'F2 Save F3 Open F10 Exit'
const footerText = promptState.active
? `${promptState.label}${promptState.value}`
: `${shortcuts} ${positionText}${statusMessage ? ` ${statusMessage}` : ''}`
output += `\x1b[${footerRow};1H${formatLine(footerText, viewport.cols)}`
}
if (promptState.active) {
const cursorRow = footerRow
const cursorColPos = Math.min(viewport.cols, promptState.label.length + promptState.value.length + 1)
output += `\x1b[${cursorRow};${cursorColPos}H`
} else {
const cursorRow = contentStartRow + (cursorLine - scrollLine)
const cursorColPos = 1 + (cursorCol - scrollCol)
output += `\x1b[${cursorRow};${cursorColPos}H`
}
stdout.emit(output)
}
render()
while (true) {
const key = await stdin.wait()
if (promptState.active) {
if (key === 'Escape') {
promptState = { active: false }
setStatus('Canceled')
render()
continue
}
if (key === 'Enter') {
const { handler } = promptState
const value = promptState.value.trim()
promptState = { active: false }
if (value.length > 0) {
await handler(value)
} else {
setStatus('Canceled')
}
render()
continue
}
if (key === 'Backspace') {
const nextValue: string = promptState.value.slice(0, -1)
promptState = {
active: true,
label: promptState.label,
value: nextValue,
handler: promptState.handler,
}
render()
continue
}
if (key.length === 1) {
const nextValue: string = promptState.value + key
promptState = {
active: true,
label: promptState.label,
value: nextValue,
handler: promptState.handler,
}
render()
}
continue
}
if (exitArmed && key !== 'F10')
exitArmed = false
switch (key) {
case 'F2':
await saveFile()
render()
continue
case 'F3':
startPrompt('Open: ', openFile)
render()
continue
case 'F10':
if (dirty && !exitArmed) {
exitArmed = true
setStatus('Unsaved changes. Press F10 again to exit.')
render()
continue
}
stdout.emit('\x1b[2J\x1b[H')
return 0
case 'ArrowLeft':
if (cursorCol > 0) {
cursorCol -= 1
} else if (cursorLine > 0) {
cursorLine -= 1
cursorCol = lines[cursorLine].length
}
preferredCol = cursorCol
render()
continue
case 'ArrowRight': {
const lineLength = lines[cursorLine].length
if (cursorCol < lineLength) {
cursorCol += 1
} else if (cursorLine < lines.length - 1) {
cursorLine += 1
cursorCol = 0
}
preferredCol = cursorCol
render()
continue
}
case 'ArrowUp':
cursorLine = Math.max(0, cursorLine - 1)
cursorCol = Math.min(preferredCol, lines[cursorLine].length)
render()
continue
case 'ArrowDown':
cursorLine = Math.min(lines.length - 1, cursorLine + 1)
cursorCol = Math.min(preferredCol, lines[cursorLine].length)
render()
continue
case 'Home':
cursorCol = 0
preferredCol = cursorCol
render()
continue
case 'End':
cursorCol = lines[cursorLine].length
preferredCol = cursorCol
render()
continue
case 'PageUp': {
const viewport = getViewport()
cursorLine = Math.max(0, cursorLine - viewport.contentHeight)
cursorCol = Math.min(preferredCol, lines[cursorLine].length)
render()
continue
}
case 'PageDown': {
const viewport = getViewport()
cursorLine = Math.min(lines.length - 1, cursorLine + viewport.contentHeight)
cursorCol = Math.min(preferredCol, lines[cursorLine].length)
render()
continue
}
case 'Backspace':
if (cursorCol > 0) {
const line = lines[cursorLine]
lines[cursorLine] = line.slice(0, cursorCol - 1) + line.slice(cursorCol)
cursorCol -= 1
preferredCol = cursorCol
dirty = true
} else if (cursorLine > 0) {
const current = lines[cursorLine]
cursorLine -= 1
cursorCol = lines[cursorLine].length
lines[cursorLine] += current
lines.splice(cursorLine + 1, 1)
preferredCol = cursorCol
dirty = true
}
render()
continue
case 'Delete': {
const line = lines[cursorLine]
if (cursorCol < line.length) {
lines[cursorLine] = line.slice(0, cursorCol) + line.slice(cursorCol + 1)
dirty = true
} else if (cursorLine < lines.length - 1) {
lines[cursorLine] = line + lines[cursorLine + 1]
lines.splice(cursorLine + 1, 1)
dirty = true
}
render()
continue
}
case 'Enter': {
const line = lines[cursorLine]
const before = line.slice(0, cursorCol)
const after = line.slice(cursorCol)
lines[cursorLine] = before
lines.splice(cursorLine + 1, 0, after)
cursorLine += 1
cursorCol = 0
preferredCol = 0
dirty = true
render()
continue
}
case 'Tab':
lines[cursorLine] = this.insertText(lines[cursorLine], cursorCol, ' ')
cursorCol += 4
preferredCol = cursorCol
dirty = true
render()
continue
}
if (key.length === 1) {
lines[cursorLine] = this.insertText(lines[cursorLine], cursorCol, key)
cursorCol += 1
preferredCol = cursorCol
dirty = true
render()
continue
}
clampCursor()
render()
}
}
private normalizeLines(content: string | null): string[] {
const normalized = (content ?? '').replace(/\r\n/g, '\n').replace(/\r/g, '\n')
const lines = normalized.split('\n')
return lines.length === 0 ? [''] : lines
}
private insertText(line: string, index: number, text: string): string {
return line.slice(0, index) + text + line.slice(index)
}
}

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

@@ -0,0 +1,35 @@
import type { Item } from '../fs/Item'
import type { SimpleStream } from '../utils/SimpleStream'
import { Program } from './Program'
export class Eval extends Program {
async Exec(stdin: SimpleStream<string>, stdout: SimpleStream<string>, _workdir: Item, args: string[]): Promise<number> {
const inlineArgs = args.slice(1)
const source = inlineArgs.length > 0
? inlineArgs.join(' ')
: await stdin.wait()
if (source.trim().length === 0) {
stdout.emit('eval: error: missing javascript input\n')
return 1
}
try {
this.RunJs(source, stdout)
} catch {
return 1
}
return 0
}
private RunJs(javascript: string, stdout: SimpleStream<string>) {
try {
// todo: pass workdir to the program
eval(javascript)
} catch (e) {
stdout.emit(`${String(e)}\n`)
throw e
}
}
}

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

@@ -0,0 +1,30 @@
import { GetCurrentTerminal, WEBSHELL_VERSION, INFO_COMMENT } 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('\n')
stdout.emit(" --> binekrasik's Webshell / ->\n\n")
stdout.emit(" Webshell and its components are licensed under the Apache 2.0 license unless stated otherwise.\n")
stdout.emit(" Source available at https://git.martinpetr.dev/binekrasik/webshell.\n\n\n")
stdout.emit(" --> Versions / ->\n\n")
stdout.emit(` Webshell ${WEBSHELL_VERSION}\n`)
stdout.emit(` Terminal ${Terminal.Version}\n`)
stdout.emit(` Shell '${GetCurrentTerminal().GetShell()?.Name}' version ${GetCurrentTerminal().GetShell()?.Version}\n\n\n`)
// print a comment for the current release (if any)
if (INFO_COMMENT) {
stdout.emit(" --> Version comment / ->\n\n")
stdout.emit(` ${INFO_COMMENT}\n\n\n`)
}
stdout.emit(" - Copyright (C) 2026 binekrasik -\n\n")
return 0
}
}

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

@@ -0,0 +1,69 @@
import { 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 path = Item.NormalizePath(args[1].startsWith('/') ? args[1] : `${workdir.GetPath()}/${args[1]}`)
let file: Item
try {
file = await Item.open(path)
} catch (err) {
stdout.emit(`loadprg: error: item ${path} is a directory\n`)
return 2
}
if (!(await file.Exists())) {
stdout.emit(`loadprg: error: item ${file.GetPath()} doesn't exist.\n`)
return 3
}
let programName: string | null = null
// check if we have a name argument
// use the file name if the program name wasn't explicitly specified
const nameArgIndex = args.findIndex(value => value === "--name")
if (nameArgIndex !== -1 && args[nameArgIndex + 1]) {
programName = args[nameArgIndex + 1]
} else programName = file.GetName()
// read the file
const javascript = file.GetData()
if (!javascript) {
stdout.emit(`loadprg: error: could not read the program data\n`)
return 3
}
// load the program
type EntrypointFunction = InstanceType<typeof Program>['Exec']
let programEntrypoint: EntrypointFunction
try {
// due to some inconveniences and limitations,
// we only allow for loading the program main method
// instead of an entire program class.
programEntrypoint = new Function("stdin", "stdout", "workdir", "args", javascript) as EntrypointFunction
} catch (err) {
stdout.emit(`loadprg: error: could not load the program: ${err}\n`)
stdout.emit(`-> Stacktrace: ${(err as Error).stack}\n`)
return 4
}
// create a wrapper class for the program
const wrapper: Program = new (class ProgramWrapper extends Program { Exec = programEntrypoint })
this.shell.LoadProgram(wrapper, programName)
stdout.emit(`-> Loaded program from ${file.GetPath()} as ${programName}\n`)
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> {
// open the target directory, default to workdir
const item = args[1]
? await Item.openDir(Item.NormalizePath(
args[1].startsWith('/')
? args[1]
: `${workdir.GetPath()}/${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
}
stdout.emit(`-> Listing contents of item: '${item.GetPath()}'\n`)
stdout.emit(` [ drwx name ]\n`)
const items = await item.List()
items.forEach(entry => {
stdout.emit(` | ${`${(entry.IsDirectory() ? 'd' : '-')}${(entry.IsReadable() ? 'r' : '-')}${(entry.IsWritable() ? 'w' : '-')}${(entry.IsExecutable() ? 'x' : '-')}`.padEnd(8, ' ')} ${entry.IsDirectory() ? '\x1B[0;30m\x1B[47m' : ''}${entry.GetName()}\x1B[0m\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
}
}

28
src/program/Mkdir.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 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(Item.NormalizePath(args[1].startsWith('/') ? args[1] : `${workdir.GetPath()}/${args[1]}`))
if (await item.Exists()) {
stdout.emit(`mkdir: directory ${item.GetPath()} already exists.\n`)
return 2
}
await item.Create()
return 0
}
}

61
src/program/Mv.ts Normal file
View File

@@ -0,0 +1,61 @@
import { Item } from '../fs/Item'
import type { SimpleStream } from '../utils/SimpleStream'
import { Program } from './Program'
export class Mv extends Program {
constructor() {
super()
}
async Exec(_: SimpleStream<string>, stdout: SimpleStream<string>, workdir: Item, args: string[]): Promise<number> {
if (args.length < 3) {
stdout.emit("mv: error: missing the first and/or second path arguments\n")
return 1
}
let item1: Item
let item2: Item
let destIsDir = false
// figure out if the items are files or directories
try {
item1 = await Item.openDir(Item.NormalizePath(args[1].startsWith('/') ? args[1] : `${workdir.GetPath()}/${args[1]}`))
} catch {
item1 = await Item.open(Item.NormalizePath(args[1].startsWith('/') ? args[1] : `${workdir.GetPath()}/${args[1]}`))
}
try {
item2 = await Item.open(Item.NormalizePath(args[2].startsWith('/') ? args[2] : `${workdir.GetPath()}/${args[2]}`))
} catch {
item2 = await Item.openDir(Item.NormalizePath(args[2].startsWith('/') ? args[2] : `${workdir.GetPath()}/${args[2]}`))
destIsDir = true
}
if (!await item1.Exists()) {
stdout.emit(`mv: error: source item ${item1.GetPath()} does not exist.\n`)
return 2
}
if (await item2.Exists() && !destIsDir) {
stdout.emit(`mv: error: destination item ${item2.GetPath()} already exists.\n`)
return 2
}
// either move the file into a destination directory or move it to a new path
if (destIsDir) {
const destChild = await Item.open(Item.NormalizePath(`${item2.GetPath()}/${item1.GetName()}`))
await item1.Copy(destChild)
stdout.emit(`-> moved ${item1.GetPath()} -> ${destChild.GetPath()}\n`)
} else {
await item2.Create()
await item1.Copy(item2)
stdout.emit(`-> moved ${item1.GetPath()} -> ${item2.GetPath()}\n`)
}
await item1.Delete()
return 0
}
}

15
src/program/Printf.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 Printf extends Program {
constructor() {
super()
}
async Exec(_: SimpleStream<string>, stdout: SimpleStream<string>, __: Item, args: string[]): Promise<number> {
stdout.emit(args.slice(1).join(' '))
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>
}

15
src/program/Pwd.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 Pwd extends Program {
constructor() {
super()
}
async Exec(_: SimpleStream<string>, stdout: SimpleStream<string>, workdir: Item, __: string[]): Promise<number> {
stdout.emit(`${workdir.GetPath()}\n`)
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
}
}

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

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

@@ -0,0 +1,632 @@
import { GetCurrentTerminal } from '../app'
import type { Item } from '../fs/Item'
import type { SimpleStream } from '../utils/SimpleStream'
import { Program } from './Program'
type SmokeState = {
y: number
x: number
ptrn: number
kind: number
}
type SlOptions = {
accident: boolean
fly: boolean
logo: boolean
c51: boolean
}
export class Sl extends Program {
private static readonly D51_HEIGHT = 10
private static readonly D51_FUNNEL = 7
private static readonly D51_LENGTH = 83
private static readonly D51_PATTERNS = 6
private static readonly LOGO_HEIGHT = 6
private static readonly LOGO_FUNNEL = 4
private static readonly LOGO_LENGTH = 84
private static readonly LOGO_PATTERNS = 6
private static readonly C51_HEIGHT = 11
private static readonly C51_FUNNEL = 7
private static readonly C51_LENGTH = 87
private static readonly C51_PATTERNS = 6
private static readonly SMOKE_PATTERNS = 16
private static readonly MAX_SMOKE = 1000
private smokes: SmokeState[] = []
private smokeSum = 0
public async Exec(
_: SimpleStream<string>,
stdout: SimpleStream<string>,
__: Item,
args: string[],
): Promise<number> {
const options = this.parseOptions(args)
const terminal = GetCurrentTerminal()
const cols = Math.max(1, Math.floor(terminal.GetWidthCells()))
const lines = Math.max(1, Math.floor(terminal.GetHeightCells()))
this.resetSmoke()
// Clear screen and move cursor home (ANSI CSI)
stdout.emit('\x1b[2J\x1b[H')
for (let x = cols - 1; ; --x) {
const ok = options.logo
? this.addSl(x, stdout, cols, lines, options)
: options.c51
? this.addC51(x, stdout, cols, lines, options)
: this.addD51(x, stdout, cols, lines, options)
if (!ok)
break
await this.sleep(40)
}
stdout.emit(`\x1b[${Math.max(1, lines)};1H\n`)
return 0
}
private parseOptions(args: string[]): SlOptions {
const options: SlOptions = {
accident: false,
fly: false,
logo: false,
c51: false,
}
for (const arg of args.slice(1)) {
if (!arg.startsWith('-'))
continue
for (const flag of arg.slice(1)) {
switch (flag) {
case 'a':
options.accident = true
break
case 'F':
options.fly = true
break
case 'l':
options.logo = true
break
case 'c':
options.c51 = true
break
}
}
}
return options
}
private addSl(
x: number,
stdout: SimpleStream<string>,
cols: number,
lines: number,
options: SlOptions,
): boolean {
if (x < -Sl.LOGO_LENGTH)
return false
let y = Math.max(0, Math.floor(lines / 2) - 3)
let py1 = 0
let py2 = 0
let py3 = 0
if (options.fly) {
y = Math.trunc(x / 6) + lines - Math.trunc(cols / 6) - Sl.LOGO_HEIGHT
py1 = 2
py2 = 4
py3 = 6
}
const pattern = Math.floor((Sl.LOGO_LENGTH + x) / 3) % Sl.LOGO_PATTERNS
for (let i = 0; i <= Sl.LOGO_HEIGHT; i++) {
this.drawString(stdout, cols, lines, y + i, x, Sl.LOGO[pattern][i])
this.drawString(stdout, cols, lines, y + i + py1, x + 21, Sl.LOGO_COAL[i])
this.drawString(stdout, cols, lines, y + i + py2, x + 42, Sl.LOGO_CAR[i])
this.drawString(stdout, cols, lines, y + i + py3, x + 63, Sl.LOGO_CAR[i])
}
if (options.accident) {
this.addMan(stdout, cols, lines, y + 1, x + 14)
this.addMan(stdout, cols, lines, y + 1 + py2, x + 45)
this.addMan(stdout, cols, lines, y + 1 + py2, x + 53)
this.addMan(stdout, cols, lines, y + 1 + py3, x + 66)
this.addMan(stdout, cols, lines, y + 1 + py3, x + 74)
}
this.addSmoke(stdout, cols, lines, y - 1, x + Sl.LOGO_FUNNEL)
return true
}
private addD51(
x: number,
stdout: SimpleStream<string>,
cols: number,
lines: number,
options: SlOptions,
): boolean {
if (x < -Sl.D51_LENGTH)
return false
let y = Math.max(0, Math.floor(lines / 2) - 5)
let dy = 0
if (options.fly) {
y = Math.trunc(x / 7) + lines - Math.trunc(cols / 7) - Sl.D51_HEIGHT
dy = 1
}
const pattern = (Sl.D51_LENGTH + x) % Sl.D51_PATTERNS
for (let i = 0; i <= Sl.D51_HEIGHT; i++) {
this.drawString(stdout, cols, lines, y + i, x, Sl.D51[pattern][i])
this.drawString(stdout, cols, lines, y + i + dy, x + 53, Sl.D51_COAL[i])
}
if (options.accident) {
this.addMan(stdout, cols, lines, y + 2, x + 43)
this.addMan(stdout, cols, lines, y + 2, x + 47)
}
this.addSmoke(stdout, cols, lines, y - 1, x + Sl.D51_FUNNEL)
return true
}
private addC51(
x: number,
stdout: SimpleStream<string>,
cols: number,
lines: number,
options: SlOptions,
): boolean {
if (x < -Sl.C51_LENGTH)
return false
let y = Math.max(0, Math.floor(lines / 2) - 5)
let dy = 0
if (options.fly) {
y = Math.trunc(x / 7) + lines - Math.trunc(cols / 7) - Sl.C51_HEIGHT
dy = 1
}
const pattern = (Sl.C51_LENGTH + x) % Sl.C51_PATTERNS
for (let i = 0; i <= Sl.C51_HEIGHT; i++) {
this.drawString(stdout, cols, lines, y + i, x, Sl.C51[pattern][i])
this.drawString(stdout, cols, lines, y + i + dy, x + 55, Sl.C51_COAL[i])
}
if (options.accident) {
this.addMan(stdout, cols, lines, y + 3, x + 45)
this.addMan(stdout, cols, lines, y + 3, x + 49)
}
this.addSmoke(stdout, cols, lines, y - 1, x + Sl.C51_FUNNEL)
return true
}
private addMan(
stdout: SimpleStream<string>,
cols: number,
lines: number,
y: number,
x: number,
) {
const man = [['', '(O)'], ['Help!', '\\O/']]
const index = Math.floor((Sl.LOGO_LENGTH + x) / 12) % 2
for (let i = 0; i < 2; i++)
this.drawString(stdout, cols, lines, y + i, x, man[index][i])
}
private addSmoke(
stdout: SimpleStream<string>,
cols: number,
lines: number,
y: number,
x: number,
) {
if (x % 4 !== 0)
return
for (let i = 0; i < this.smokeSum; i++) {
const smoke = this.smokes[i]
this.drawString(stdout, cols, lines, smoke.y, smoke.x, Sl.SMOKE_ERASER[smoke.ptrn])
smoke.y -= Sl.SMOKE_DY[smoke.ptrn]
smoke.x += Sl.SMOKE_DX[smoke.ptrn]
smoke.ptrn = smoke.ptrn < Sl.SMOKE_PATTERNS - 1 ? smoke.ptrn + 1 : smoke.ptrn
this.drawString(stdout, cols, lines, smoke.y, smoke.x, Sl.SMOKE[smoke.kind][smoke.ptrn])
}
this.drawString(stdout, cols, lines, y, x, Sl.SMOKE[this.smokeSum % 2][0])
if (this.smokeSum < Sl.MAX_SMOKE) {
this.smokes[this.smokeSum] = { y, x, ptrn: 0, kind: this.smokeSum % 2 }
this.smokeSum++
}
}
private drawString(
stdout: SimpleStream<string>,
cols: number,
lines: number,
y: number,
x: number,
text: string,
) {
if (y < 0 || y >= lines)
return
let cursorX = x
let out = text
if (cursorX < 0) {
out = out.slice(-cursorX)
cursorX = 0
}
if (cursorX >= cols || out.length === 0)
return
if (cursorX + out.length > cols)
out = out.slice(0, cols - cursorX)
stdout.emit(`\x1b[${y + 1};${cursorX + 1}H${out}`)
}
private resetSmoke() {
this.smokes = []
this.smokeSum = 0
}
private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
private static readonly LOGO: string[][] = [
[
' ++ +------ ',
' || |+-+ | ',
' /---------|| | | ',
' + ======== +-+ | ',
' _|--O========O~\\-+ ',
'//// \\_/ \\_/ ',
' ',
],
[
' ++ +------ ',
' || |+-+ | ',
' /---------|| | | ',
' + ======== +-+ | ',
' _|--/O========O\\-+ ',
'//// \\_/ \\_/ ',
' ',
],
[
' ++ +------ ',
' || |+-+ | ',
' /---------|| | | ',
' + ======== +-+ | ',
' _|--/~O========O-+ ',
'//// \\_/ \\_/ ',
' ',
],
[
' ++ +------ ',
' || |+-+ | ',
' /---------|| | | ',
' + ======== +-+ | ',
' _|--/~\\------/~\\-+ ',
'//// \\_O========O ',
' ',
],
[
' ++ +------ ',
' || |+-+ | ',
' /---------|| | | ',
' + ======== +-+ | ',
' _|--/~\\------/~\\-+ ',
'//// \\O========O/ ',
' ',
],
[
' ++ +------ ',
' || |+-+ | ',
' /---------|| | | ',
' + ======== +-+ | ',
' _|--/~\\------/~\\-+ ',
'//// O========O_/ ',
' ',
],
]
private static readonly LOGO_COAL = [
'____ ',
'| \\@@@@@@@@@@@ ',
'| \\@@@@@@@@@@@@@_ ',
'| | ',
'|__________________| ',
' (O) (O) ',
' ',
]
private static readonly LOGO_CAR = [
'____________________ ',
'| ___ ___ ___ ___ | ',
'| |_| |_| |_| |_| | ',
'|__________________| ',
'|__________________| ',
' (O) (O) ',
' ',
]
private static readonly D51 = [
[
' ==== ________ ___________ ',
' _D _| |_______/ \\__I_I_____===__|_________| ',
' |(_)--- | H\\________/ | | =|___ ___| ',
' / | | H | | | | ||_| |_|| ',
' | | | H |__--------------------| [___] | ',
' | ________|___H__/__|_____/[][]~\\_______| | ',
' |/ | |-----------I_____I [][] [] D |=======|__ ',
'__/ =| o |=-~~\\ /~~\\ /~~\\ /~~\\ ____Y___________|__ ',
' |/-=|___|= || || || |_____/~\\___/ ',
' \\_/ \\O=====O=====O=====O_/ \\_/ ',
' ',
],
[
' ==== ________ ___________ ',
' _D _| |_______/ \\__I_I_____===__|_________| ',
' |(_)--- | H\\________/ | | =|___ ___| ',
' / | | H | | | | ||_| |_|| ',
' | | | H |__--------------------| [___] | ',
' | ________|___H__/__|_____/[][]~\\_______| | ',
' |/ | |-----------I_____I [][] [] D |=======|__ ',
'__/ =| o |=-~~\\ /~~\\ /~~\\ /~~\\ ____Y___________|__ ',
' |/-=|___|=O=====O=====O=====O |_____/~\\___/ ',
' \\_/ \\__/ \\__/ \\__/ \\__/ \\_/ ',
' ',
],
[
' ==== ________ ___________ ',
' _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 |=-~~\\ /~~\\ /~~\\ /~~\\ ____Y___________|__ ',
' |/-=|___|= O=====O=====O=====O|_____/~\\___/ ',
' \\_/ \\__/ \\__/ \\__/ \\__/ \\_/ ',
' ',
],
[
' ==== ________ ___________ ',
' _D _| |_______/ \\__I_I_____===__|_________| ',
' |(_)--- | H\\________/ | | =|___ ___| ',
' / | | H | | | | ||_| |_|| ',
' | | | H |__--------------------| [___] | ',
' | ________|___H__/__|_____/[][]~\\_______| | ',
' |/ | |-----------I_____I [][] [] D |=======|__ ',
'__/ =| o |=-~~\\ /~~\\ /~~\\ /~~\\ ____Y___________|__ ',
' |/-=|___|= || || || |_____/~\\___/ ',
' \\_/ \\_O=====O=====O=====O/ \\_/ ',
' ',
],
]
private static readonly D51_COAL = [
' ',
' ',
' _________________ ',
' _| \\_____A ',
' =| | ',
' -| | ',
'__|________________________|_ ',
'|__________________________|_ ',
' |_D__D__D_| |_D__D__D_| ',
' \\_/ \\_/ \\_/ \\_/ ',
' ',
]
private static readonly C51 = [
[
' ___ ',
' _|_|_ _ __ __ ___________',
' D__/ \\_(_)___| |__H__| |_____I_Ii_()|_________|',
' | `---\' |:: `--\' H `--\' | |___ ___| ',
' +|~~~~~~~~++::~~~~~~~H~~+=====+~~~~~~|~~||_| |_|| ',
' || | :: H +=====+ | |:: ...| ',
'| | _______|_::-----------------[][]-----| | ',
'| /~~ || |-----/~~~~\\ /[I_____I][][] --|||_______|__',
'------\'|oOo|=[]=- || || | ||=======_|__',
'/~\\____|___|/~\\_| O=======O=======O |__|+-/~\\_| ',
'\\_/ \\_/ \\____/ \\____/ \\____/ \\_/ ',
' ',
],
[
' ___ ',
' _|_|_ _ __ __ ___________',
' D__/ \\_(_)___| |__H__| |_____I_Ii_()|_________|',
' | `---\' |:: `--\' H `--\' | |___ ___| ',
' +|~~~~~~~~++::~~~~~~~H~~+=====+~~~~~~|~~||_| |_|| ',
' || | :: H +=====+ | |:: ...| ',
'| | _______|_::-----------------[][]-----| | ',
'| /~~ || |-----/~~~~\\ /[I_____I][][] --|||_______|__',
'------\'|oOo|=[]=- O=======O=======O | ||=======_|__',
'/~\\____|___|/~\\_| || || |__|+-/~\\_| ',
'\\_/ \\_/ \\____/ \\____/ \\____/ \\_/ ',
' ',
],
[
' ___ ',
' _|_|_ _ __ __ ___________',
' D__/ \\_(_)___| |__H__| |_____I_Ii_()|_________|',
' | `---\' |:: `--\' H `--\' | |___ ___| ',
' +|~~~~~~~~++::~~~~~~~H~~+=====+~~~~~~|~~||_| |_|| ',
' || | :: H +=====+ | |:: ...| ',
'| | _______|_::-----------------[][]-----| | ',
'| /~~ || |-----/~~~~\\ /[I_____I][][] --|||_______|__',
'------\'|oOo|==[]=- O=======O=======O | ||=======_|__',
'/~\\____|___|/~\\_| || || |__|+-/~\\_| ',
'\\_/ \\_/ \\____/ \\____/ \\____/ \\_/ ',
' ',
],
[
' ___ ',
' _|_|_ _ __ __ ___________',
' D__/ \\_(_)___| |__H__| |_____I_Ii_()|_________|',
' | `---\' |:: `--\' H `--\' | |___ ___| ',
' +|~~~~~~~~++::~~~~~~~H~~+=====+~~~~~~|~~||_| |_|| ',
' || | :: H +=====+ | |:: ...| ',
'| | _______|_::-----------------[][]-----| | ',
'| /~~ || |-----/~~~~\\ /[I_____I][][] --|||_______|__',
'------\'|oOo|===[]=- O=======O=======O | ||=======_|__',
'/~\\____|___|/~\\_| || || |__|+-/~\\_| ',
'\\_/ \\_/ \\____/ \\____/ \\____/ \\_/ ',
' ',
],
[
' ___ ',
' _|_|_ _ __ __ ___________',
' D__/ \\_(_)___| |__H__| |_____I_Ii_()|_________|',
' | `---\' |:: `--\' H `--\' | |___ ___| ',
' +|~~~~~~~~++::~~~~~~~H~~+=====+~~~~~~|~~||_| |_|| ',
' || | :: H +=====+ | |:: ...| ',
'| | _______|_::-----------------[][]-----| | ',
'| /~~ || |-----/~~~~\\ /[I_____I][][] --|||_______|__',
'------\'|oOo|===[]=- || || | ||=======_|__',
'/~\\____|___|/~\\_| O=======O=======O |__|+-/~\\_| ',
'\\_/ \\_/ \\____/ \\____/ \\____/ \\_/ ',
' ',
],
[
' ___ ',
' _|_|_ _ __ __ ___________',
' D__/ \\_(_)___| |__H__| |_____I_Ii_()|_________|',
' | `---\' |:: `--\' H `--\' | |___ ___| ',
' +|~~~~~~~~++::~~~~~~~H~~+=====+~~~~~~|~~||_| |_|| ',
' || | :: H +=====+ | |:: ...| ',
'| | _______|_::-----------------[][]-----| | ',
'| /~~ || |-----/~~~~\\ /[I_____I][][] --|||_______|__',
'------\'|oOo|==[]=- || || | ||=======_|__',
'/~\\____|___|/~\\_| O=======O=======O |__|+-/~\\_| ',
'\\_/ \\_/ \\____/ \\____/ \\____/ \\_/ ',
' ',
],
]
private static readonly C51_COAL = [
' ',
' ',
' ',
' _________________ ',
' _| \\_____A ',
' =| | ',
' -| | ',
'__|________________________|_ ',
'|__________________________|_ ',
' |_D__D__D_| |_D__D__D_| ',
' \\_/ \\_/ \\_/ \\_/ ',
' ',
]
private static readonly SMOKE = [
[
'( )',
'( )',
'( )',
'( )',
'( )',
'( )',
'( )',
'( )',
'()',
'()',
'O',
'O',
'O',
'O',
'O',
' ',
],
[
'(@@@)',
'(@@@@)',
'(@@@@)',
'(@@@)',
'(@@)',
'(@@)',
'(@)',
'(@)',
'@@',
'@@',
'@',
'@',
'@',
'@',
'@',
' ',
],
]
private static readonly SMOKE_ERASER = [
' ',
' ',
' ',
' ',
' ',
' ',
' ',
' ',
' ',
' ',
' ',
' ',
' ',
' ',
' ',
' ',
]
private static readonly SMOKE_DY = [2, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
private static readonly SMOKE_DX = [-2, -1, 0, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 3, 3, 3]
}

27
src/program/Touch.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 Touch extends Program {
constructor() {
super()
}
async Exec(_: SimpleStream<string>, stdout: SimpleStream<string>, workdir: Item, args: string[]): Promise<number> {
if (args.length < 2) {
stdout.emit("touch: error: missing path argument\n")
return 1
}
const item = await Item.open(Item.NormalizePath(args[1].startsWith('/') ? args[1] : `${workdir.GetPath()}/${args[1]}`))
if (await item.Exists()) {
stdout.emit("touch: the file already exists.\n")
return 1
}
await item.Create()
return 0
}
}

45
src/program/Tree.ts Normal file
View File

@@ -0,0 +1,45 @@
import { Item } from '../fs/Item'
import type { SimpleStream } from '../utils/SimpleStream'
import { Program } from './Program'
export class Tree extends Program {
constructor() {
super()
}
async Exec(_: SimpleStream<string>, stdout: SimpleStream<string>, workdir: Item, args: string[]): Promise<number> {
// open the target directory, default to workdir
const item = args[1]
? await Item.openDir(Item.NormalizePath(
args[1].startsWith('/')
? args[1]
: `${workdir.GetPath()}/${args[1]}`
))
: workdir
if (args[1] && !item.IsDirectory()) {
stdout.emit("tree: error: the provided path is not a directory\n")
return 1
}
if (!(await item.Exists())) {
stdout.emit(`tree: error: path ${item.GetPath()} doesn't exist\n`)
return 2
}
stdout.emit(`-> Tree of item: '${item.GetPath()}'\n`)
await this.printTree(stdout, item, 1)
return 0
}
private async printTree(stdout: SimpleStream<string>, item: Item, depth: number): Promise<void> {
const items = await item.List()
for (const item of items) {
stdout.emit(` ${' |'.repeat(depth)}--+ ${item.IsDirectory() ? '\x1B[0;30m\x1B[47m' : ''}${item.GetName()}\x1B[0m\n`)
if (item.IsDirectory())
await this.printTree(stdout, item, depth + 1)
}
}
}

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

@@ -0,0 +1,21 @@
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
readonly 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 SetWorkingDirectory(directory: Item): Promise<void>
}

View File

@@ -0,0 +1,40 @@
/**
* Wush environment manager
* Manages environment variables, aliases etc.
*/
export class Environment {
private variables: Record<string, string> = {}
private aliases: Record<string, string> = {}
SetVariable(name: string, value: any | null): void {
if (value === null) {
// remove the variable if value is null
delete this.variables[name]
} else this.variables[name] = String(value)
}
GetVariable(name: string): string | null {
return this.variables[name] ?? null
}
SetAlias(name: string, value: string | null): void {
if (value === null) {
// remove the alias if value is null
delete this.aliases[name]
} else this.aliases[name] = value
}
GetAlias(name: string): string | null {
return this.aliases[name] ?? null
}
GetAliasesByValue(value: string): string[] | null {
const keys: string[] = []
for (const key in this.aliases)
if (this.aliases[key] === value)
keys.push(key)
return keys.length === 0 ? null : keys
}
}

View File

@@ -0,0 +1,242 @@
import type { EventBroadcaster } from "../../utils/EventBroadcaster"
import type { Wush } from "./Wush"
export type ModifierKey = 'shift' | 'ctrl' | 'alt' | 'rightAlt'
/**
* Wush input manager
* Manages input events and holds command history
*/
export class InputManager {
private modifierKeys: ModifierKey[] = []
private shell: Wush
private keyEventBroadcaster: EventBroadcaster
// history
history: string[] = []
historyIndex: number | null = null
historyDraft: string = ''
constructor(shell: Wush, keyEventBroadcaster: EventBroadcaster) {
this.shell = shell
this.keyEventBroadcaster = keyEventBroadcaster
}
//
// -> history management
//
private NavigateHistory(direction: -1 | 1) {
// don't continue if there's no history
if (this.history.length === 0)
return
// I honestly don't know how but it works.
// __**do not touch this under any circumstances**__
if (direction === -1) {
if (this.historyIndex === null) {
this.historyDraft = this.shell._buffer.join('')
this.historyIndex = this.history.length - 1
} else if (this.historyIndex > 0) {
this.historyIndex -= 1
} else return
this.shell._SetBuffer(this.history[this.historyIndex])
return
}
if (this.historyIndex === null)
return
if (this.historyIndex < this.history.length - 1) {
this.historyIndex += 1
this.shell._SetBuffer(this.history[this.historyIndex])
return
}
this.historyIndex = null
this.shell._SetBuffer(this.historyDraft)
}
private ExitHistoryNavigation() {
this.historyIndex = null
this.historyDraft = ''
}
//
// -> keyboard management
//
/**
* Checks if the modifier includes every key of the combo
* @param combo modifier combo to check for
* @returns boolean according to the check truthfullness
*/
static HasModifierCombo(modifiers: ModifierKey[], combo: ModifierKey[]) {
let result = true
// check if the modifier buffer has every key in the combo
combo.forEach(key => {
if (!modifiers.includes(key)) {
result = false
return
}
})
return result
}
/**
* Gets a ModifierKey using a key id from KeyboardEvent
* @param key key from KeyboardEvent
* @returns a ModifierKey string if the provided key is valid, null otherwise
*/
static GetModifierKeyFromKey(key: string): ModifierKey | null {
switch (key) {
case 'Control':
return 'ctrl'
case 'Shift':
return 'shift'
case 'Alt':
return 'alt'
case 'AltGraph':
return 'rightAlt'
default:
return null
}
}
/**
* Registers keyboard listeners
*/
RegisterEvents() {
// also check for unfocus events as the modifier keys may be pressed when the window loses focus
window.addEventListener('blur', () => {
this.modifierKeys = []
})
this.keyEventBroadcaster.on('keyup', (key: string, isCharacter: boolean) => {
// deregister a modifier key as pressed
if (!isCharacter) {
const mod = InputManager.GetModifierKeyFromKey(key)
if (mod && this.modifierKeys.includes(mod))
this.modifierKeys = this.modifierKeys.filter(k => k !== mod)
}
})
this.keyEventBroadcaster.on('keydown', (key: string, isCharacter: boolean) => {
// register a modifier key as pressed
if (!isCharacter && !this.modifierKeys.includes(InputManager.GetModifierKeyFromKey(key)!)) {
const mod = InputManager.GetModifierKeyFromKey(key)
if (mod) this.modifierKeys.push(mod)
}
this.HandleKeyInput(key, isCharacter)
})
}
HandleKeyInput(key: string, isCharacter: boolean) {
// handle special input
switch (key.toUpperCase()) {
case 'C':
if (InputManager.HasModifierCombo(this.modifierKeys, ['ctrl'])) {
document.execCommand('copy')
return
}
break
case 'R':
if (InputManager.HasModifierCombo(this.modifierKeys, ['ctrl'])) {
location.reload()
return
}
break
case 'F5':
if (InputManager.HasModifierCombo(this.modifierKeys, ['alt'])) {
location.reload()
return
}
break
}
// redirect input to a running program instead
if (this.shell.HasRunningProgram()) {
this.shell.WriteStdin(key)
return
}
// handle standard keys
if (!isCharacter) {
switch (key) {
case 'ArrowLeft':
if (this.shell._bufferPos > 0) {
this.shell._bufferPos -= 1
this.shell._SyncCursorToBuffer()
}
break
case 'ArrowRight':
if (this.shell._bufferPos < this.shell._buffer.length) {
this.shell._bufferPos += 1
this.shell._SyncCursorToBuffer()
}
break
case 'ArrowUp':
this.NavigateHistory(-1)
break
case 'ArrowDown':
this.NavigateHistory(1)
break
case 'Backspace':
// don't erase anything if there's nothing left in the buffer
if (this.shell._bufferPos === 0)
break
this.ExitHistoryNavigation()
this.shell.terminal.RemoveCell()
this.shell.RemoveCharFromBuffer(1, this.shell._bufferPos)
this.shell._SyncCursorToBuffer()
break
case 'Delete':
if (this.shell._bufferPos >= this.shell._buffer.length || this.shell._buffer.length <= 0)
break
this.ExitHistoryNavigation()
this.shell._bufferPos += 1
this.shell._SyncCursorToBuffer()
this.shell.terminal.RemoveCell()
this.shell.RemoveCharFromBuffer(1, this.shell._bufferPos)
this.shell._SyncCursorToBuffer()
break
case 'Enter':
// send the buffer to stdin if an exec is running
if (this.shell.GetExecExitCode() === -1) {
this.shell.WriteStdin(`${this.shell._buffer.join('')}\n`)
this.shell.FlushBuffer()
} else {
// "execute" the buffer
this.shell.terminal.MoveCursor(0, 1, { x: true, y: false })
const command = this.shell._buffer.join('')
if (command.length > 0)
this.history.push(command)
this.historyIndex = null
this.historyDraft = ''
this.shell.ExecuteLineBuffer()
}
break
}
} else {
this.ExitHistoryNavigation()
// push the character into the buffer
this.shell._InsertText(key)
}
}
}

View File

@@ -0,0 +1,13 @@
/**
* Wush input parser
* Provides necessary parsing functions
*/
export class InputParser {
// static Tokenize(input: string) {
// }
// static Parse(tokens: string[]) {
// }
}

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

@@ -0,0 +1,962 @@
// 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 type { CursorPosition } from '../../terminal/CursorProperties'
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 { Printf } from '../../program/Printf'
import { Pwd } from '../../program/Pwd'
import { Environment } from './Environment'
import { InputManager } from './InputManager'
import { Edit } from '../../program/Edit'
import { Mv } from '../../program/Mv'
import { Tree } from '../../program/Tree'
import { Cp } from '../../program/Cp'
export class Wush extends Shell {
public readonly Version = "0.3.2"
public readonly Name = "wush"
// buffer
_buffer: string[] = []
_bufferPos: number = 0
_promptStart: CursorPosition = { col: 0, row: 0 }
private inputManager: InputManager
private environment: Environment
// exec stuff
/**
* -1 if the exec is currently running and anything else when it's not
*/
private execExitCode: number = 0
// 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>()
this.inputManager = new InputManager(this, broadcaster)
this.environment = new Environment()
}
async Init() {
// initialize keyboard listener
this.inputManager.RegisterEvents()
// load workdir
this.workingDirectory = await Item.Root()
// register streams
this.stdout.on(data => this.WriteEscapedString(data))
// 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.programs['printf'] = new Printf()
this.programs['edit'] = new Edit()
this.programs['mv'] = new Mv()
this.programs['cp'] = new Cp()
this.programs['tree'] = new Tree()
// reset exit code
this.SetExitCode(0)
// initial prompt
this.Prompt()
}
SetExitCode(code: number): void {
this.execExitCode = code
this.environment.SetVariable("status", code)
}
HasRunningProgram(): boolean {
return this.execExitCode === -1
}
GetPrograms(): { [name: string]: Program } {
return this.programs
}
LoadProgram(program: Program, name: string) {
this.programs[name] = program
}
UnloadProgram(name: string) {
delete this.programs[name]
}
GetExecExitCode(): number {
return this.execExitCode
}
async ExecuteProgram(name: string, args: string[]): Promise<void> {
return new Promise<void>(resolve => {
this.programs[name].Exec(this.stdin, this.stdout, this.workingDirectory, args)
.then(code => {
this.SetExitCode(code != -1 ? code : -2)
resolve()
})
.catch((e) => {
this.WriteEscapedString(`wush: command ${name} exited with the following exception\n`)
this.WriteEscapedString(` | ${String(e)}\n`)
resolve()
})
.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.SetExitCode(-2)
// this.terminal.Write(`The program exited with exit code ${this.execExitCode}.`)
resolve()
})
})
}
Prompt() {
this.terminal.Write(`user in ${this.workingDirectory.GetPath()} -> `)
this._promptStart = this.terminal.GetCursorPosition()
this._buffer = []
this._bufferPos = 0
this.inputManager.historyIndex = null
this.inputManager.historyDraft = ''
}
/**
* 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 buffer = ''
// write the buffer to screen
const flush = () => {
if (buffer.length === 0)
return
this.terminal.Write(buffer)
buffer = ''
}
// process the string
let i = 0
while (i < data.length) {
const char = data[i]
// check for escape sequences
if (char === '\x1b') {
flush()
const nextIndex = this.ProcessEscapeSequence(data, i)
if (nextIndex !== null) {
i = nextIndex
continue
}
}
// check for control codes
if (char === '\f' || char === '\n') {
flush()
this.ProcessControlCode(char)
i++
continue
}
buffer += char
i++
}
flush()
}
PushToBuffer(text: string) {
text.split('').forEach(char => {
this._buffer.splice(this._bufferPos, 0, char)
this._bufferPos++
})
}
RemoveCharFromBuffer(amount: number, index: number) {
this._buffer.splice(index - amount, amount)
this._bufferPos -= amount
}
MoveBufferPos(index: number, absolute: boolean = false) {
this._bufferPos = Math.max(absolute ? index : this._bufferPos + index, 0)
}
FlushBuffer() {
this._buffer = []
this._bufferPos = 0
}
/**
* Takes the prompt buffer, parses it and executes the contents
*/
async ExecuteLineBuffer(data?: string) {
type OperatorToken = '&&' | '||' | '|' | ';' | '>' | '>>' | '<'
type Token = { type: 'word'; value: string } | { type: 'operator'; value: OperatorToken }
type ParsedCommand = {
args: string[]
stdin: null | SimpleStream<string>
stdout: null | SimpleStream<string>
stdinPath: string | null
stdoutPath: string | null
stdoutAppend: boolean
}
type ParsedPipeline = { commands: ParsedCommand[] }
type ParsedListItem = { pipeline: ParsedPipeline; operator: '&&' | '||' | ';' | null }
// environment variable helpers
/**
* @returns value of the variable, empty string otherwise
*/
const resolveVariable = (name: string): string => this.environment.GetVariable(name) ?? ''
const isValidVarNameChar = (char: string) => /[A-Za-z0-9_]/.test(char)
/**
* splits the buffer into smaller processable pieces (tokens)
*/
const tokenize = (text: string): { tokens: Token[]; error: string | null } => {
const tokens: Token[] = []
// state machine stuff
let current = ''
let wordStarted = false
let inSingleQuote = false
let inDoubleQuote = false
let escapeNext = false
/**
* adds a new plain string to the token buffer
*/
const pushWord = () => {
if (!wordStarted)
return
tokens.push({ type: 'word', value: current })
// reset the state
current = ''
wordStarted = false
}
// reads environment variable tokens
const readVariable = (index: number): {
value: string
nextIndex: number
error: string | null
expanded: boolean
} => {
if (index + 1 >= text.length)
return { value: '$', nextIndex: index, error: null, expanded: false }
const next = text[index + 1]
if (next === '{') {
let end = index + 2
while (end < text.length && text[end] !== '}')
end++
if (end >= text.length)
return { value: '', nextIndex: text.length - 1, error: 'unterminated variable expansion', expanded: true }
const name = text.slice(index + 2, end)
if (name.length === 0)
return { value: '', nextIndex: end, error: 'empty variable name', expanded: true }
return { value: resolveVariable(name), nextIndex: end, error: null, expanded: true }
}
if (isValidVarNameChar(next)) {
let end = index + 1
while (end + 1 < text.length && isValidVarNameChar(text[end + 1]))
end++
const name = text.slice(index + 1, end + 1)
return { value: resolveVariable(name), nextIndex: end, error: null, expanded: true }
}
return { value: '$', nextIndex: index, error: null, expanded: false }
}
const appendExpandedValue = (value: string, split: boolean, preserveEmpty: boolean) => {
if (value.length === 0) {
if (preserveEmpty)
wordStarted = true
return
}
if (!split) {
current += value
wordStarted = true
return
}
for (const char of value) {
if (char === ' ' || char === '\t') {
pushWord()
continue
}
current += char
wordStarted = true
}
}
// iterates over the provided text buffer
for (let i = 0; i < text.length; i++) {
const char = text[i]
if (escapeNext) {
current += char
escapeNext = false
wordStarted = true
continue
}
// process strict (single quote) strings
if (inSingleQuote) {
if (char === "'") {
inSingleQuote = false
wordStarted = true
} else {
current += char
wordStarted = true
}
continue
}
// process classic (double quote) strings
if (inDoubleQuote) {
if (char === '"') {
inDoubleQuote = false
wordStarted = true
} else if (char === '\\') {
if (i + 1 >= text.length)
return { tokens: [], error: 'unterminated escape sequence' }
current += text[i + 1]
i++
wordStarted = true
} else if (char === '$') {
const { value, nextIndex, error, expanded } = readVariable(i)
if (error)
return { tokens: [], error }
if (expanded) {
appendExpandedValue(value, false, true)
i = nextIndex
} else {
current += char
wordStarted = true
}
} else {
current += char
wordStarted = true
}
continue
}
// state checks
// escapes
if (char === '\\') {
escapeNext = true
wordStarted = true
continue
}
// single quote strings
if (char === "'") {
inSingleQuote = true
wordStarted = true
continue
}
// double quote strings
if (char === '"') {
inDoubleQuote = true
wordStarted = true
continue
}
if (char === '$') {
const { value, nextIndex, error, expanded } = readVariable(i)
if (error)
return { tokens: [], error }
if (expanded) {
appendExpandedValue(value, true, false)
i = nextIndex
} else {
current += char
wordStarted = true
}
continue
}
if (char === ' ' || char === '\t') {
pushWord()
continue
}
if (char === '#') {
if (!wordStarted)
break
current += char
wordStarted = true
continue
}
if (char === ';') {
pushWord()
tokens.push({ type: 'operator', value: ';' })
continue
}
if (char === '>') {
pushWord()
if (text[i + 1] === '>') {
tokens.push({ type: 'operator', value: '>>' })
i++
} else {
tokens.push({ type: 'operator', value: '>' })
}
continue
}
if (char === '<') {
pushWord()
tokens.push({ type: 'operator', value: '<' })
continue
}
if (char === '|') {
pushWord()
if (text[i + 1] === '|') {
tokens.push({ type: 'operator', value: '||' })
i++
} else {
tokens.push({ type: 'operator', value: '|' })
}
continue
}
if (char === '&') {
if (text[i + 1] === '&') {
pushWord()
tokens.push({ type: 'operator', value: '&&' })
i++
} else {
current += char
wordStarted = true
}
continue
}
current += char
wordStarted = true
}
if (escapeNext)
return { tokens: [], error: 'unterminated escape sequence' }
if (inSingleQuote)
return { tokens: [], error: 'unterminated single quote' }
if (inDoubleQuote)
return { tokens: [], error: 'unterminated double quote' }
pushWord()
return { tokens, error: null }
}
// parse the tokens into a list of parsed commands and pipelines
const parseTokens = (tokens: Token[]): { list: ParsedListItem[]; error: string | null } => {
const list: ParsedListItem[] = []
let currentArgs: string[] = []
let currentPipeline: ParsedCommand[] = []
let expectCommand = false
let expectRedirect: 'stdin' | 'stdout' | 'append' | null = null
let currentStdinPath: string | null = null
let currentStdoutPath: string | null = null
let currentStdoutAppend = false
const pushCommand = () => {
if (currentArgs.length === 0)
return false
currentPipeline.push({
args: currentArgs,
stdin: null,
stdout: null,
stdinPath: currentStdinPath,
stdoutPath: currentStdoutPath,
stdoutAppend: currentStdoutAppend,
})
currentArgs = []
currentStdinPath = null
currentStdoutPath = null
currentStdoutAppend = false
return true
}
const pushPipeline = (operator: '&&' | '||' | ';' | null) => {
if (currentPipeline.length === 0)
return false
list.push({ pipeline: { commands: currentPipeline }, operator })
currentPipeline = []
return true
}
for (const token of tokens) {
if (token.type === 'word') {
if (expectRedirect) {
if (expectRedirect === 'stdin') {
currentStdinPath = token.value
} else {
currentStdoutPath = token.value
currentStdoutAppend = expectRedirect === 'append'
}
expectRedirect = null
expectCommand = false
continue
}
currentArgs.push(token.value)
expectCommand = false
continue
}
if (expectRedirect)
return { list: [], error: 'expected redirection target' }
if (token.value === '>' || token.value === '>>' || token.value === '<') {
if (currentArgs.length === 0)
return { list: [], error: `unexpected "${token.value}" operator` }
expectRedirect = token.value === '<'
? 'stdin'
: token.value === '>>'
? 'append'
: 'stdout'
continue
}
if (token.value === '|') {
if (currentStdoutPath)
return { list: [], error: 'unexpected "|" operator after redirection' }
if (!pushCommand())
return { list: [], error: 'unexpected "|" operator' }
expectCommand = true
continue
}
if (!pushCommand() || !pushPipeline(token.value))
return { list: [], error: `unexpected "${token.value}" operator` }
expectCommand = true
}
if (expectCommand)
return { list: [], error: 'unexpected end of input' }
if (expectRedirect)
return { list: [], error: 'expected redirection target' }
if (currentArgs.length > 0)
pushCommand()
if (currentPipeline.length > 0)
pushPipeline(null)
return { list, error: null }
}
// process the buffer
let line: string
if (data) {
// data overrides the buffer
this._buffer = data.split('')
this._bufferPos = 0
line = data
} else {
// use shell buffer if no arbitrary string was provided
line = this._buffer.join('')
this.FlushBuffer()
}
const { tokens, error } = tokenize(line)
if (error) {
this.terminal.Write(`wush: error: ${error}`)
this.terminal.MoveCursor(0, 1, { x: true, y: false })
this.Prompt()
return
}
const { list, error: parseError } = parseTokens(tokens)
if (parseError) {
this.terminal.Write(`wush: error: ${parseError}`)
this.terminal.MoveCursor(0, 1, { x: true, y: false })
this.Prompt()
return
}
if (list.length === 0) {
this.Prompt()
return
}
const resolvePath = (path: string) => Item.NormalizePath(
path.startsWith('/') ? path : `${this.workingDirectory.GetPath()}/${path}`
)
const openInputRedirect = async (path: string) => {
const resolved = resolvePath(path)
const item = await Item.open(resolved)
if (!(await item.Exists()))
throw new Error(`input file ${resolved} doesn't exist`)
if (item.IsDirectory())
throw new Error(`input file ${resolved} is a directory`)
return { stream: new SimpleStream<string>(), data: item.GetData() ?? '' }
}
const openOutputRedirect = async (path: string, append: boolean) => {
const resolved = resolvePath(path)
const item = await Item.open(resolved)
if (!(await item.Exists()))
await item.Create()
if (item.IsDirectory())
throw new Error(`output path ${resolved} is a directory`)
if (!append)
await item.Write('')
const stream = new SimpleStream<string>()
let writeQueue = Promise.resolve()
const listener = (data: string) => {
writeQueue = writeQueue.then(() => item.Append(data))
}
stream.on(listener)
return {
stream,
finish: () => writeQueue,
teardown: () => stream.off(listener),
}
}
const setupPipelineStreams = async (pipeline: ParsedPipeline) => {
const teardown: Array<() => void> = []
const stdins: SimpleStream<string>[] = []
const stdouts: SimpleStream<string>[] = []
const finishers: Array<() => Promise<void>> = []
const inputFeeds: Array<{ stream: SimpleStream<string>; data: string }> = []
try {
for (const [index, command] of pipeline.commands.entries()) {
let stdin: SimpleStream<string>
let stdout: SimpleStream<string>
if (command.stdinPath) {
if (index !== 0)
throw new Error('input redirection is only supported on the first command in a pipeline')
const { stream, data } = await openInputRedirect(command.stdinPath)
stdin = stream
inputFeeds.push({ stream, data })
} else {
stdin = index === 0 ? this.stdin : new SimpleStream<string>()
}
if (command.stdoutPath) {
if (index !== pipeline.commands.length - 1)
throw new Error('output redirection is only supported on the last command in a pipeline')
const { stream, finish, teardown: remove } = await openOutputRedirect(
command.stdoutPath,
command.stdoutAppend
)
stdout = stream
finishers.push(finish)
teardown.push(remove)
} else {
stdout = index === pipeline.commands.length - 1 ? this.stdout : new SimpleStream<string>()
}
command.stdin = stdin
command.stdout = stdout
stdins.push(stdin)
stdouts.push(stdout)
}
for (let i = 0; i < pipeline.commands.length - 1; i++) {
if (pipeline.commands[i].stdoutPath)
throw new Error('cannot pipe a command with output redirection')
if (pipeline.commands[i + 1].stdinPath)
throw new Error('cannot pipe into a command with input redirection')
const listener = (data: string) => stdins[i + 1].emit(data)
stdouts[i].on(listener)
teardown.push(() => stdouts[i].off(listener))
}
return { stdins, stdouts, teardown, finishers, inputFeeds }
} catch (e) {
teardown.forEach(remove => remove())
throw e
}
}
const executePipeline = async (pipeline: ParsedPipeline): Promise<number> => {
const missing = pipeline.commands.find(command => !(this.programs[command.args[0]] instanceof Program))
if (missing) {
const name = missing.args[0]
if (name.length !== 0) {
this.terminal.Write(`wush: error: unknown command: ${name}.`)
this.terminal.MoveCursor(0, 1, { x: true, y: false })
return 127
}
return 0
}
let setup: Awaited<ReturnType<typeof setupPipelineStreams>>
try {
setup = await setupPipelineStreams(pipeline)
} catch (e) {
const message = e instanceof Error ? e.message : String(e)
this.terminal.Write(`wush: error: ${message}`)
this.terminal.MoveCursor(0, 1, { x: true, y: false })
return 1
}
try {
const exitCodes = await Promise.all(pipeline.commands.map(async (command, index) => {
try {
return await this.programs[command.args[0]].Exec(
setup.stdins[index],
setup.stdouts[index],
this.workingDirectory,
command.args
)
} catch (e) {
this.WriteEscapedString(`wush: command ${command.args[0]} exited with the following exception\n`)
this.WriteEscapedString(` | ${String(e)}\n`)
return -2
}
}))
setup.inputFeeds.forEach(feed => {
queueMicrotask(() => feed.stream.emit(feed.data))
})
await Promise.all(setup.finishers.map(finish => finish()))
const lastExitCode = exitCodes[exitCodes.length - 1]
return lastExitCode !== -1 ? lastExitCode : -2
} finally {
setup.teardown.forEach(remove => remove())
}
}
for (const item of list) {
this.SetExitCode(-1)
this.SetExitCode(await executePipeline(item.pipeline))
}
this.Prompt()
}
ProcessControlCode(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
}
}
ProcessEscapeSequence(data: string, index: number): number | null {
if (data[index] !== '\x1b' || data[index + 1] !== '[')
return null
let i = index + 2
let params = ''
while (i < data.length) {
const char = data[i]
if ((char >= '0' && char <= '9') || char === ';') {
params += char
i++
continue
}
this.ProcessCsiControlCode(char, params)
return i + 1
}
return data.length
}
ProcessCsiControlCode(command: string, params: string): boolean {
const values = params.length === 0
? []
: params.split(';').map(value => Number.parseInt(value, 10))
switch (command) {
case 'H':
case 'f': {
const row = Number.isFinite(values[0]) ? values[0] : 1
const col = Number.isFinite(values[1]) ? values[1] : 1
this.terminal.SetCursorPosition(Math.max(col - 1, 0), Math.max(row - 1, 0))
return true
}
case 'J': {
this.terminal.NewPage()
return true
}
case 'm': {
this.terminal.ApplyCellStyleCodes(values)
return true
}
default:
return false
}
}
private GetWrapWidth(): number {
const width = Math.floor(this.terminal.GetWidthCells())
if (!Number.isFinite(width) || width <= 0)
return 1
return Math.max(1, width)
}
private GetCursorPositionForBufferPos(pos: number): CursorPosition {
const width = this.GetWrapWidth()
const absoluteIndex = this._promptStart.col + Math.max(pos, 0)
const rowOffset = Math.floor(absoluteIndex / width)
const col = absoluteIndex % width
return { row: this._promptStart.row + rowOffset, col }
}
_SyncCursorToBuffer() {
const position = this.GetCursorPositionForBufferPos(this._bufferPos)
this.terminal.SetCursorPosition(position.col, position.row)
}
private ClearBuffer() {
if (this._buffer.length === 0)
return
if (this._bufferPos < this._buffer.length) {
this._bufferPos = this._buffer.length
this._SyncCursorToBuffer()
}
while (this._bufferPos > 0) {
this.terminal.RemoveCell()
this.RemoveCharFromBuffer(1, this._bufferPos)
this._SyncCursorToBuffer()
}
}
_InsertText(text: string) {
if (text.length === 0)
return
for (const char of text) {
this.terminal.InsertCell(char)
this.PushToBuffer(char)
this._SyncCursorToBuffer()
}
}
_SetBuffer(text: string) {
this.ClearBuffer()
this._InsertText(text)
}
}

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;

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

@@ -0,0 +1,14 @@
@use "colors.scss" as colors;
#cursor {
width: 1px;
height: 20px;
top: 0;
left: 0;
position: absolute;
backdrop-filter: invert(100%) saturate(0);
transition: .2s cubic-bezier(0, 1, 0.3, 1);
}

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

@@ -0,0 +1,26 @@
@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;
font-variant-ligatures: normal;
font-feature-settings: "liga" 1, "calt" 1;
::-moz-selection {
background: colors.$terminal-white;
color: colors.$terminal-background;
}
p {
margin: 0;
height: fit-content;
}
.line {
width: fit-content;
white-space: pre;
}
}

View File

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

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

@@ -0,0 +1,845 @@
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.2.2"
private static readonly AnsiColors = [
'#000000',
'#800000',
'#008000',
'#808000',
'#000080',
'#800080',
'#008080',
'#ffffff',
'#808080',
'#ff0000',
'#00ff00',
'#ffff00',
'#0000ff',
'#ff00ff',
'#00ffff',
'#ffffff',
]
private terminal: HTMLElement
private cursor: HTMLElement
private cursorStyle: CursorStyle = 'bar'
private cellHeight = 0
private cellWidth = 0
private lines: string[] = []
private lineElements: HTMLElement[] = []
private lineWrapped: boolean[] = []
private lineStyles: Map<number, Map<number, string>> = new Map()
private currentForeground: string | null = null
private currentBackground: string | null = null
private currentCellStyle: string | null = null
private scrollPending = false
private cursorRange: Range | null = null
private cursorPosition: CursorPosition = {
col: 0,
row: 0,
}
private shell?: Shell
constructor() {
SetCurrentTerminal(this)
this.terminal = sqs('#terminal')
this.cursor = sqs('#cursor')
this.cursorRange = document.createRange()
this.SetCursorStyle('bar')
this.NewPage()
this.CreateMetricsRefreshListener()
}
async LoadShell(shell: Shell) {
this.shell = shell
await this.shell.Init()
}
GetShell(): Shell | undefined {
return this.shell
}
NewPage() {
this.ResetCellSize()
this.terminal.innerHTML = ''
this.lines = []
this.lineElements = []
this.lineWrapped = []
this.lineStyles.clear()
this.SetCursorPosition(0, 0)
}
AppendLine() {
this.EnsureLine(this.lineElements.length)
}
/**
* @returns index of the last line on the page. -1 if there are no lines
*/
GetLastLineIndex(): number {
return this.lineElements.length - 1
}
Write(text: string) {
if (text.length === 0)
return
const width = this.GetWrapWidth()
let row = this.cursorPosition.row
let col = this.cursorPosition.col
const style = this.currentCellStyle
while (col >= width) {
this.EnsureLine(row)
this.lineWrapped[row] = true
col -= width
row += 1
}
this.EnsureLine(row)
let remaining = text
while (remaining.length > 0) {
let line = this.lines[row]
if (col > line.length)
line += ' '.repeat(col - line.length)
const available = width - col
if (available <= 0) {
this.lineWrapped[row] = true
row += 1
col = 0
this.EnsureLine(row)
continue
}
const chunk = remaining.slice(0, available)
if (col === line.length) {
line += chunk
} else {
const prefix = line.slice(0, col)
const suffixStart = Math.min(line.length, col + chunk.length)
const suffix = line.slice(suffixStart)
line = prefix + chunk + suffix
}
this.lines[row] = line
this.ApplyStyleRange(row, col, chunk.length, style)
this.UpdateLine(row)
remaining = remaining.slice(chunk.length)
col += chunk.length
if (remaining.length > 0) {
this.lineWrapped[row] = true
row += 1
col = 0
this.EnsureLine(row)
} else {
this.lineWrapped[row] = false
}
}
this.cursorPosition.col = Math.max(col, 0)
this.cursorPosition.row = Math.max(row, 0)
this.UpdateCursor()
}
SetCell(char: string) {
const width = this.GetWrapWidth()
let row = this.cursorPosition.row
let col = this.cursorPosition.col
while (col >= width) {
this.EnsureLine(row)
this.lineWrapped[row] = true
col -= width
row += 1
}
this.EnsureLine(row)
let line = this.lines[row]
if (col > line.length)
line += ' '.repeat(col - line.length)
if (col === line.length) {
line += char[0]
} else {
line = line.slice(0, col) + char[0] + line.slice(col + 1)
}
this.lines[row] = line
this.ApplyStyleRange(row, col, 1, this.currentCellStyle)
this.UpdateLine(row)
}
SetCellStyle(col: number, row: number, style: string | null) {
if (!Number.isFinite(col) || !Number.isFinite(row))
return
const targetRow = Math.max(0, Math.floor(row))
const targetCol = Math.max(0, Math.floor(col))
const width = this.GetWrapWidth()
if (targetCol >= width)
return
this.EnsureLine(targetRow)
let line = this.lines[targetRow] ?? ''
if (targetCol >= line.length) {
line += ' '.repeat(targetCol - line.length + 1)
this.lines[targetRow] = line
}
const cssText = style?.trim()
if (cssText) {
let rowStyles = this.lineStyles.get(targetRow)
if (!rowStyles) {
rowStyles = new Map()
this.lineStyles.set(targetRow, rowStyles)
}
rowStyles.set(targetCol, cssText)
} else {
const rowStyles = this.lineStyles.get(targetRow)
if (rowStyles) {
rowStyles.delete(targetCol)
if (rowStyles.size === 0)
this.lineStyles.delete(targetRow)
}
}
this.UpdateLine(targetRow)
this.UpdateCursor()
}
ApplyCellStyleCodes(values: number[]) {
const params = values.length === 0 ? [0] : values
let foreground = this.currentForeground
let background = this.currentBackground
let i = 0
while (i < params.length) {
const code = params[i]
if (!Number.isFinite(code)) {
i += 1
continue
}
if (code === 0) {
foreground = null
background = null
i += 1
continue
}
if (code === 39) {
foreground = null
i += 1
continue
}
if (code === 49) {
background = null
i += 1
continue
}
if (code >= 30 && code <= 37) {
foreground = Terminal.AnsiColors[code - 30]
i += 1
continue
}
if (code >= 90 && code <= 97) {
foreground = Terminal.AnsiColors[code - 90 + 8]
i += 1
continue
}
if (code >= 40 && code <= 47) {
background = Terminal.AnsiColors[code - 40]
i += 1
continue
}
if (code >= 100 && code <= 107) {
background = Terminal.AnsiColors[code - 100 + 8]
i += 1
continue
}
if (code === 38 || code === 48) {
const isForeground = code === 38
const mode = params[i + 1]
if (mode === 2) {
const red = params[i + 2]
const green = params[i + 3]
const blue = params[i + 4]
if (Number.isFinite(red) && Number.isFinite(green) && Number.isFinite(blue)) {
const color = Terminal.ToRgbColor(red, green, blue)
if (isForeground)
foreground = color
else
background = color
}
i += 5
continue
}
if (mode === 5) {
const index = params[i + 2]
const color = Terminal.GetAnsi256Color(index)
if (color) {
if (isForeground)
foreground = color
else
background = color
}
i += 3
continue
}
}
i += 1
}
this.currentForeground = foreground
this.currentBackground = background
this.UpdateCurrentStyle()
}
InsertCell(char: string) {
const width = this.GetWrapWidth()
let row = this.cursorPosition.row
let col = this.cursorPosition.col
while (col >= width) {
this.EnsureLine(row)
this.lineWrapped[row] = true
col -= width
row += 1
}
this.EnsureLine(row)
let line = this.lines[row]
if (col > line.length)
line += ' '.repeat(col - line.length)
line = line.slice(0, col) + char[0] + line.slice(col)
this.lines[row] = line
this.ApplyInsertStyle(row, col, this.currentCellStyle)
this.UpdateLine(row)
this.ReflowFromRow(row)
}
RemoveCell() {
let row = this.cursorPosition.row
let removeIndex = this.cursorPosition.col - 1
if (removeIndex < 0) {
if (row <= 0 || !this.lineWrapped[row - 1])
return
row -= 1
removeIndex = (this.lines[row] ?? '').length - 1
if (removeIndex < 0)
return
}
this.EnsureLine(row)
let line = this.lines[row]
if (removeIndex >= line.length)
return
line = line.slice(0, removeIndex) + line.slice(removeIndex + 1)
this.lines[row] = line
this.ApplyRemoveStyle(row, removeIndex)
this.UpdateLine(row)
this.ReflowFromRow(row)
}
ResetCellSize() {
// dynamically determine cell size with dom
const cell = document.createElement('span')
cell.textContent = 'A'
cell.style.position = 'absolute'
cell.style.visibility = 'hidden'
cell.style.whiteSpace = 'pre'
this.terminal.appendChild(cell)
this.cellWidth = cell.offsetWidth
this.cellHeight = cell.offsetHeight
cell.remove()
}
GetHeightCells(): number {
const rect = this.terminal.getBoundingClientRect()
const height = Math.max(0, document.documentElement.clientHeight - rect.top)
return height / this.cellHeight
}
GetWidthCells(): number {
const rect = this.terminal.getBoundingClientRect()
const width = Math.max(0, document.documentElement.clientWidth - rect.left)
return width / this.cellWidth
}
private GetWrapWidth(): number {
const width = this.GetWidthCells()
if (!Number.isFinite(width) || width <= 0)
return 1
return Math.max(1, Math.floor(width))
}
UpdateCursor() {
this.SetCursorStyle(this.cursorStyle)
const rect = this.terminal.getBoundingClientRect()
const left = this.GetCursorXOffset() + rect.left + window.scrollX
const top = this.cursorPosition.row * this.cellHeight + rect.top + window.scrollY
this.cursor.style.left = `${left}px`
this.cursor.style.top = `${top}px`
}
GetCursorPosition(): CursorPosition {
return { col: this.cursorPosition.col, row: this.cursorPosition.row }
}
SetCursorPosition(col: number, row: number) {
const width = this.GetWrapWidth()
const clampedCol = Number.isFinite(width) ? Math.min(col, width - 1) : col
this.cursorPosition.col = Math.max(clampedCol, 0)
this.cursorPosition.row = Math.max(row, 0)
this.EnsureLine(this.cursorPosition.row)
this.UpdateCursor()
}
MoveCursor(col: number, row: number, absolute: { x: boolean; y: boolean } = { x: false, y: false }) {
const width = this.GetWrapWidth()
let targetCol = !absolute.x ? this.cursorPosition.col + col : col
let targetRow = !absolute.y ? this.cursorPosition.row + row : row
if (!absolute.x && col !== 0) {
if (targetCol >= width) {
const shift = Math.floor(targetCol / width)
targetRow += shift
targetCol = targetCol % width
} else if (targetCol < 0) {
while (targetCol < 0 && targetRow > 0) {
if (!this.lineWrapped[targetRow - 1]) {
targetCol = 0
break
}
targetRow -= 1
targetCol += width
}
if (targetCol < 0)
targetCol = 0
}
}
this.SetCursorPosition(targetCol, targetRow)
}
SetCursorStyle(style: CursorStyle) {
switch (style) {
default:
this.cursor.style.width = `${this.cellWidth}px`
this.cursor.style.height = `${this.cellHeight}px`
}
}
private EnsureLine(row: number) {
let added = false
while (this.lineElements.length <= row) {
const paragraph = document.createElement('p')
paragraph.style.height = `${this.cellHeight}px`
paragraph.id = `line-${this.lineElements.length}`
paragraph.className = 'line'
this.terminal.appendChild(paragraph)
this.lineElements.push(paragraph)
this.lines.push('')
this.lineWrapped.push(false)
added = true
}
if (added)
this.ScheduleScroll()
}
private UpdateLine(row: number) {
const line = this.lineElements[row]
if (!line)
return
const text = this.lines[row] ?? ''
const styles = this.lineStyles.get(row)
if (!styles || styles.size === 0) {
line.textContent = text
return
}
for (const col of Array.from(styles.keys())) {
if (col < 0 || col >= text.length)
styles.delete(col)
}
if (styles.size === 0) {
this.lineStyles.delete(row)
line.textContent = text
return
}
const sorted = Array.from(styles.entries()).sort((a, b) => a[0] - b[0])
const fragment = document.createDocumentFragment()
let cursor = 0
let index = 0
while (index < sorted.length) {
const [col, style] = sorted[index]
if (col > cursor)
fragment.append(document.createTextNode(text.slice(cursor, col)))
let runStart = col
let runEnd = col
while (index + 1 < sorted.length) {
const [nextCol, nextStyle] = sorted[index + 1]
if (nextCol !== runEnd + 1 || nextStyle !== style)
break
index += 1
runEnd = nextCol
}
const span = document.createElement('span')
span.textContent = text.slice(runStart, runEnd + 1)
span.style.cssText = style
fragment.append(span)
cursor = runEnd + 1
index += 1
}
if (cursor < text.length)
fragment.append(document.createTextNode(text.slice(cursor)))
line.replaceChildren(fragment)
}
private ReflowFromRow(startRow: number) {
const width = this.GetWrapWidth()
if (width <= 0)
return
this.EnsureLine(startRow)
let text = this.lines[startRow] ?? ''
let endRow = startRow
while (this.lineWrapped[endRow]) {
endRow += 1
if (endRow >= this.lines.length)
break
text += this.lines[endRow] ?? ''
}
const lineLengths: number[] = []
for (let row = startRow; row <= endRow; row++)
lineLengths.push((this.lines[row] ?? '').length)
const styledIndexes = new Map<number, string>()
let styleOffset = 0
for (let row = startRow; row <= endRow; row++) {
const rowStyles = this.lineStyles.get(row)
const rowLength = lineLengths[row - startRow]
if (rowStyles && rowLength > 0) {
for (const [col, style] of rowStyles) {
if (col >= 0 && col < rowLength)
styledIndexes.set(styleOffset + col, style)
}
}
styleOffset += rowLength
}
let row = startRow
let index = 0
if (text.length === 0) {
this.lines[row] = ''
this.lineWrapped[row] = false
row += 1
} else {
while (index < text.length) {
const chunk = text.slice(index, index + width)
this.lines[row] = chunk
this.lineWrapped[row] = index + width < text.length
row += 1
index += width
if (index < text.length)
this.EnsureLine(row)
}
}
const newLastRow = text.length > 0 ? startRow + Math.floor((text.length - 1) / width) : startRow
const clearEnd = Math.max(endRow, newLastRow)
for (let clearRow = startRow; clearRow <= clearEnd; clearRow++)
this.lineStyles.delete(clearRow)
if (styledIndexes.size > 0) {
for (const [styleIndex, style] of styledIndexes) {
const targetRow = startRow + Math.floor(styleIndex / width)
const targetCol = styleIndex % width
let rowStyles = this.lineStyles.get(targetRow)
if (!rowStyles) {
rowStyles = new Map()
this.lineStyles.set(targetRow, rowStyles)
}
rowStyles.set(targetCol, style)
}
}
for (let i = row; i <= endRow; i++) {
if (this.lines[i] !== '' || this.lineWrapped[i]) {
this.lines[i] = ''
this.lineWrapped[i] = false
}
}
const renderEnd = Math.max(endRow, newLastRow)
for (let renderRow = startRow; renderRow <= renderEnd; renderRow++)
this.UpdateLine(renderRow)
}
private UpdateCurrentStyle() {
const styles: string[] = []
if (this.currentForeground)
styles.push(`color: ${this.currentForeground}`)
if (this.currentBackground)
styles.push(`background-color: ${this.currentBackground}`)
this.currentCellStyle = styles.length > 0 ? styles.join('; ') : null
}
private ApplyStyleRange(row: number, startCol: number, length: number, style: string | null) {
if (!Number.isFinite(row) || !Number.isFinite(startCol) || length <= 0)
return
let rowStyles = this.lineStyles.get(row)
if (!rowStyles && !style)
return
if (!rowStyles) {
rowStyles = new Map()
this.lineStyles.set(row, rowStyles)
}
const start = Math.max(0, Math.floor(startCol))
const end = start + Math.max(0, Math.floor(length))
for (let col = start; col < end; col++) {
if (style)
rowStyles.set(col, style)
else
rowStyles.delete(col)
}
if (rowStyles.size === 0)
this.lineStyles.delete(row)
}
private ApplyInsertStyle(row: number, col: number, style: string | null) {
const rowStyles = this.lineStyles.get(row)
if (!rowStyles && !style)
return
const updated = new Map<number, string>()
if (rowStyles) {
for (const [index, cssText] of rowStyles) {
if (index >= col)
updated.set(index + 1, cssText)
else
updated.set(index, cssText)
}
}
if (style)
updated.set(col, style)
if (updated.size > 0)
this.lineStyles.set(row, updated)
else
this.lineStyles.delete(row)
}
private ApplyRemoveStyle(row: number, col: number) {
const rowStyles = this.lineStyles.get(row)
if (!rowStyles)
return
const updated = new Map<number, string>()
for (const [index, cssText] of rowStyles) {
if (index === col)
continue
if (index > col)
updated.set(index - 1, cssText)
else
updated.set(index, cssText)
}
if (updated.size > 0)
this.lineStyles.set(row, updated)
else
this.lineStyles.delete(row)
}
private static ToRgbColor(red: number, green: number, blue: number): string {
const clamp = (value: number) => Math.min(255, Math.max(0, Math.round(value)))
const r = clamp(red)
const g = clamp(green)
const b = clamp(blue)
return `rgb(${r}, ${g}, ${b})`
}
private static GetAnsi256Color(code: number): string | null {
if (!Number.isFinite(code))
return null
const index = Math.floor(code)
if (index < 0 || index > 255)
return null
if (index < 16)
return Terminal.AnsiColors[index]
if (index <= 231) {
const offset = index - 16
const r = Math.floor(offset / 36)
const g = Math.floor((offset % 36) / 6)
const b = offset % 6
const levels = [0, 95, 135, 175, 215, 255]
return `rgb(${levels[r]}, ${levels[g]}, ${levels[b]})`
}
const grayscale = 8 + (index - 232) * 10
return `rgb(${grayscale}, ${grayscale}, ${grayscale})`
}
private GetCursorXOffset(): number {
const row = this.cursorPosition.row
const col = this.cursorPosition.col
const line = this.lineElements[row]
const text = this.lines[row] ?? ''
if (!line)
return col * this.cellWidth
const textLength = text.length
const clamped = Math.min(col, textLength)
let width = 0
const hasSingleTextNode = line.childNodes.length === 1 && line.firstChild?.nodeType === Node.TEXT_NODE
if (clamped > 0) {
if (clamped === textLength) {
width = line.getBoundingClientRect().width
} else if (this.cursorRange) {
if (hasSingleTextNode) {
const textNode = line.firstChild as Text
this.cursorRange.setStart(textNode, 0)
this.cursorRange.setEnd(textNode, clamped)
width = this.cursorRange.getBoundingClientRect().width
} else {
const target = this.FindTextNodeAtOffset(line, clamped)
if (target) {
this.cursorRange.setStart(line, 0)
this.cursorRange.setEnd(target.node, target.offset)
width = this.cursorRange.getBoundingClientRect().width
}
}
}
}
if (clamped > 0 && width === 0)
width = clamped * this.cellWidth
if (col > textLength)
width += (col - textLength) * this.cellWidth
return width
}
private FindTextNodeAtOffset(root: HTMLElement, offset: number): { node: Text; offset: number } | null {
if (offset <= 0)
return null
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
let remaining = offset
let node = walker.nextNode() as Text | null
while (node) {
const length = node.data.length
if (remaining <= length)
return { node, offset: remaining }
remaining -= length
node = walker.nextNode() as Text | null
}
return null
}
private RefreshMetrics() {
this.ResetCellSize()
this.lineElements.forEach(line => {
line.style.height = `${this.cellHeight}px`
})
this.UpdateCursor()
}
private CreateMetricsRefreshListener() {
if ('fonts' in document) {
document.fonts.ready.then(() => {
this.RefreshMetrics()
})
}
window.addEventListener('resize', () => this.RefreshMetrics())
}
private ScheduleScroll() {
if (this.scrollPending)
return
this.scrollPending = true
requestAnimationFrame(() => {
this.scrollPending = false
const lastLine = this.lineElements[this.lineElements.length - 1]
if (lastLine)
lastLine.scrollIntoView({ block: 'end' })
})
}
}

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