Compare commits

...

13 Commits

Author SHA1 Message Date
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
18 changed files with 489 additions and 32 deletions

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

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`
- Univerzálně:
1. pomocí npm příkazem `npm i -g bun`
2. z oficiální stránky https://bun.sh/
- Win NT:
1. ze stránky https://bun.sh/
1. z powershellu příkazem `powershell -c "irm bun.sh/install.ps1 | iex"`
2. 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/
1. z terminálu příkazem `curl -fsSL https://bun.sh/install | bash`
2. 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:
- `Chromium 146.0.7680.153 (Official Build) Arch Linux (64-bit)`
- `Zen Browser 1.19.3b (Firefox 148.0.2) (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,30 @@
<!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>
<!-- google fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&display=swap" rel="stylesheet" />
<!-- 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

@@ -11,5 +11,8 @@
"devDependencies": {
"typescript": "~5.9.3",
"vite": "^8.0.1"
},
"dependencies": {
"sass": "^1.98.0"
}
}

20
src/app.ts Normal file
View File

@@ -0,0 +1,20 @@
import { CreateKeyboardListeners } from './input/keyboard'
import { Wush } from './shell/Wush'
import { Terminal } from './terminal/Terminal'
import { EventBroadcaster } from './utils/EventBroadcaster'
// Initializes the app
const init = () => {
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()
terminal.LoadShell(new Wush(localBroadcaster, terminal))
}
init()

0
src/init.ts Normal file
View File

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

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

@@ -0,0 +1,15 @@
import type { Terminal } from '../terminal/Terminal'
import type { EventBroadcaster } from '../utils/EventBroadcaster'
export abstract class Shell {
broadcaster: EventBroadcaster
terminal: Terminal
constructor(broadcaster: EventBroadcaster, terminal: Terminal) {
this.broadcaster = broadcaster
this.terminal = terminal
}
abstract Init(): void
abstract HandleKeyInput(key: string, isCharacter: boolean): void
}

87
src/shell/Wush.ts Normal file
View File

@@ -0,0 +1,87 @@
// Web-Uno Shell
// the best name I could come up with lmao
import { Terminal } from '../terminal/Terminal'
import type { EventBroadcaster } from '../utils/EventBroadcaster'
import { Shell } from './Shell'
export class Wush extends Shell {
private buffer: string[] = []
private bufferPos: number = 0
constructor(broadcaster: EventBroadcaster, terminal: Terminal) {
super(broadcaster, terminal)
}
Init() {
this.broadcaster.on('keydown', (key: string, isCharacter: boolean) =>
this.HandleKeyInput(key, isCharacter),
)
this.Prompt()
}
Prompt() {
this.terminal.Write('hi -> ')
}
PushToBuffer(text: string) {
text.split('').forEach(char => {
this.buffer.splice(this.bufferPos, 0, char)
this.bufferPos++
})
console.log(this.buffer)
}
RemoveLastCharsFromBuffer(amount: number) {
this.buffer.splice(this.buffer.length - amount, amount)
this.bufferPos -= amount
console.log(this.buffer)
}
MoveBufferPos(index: number, absolute: boolean = false) {
this.bufferPos = Math.max(absolute ? index : this.bufferPos + index, 0)
}
FlushBuffer() {
this.buffer = []
this.bufferPos = 0
}
ExecuteBuffer() {
const args = this.buffer.join('').split(' ')
this.FlushBuffer()
console.log(`Executing ${args[0]} with args '${args}'`)
}
HandleKeyInput(key: string, isCharacter: boolean) {
if (!isCharacter) {
switch (key) {
case 'ArrowLeft':
this.terminal.MoveCursor(-1, 0)
this.MoveBufferPos(-1)
break
case 'ArrowRight':
this.terminal.MoveCursor(1, 0)
this.MoveBufferPos(1)
break
case 'Backspace':
this.terminal.RemoveCell()
this.RemoveLastCharsFromBuffer(1)
this.terminal.MoveCursor(-1, 0)
break
case 'Enter':
this.terminal.MoveCursor(0, 1, { x: true, y: false })
this.ExecuteBuffer()
this.Prompt()
break
}
} else {
this.terminal.InsertCell(key)
this.PushToBuffer(key)
this.terminal.MoveCursor(1, 0)
}
}
}

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

@@ -0,0 +1,9 @@
@forward "terminal.scss";
body {
margin: 0;
}
* {
box-sizing: border-box;
}

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

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

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

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

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

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

View File

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

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

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

View File

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

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