sync: sync wip changes

This commit is contained in:
binekrasik
2026-05-20 13:10:34 +02:00
parent b5089251ff
commit abe52acaa7
6 changed files with 389 additions and 112 deletions

View File

@@ -4,7 +4,8 @@ import { Wush } from './shell/wush/Wush'
import { Terminal } from './terminal/Terminal'
import { EventBroadcaster } from './utils/EventBroadcaster'
export const WEBSHELL_VERSION = "0.1.0"
export const WEBSHELL_VERSION = "0.2.0"
export const VERSION_COMMENT: string | null = "hi"
// initialize object store for webfs
let WebfsDatabase: IDBDatabase | null = null

View File

@@ -1,4 +1,4 @@
import { GetCurrentTerminal, WEBSHELL_VERSION } from '../app'
import { GetCurrentTerminal, WEBSHELL_VERSION, VERSION_COMMENT } from '../app'
import type { Item } from '../fs/Item'
import { Terminal } from '../terminal/Terminal'
import type { SimpleStream } from '../utils/SimpleStream'
@@ -10,6 +10,10 @@ export class Info extends Program {
stdout.emit(`Terminal v${Terminal.Version}\n`)
stdout.emit(`Running ${GetCurrentTerminal().GetShell()?.Name} v${GetCurrentTerminal().GetShell()?.Version}\n`)
// print a comment for the current release if any
if (VERSION_COMMENT)
stdout.emit(`-> ${VERSION_COMMENT}\n`)
return 0
}
}

View File

@@ -4,18 +4,37 @@
*/
export class ShellEnvironment {
private variables: Record<string, string> = {}
private aliases: Record<string, string> = {}
SetVariable(name: string, value: string | null): void {
// remove the variable if value is null
if (value === null) {
// remove the variable if value is null
delete this.variables[name]
return
}
this.variables[name] = value
} else this.variables[name] = value
}
GetVariable(name: string): string | undefined {
return this.variables[name]
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,129 @@
import type { Wush } from "./Wush"
export type ModifierKey = 'shift' | 'rightShift' | 'ctrl' | 'rightCtrl' | 'alt' | 'rightAlt'
export class ShellInputManager {
private modifierKeys: ModifierKey[] = []
private shell: Wush
constructor(shell: Wush) {
this.shell = shell
}
/**
* Checks if the modifier includes every key of the combo
* @param combo modifier combo to check for
* @returns boolean according to the check truthfullness
*/
HasModifierCombo(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
}
/**
*
* @param event keypress event to process
*/
private processKeypressEvent(event: KeyboardEvent) {
}
HandleKeyInput(key: string, modifiers: ModifierKey[], isCharacter: boolean) {
// handle special input
switch (key.toUpperCase()) {
case 'R':
if (hasModifierCombo(['ctrl']))
location.reload()
break
case 'F5':
location.reload()
break
}
if (this.execExitCode === -1) {
console.log('redirecting input to a running program')
this.WriteStdin(key)
return
}
// handle standard keys
if (!isCharacter) {
switch (key) {
case 'ArrowLeft':
if (this.bufferPos > 0) {
this.bufferPos -= 1
this.SyncCursorToBuffer()
}
break
case 'ArrowRight':
if (this.bufferPos < this.buffer.length) {
this.bufferPos += 1
this.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.bufferPos === 0)
break
this.ExitHistoryNavigation()
this.terminal.RemoveCell()
this.RemoveCharFromBuffer(1, this.bufferPos)
this.SyncCursorToBuffer()
break
case 'Delete':
if (this.bufferPos >= this.buffer.length || this.buffer.length <= 0)
break
this.ExitHistoryNavigation()
this.bufferPos += 1
this.SyncCursorToBuffer()
this.terminal.RemoveCell()
this.RemoveCharFromBuffer(1, this.bufferPos)
this.SyncCursorToBuffer()
break
case 'Enter':
// send the buffer to stdin if an exec is running
if (this.execExitCode === -1) {
this.WriteStdin(`${this.buffer.join('')}\n`)
this.FlushBuffer()
} else {
// "execute" the buffer
this.terminal.MoveCursor(0, 1, { x: true, y: false })
const command = this.buffer.join('')
if (command.length > 0)
this.history.push(command)
this.historyIndex = null
this.historyDraft = ''
this.ExecuteLineBuffer()
}
break
}
} else {
this.ExitHistoryNavigation()
// push the character into the buffer
this.InsertText(key)
}
}
}

View File

@@ -25,11 +25,10 @@ import { Mkdir } from '../../program/Mkdir'
import { Cd } from '../../program/Cd'
import { Printf } from '../../program/Printf'
import { Pwd } from '../../program/Pwd'
// import { Pwd } from '../program/Pwd'
// import { Cd } from '../program/Cd'
import { ShellEnvironment } from './ShellEnvironment'
export class Wush extends Shell {
public readonly Version = "0.1.0"
public readonly Version = "0.3.1"
public readonly Name = "wush"
// buffer
@@ -47,8 +46,7 @@ export class Wush extends Shell {
*/
private execExitCode: number = 0
// todo: workers
// private workersAllowed: boolean = false
readonly environment: ShellEnvironment = new ShellEnvironment()
// streams
readonly stdin: SimpleStream<string>
@@ -66,6 +64,7 @@ export class Wush extends Shell {
}
async Init() {
// initialize keyboard listener
this.broadcaster.on('keydown', (key: string, isCharacter: boolean) =>
this.HandleKeyInput(key, isCharacter),
)
@@ -73,6 +72,9 @@ export class Wush extends Shell {
// 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()
@@ -92,7 +94,7 @@ export class Wush extends Shell {
this.programs['cd'] = new Cd(this)
this.programs['printf'] = new Printf()
this.stdout.on(data => this.WriteEscapedString(data))
// initial prompt
this.Prompt()
}
@@ -158,9 +160,9 @@ export class Wush extends Shell {
}
WriteEscapedString(data: string) {
let i = 0
let buffer = ''
// write the buffer to screen
const flush = () => {
if (buffer.length === 0)
return
@@ -169,11 +171,15 @@ export class Wush extends Shell {
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
@@ -181,10 +187,13 @@ export class Wush extends Shell {
}
}
// check for control codes
if (char === '\f' || char === '\n') {
flush()
this.ProcessSimpleControlCode(char)
this.ProcessControlCode(char)
i++
continue
}
@@ -200,15 +209,11 @@ export class Wush extends Shell {
this.buffer.splice(this.bufferPos, 0, char)
this.bufferPos++
})
// console.log(this.buffer)
}
RemoveCharFromBuffer(amount: number, index: number) {
this.buffer.splice(index - amount, amount)
this.bufferPos -= amount
// console.log(this.buffer)
}
MoveBufferPos(index: number, absolute: boolean = false) {
@@ -220,7 +225,8 @@ export class Wush extends Shell {
this.bufferPos = 0
}
async ExecuteBuffer() {
// 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 = {
@@ -231,23 +237,32 @@ export class Wush extends Shell {
type ParsedPipeline = { commands: ParsedCommand[] }
type ParsedListItem = { pipeline: ParsedPipeline; operator: '&&' | '||' | ';' | null }
// splits the buffer into 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 word to the token buffer
*/
const pushWord = () => {
if (!wordStarted)
return
tokens.push({ type: 'word', value: current })
// reset the state
current = ''
wordStarted = false
}
// iterates over the provided text buffer
for (let i = 0; i < text.length; i++) {
const char = text[i]
@@ -258,6 +273,7 @@ export class Wush extends Shell {
continue
}
// process strict (single quote) strings
if (inSingleQuote) {
if (char === "'") {
inSingleQuote = false
@@ -270,6 +286,7 @@ export class Wush extends Shell {
continue
}
// process classic (double quote) strings
if (inDoubleQuote) {
if (char === '"') {
inDoubleQuote = false
@@ -289,18 +306,23 @@ export class Wush extends Shell {
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
@@ -365,6 +387,7 @@ export class Wush extends Shell {
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[] = []
@@ -426,8 +449,18 @@ export class Wush extends Shell {
return { list, error: null }
}
const line = this.buffer.join('')
this.FlushBuffer()
// 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) {
@@ -483,7 +516,7 @@ export class Wush extends Shell {
this.Prompt()
}
ProcessSimpleControlCode(code: string): boolean {
ProcessControlCode(code: string): boolean {
switch (code) {
case '\f':
this.terminal.NewPage()
@@ -542,79 +575,6 @@ export class Wush extends Shell {
}
}
HandleKeyInput(key: string, isCharacter: boolean) {
if (this.execExitCode === -1) console.log('program running')
if (!isCharacter) {
switch (key) {
case 'ArrowLeft':
if (this.bufferPos > 0) {
this.bufferPos -= 1
this.SyncCursorToBuffer()
}
break
case 'ArrowRight':
if (this.bufferPos < this.buffer.length) {
this.bufferPos += 1
this.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.bufferPos === 0)
break
this.ExitHistoryNavigation()
this.terminal.RemoveCell()
this.RemoveCharFromBuffer(1, this.bufferPos)
this.SyncCursorToBuffer()
break
case 'Delete':
if (this.bufferPos >= this.buffer.length || this.buffer.length <= 0)
break
this.ExitHistoryNavigation()
this.bufferPos += 1
this.SyncCursorToBuffer()
this.terminal.RemoveCell()
this.RemoveCharFromBuffer(1, this.bufferPos)
this.SyncCursorToBuffer()
break
case 'Enter':
// send the buffer to stdin if an exec is running
if (this.execExitCode === -1) {
this.WriteStdin(`${this.buffer.join('')}\n`)
this.FlushBuffer()
} else {
// "execute" the buffer
this.terminal.MoveCursor(0, 1, { x: true, y: false })
const command = this.buffer.join('')
if (command.length > 0)
this.history.push(command)
this.historyIndex = null
this.historyDraft = ''
this.ExecuteBuffer()
}
break
case 'F5':
location.reload()
break
}
} else {
this.ExitHistoryNavigation()
// push the character into the buffer
this.InsertText(key)
}
}
private GetWrapWidth(): number {
const width = Math.floor(this.terminal.GetWidthCells())
if (!Number.isFinite(width) || width <= 0)

View File

@@ -5,7 +5,7 @@ import sqs from '../utils/sqs'
import type { CursorPosition, CursorStyle } from './CursorProperties'
export class Terminal {
public static readonly Version = "0.1.1"
public static readonly Version = "0.2.1"
private terminal: HTMLElement
private cursor: HTMLElement
@@ -15,6 +15,7 @@ export class Terminal {
private lines: string[] = []
private lineElements: HTMLElement[] = []
private lineWrapped: boolean[] = []
private lineStyles: Map<number, Map<number, string>> = new Map()
private scrollPending = false
private cursorRange: Range | null = null
@@ -34,7 +35,7 @@ export class Terminal {
this.SetCursorStyle('bar')
this.NewPage()
this.SetupMetricsRefresh()
this.CreateMetricsRefreshListener()
}
async LoadShell(shell: Shell) {
@@ -53,6 +54,7 @@ export class Terminal {
this.lines = []
this.lineElements = []
this.lineWrapped = []
this.lineStyles.clear()
this.SetCursorPosition(0, 0)
}
@@ -158,6 +160,46 @@ export class Terminal {
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()
}
InsertCell(char: string) {
const width = this.GetWrapWidth()
let row = this.cursorPosition.row
@@ -208,7 +250,7 @@ export class Terminal {
}
ResetCellSize() {
// dynamically determine cell size using dom
// dynamically determine cell size with dom
const cell = document.createElement('span')
cell.textContent = 'A'
cell.style.position = 'absolute'
@@ -328,8 +370,61 @@ export class Terminal {
private UpdateLine(row: number) {
const line = this.lineElements[row]
if (line)
line.textContent = this.lines[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) {
@@ -350,19 +445,35 @@ export class Terminal {
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.UpdateLine(row)
this.lineWrapped[row] = false
row += 1
} else {
while (index < text.length) {
const chunk = text.slice(index, index + width)
this.lines[row] = chunk
this.UpdateLine(row)
this.lineWrapped[row] = index + width < text.length
row += 1
@@ -373,13 +484,34 @@ export class Terminal {
}
}
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.UpdateLine(i)
this.lineWrapped[i] = false
}
}
const renderEnd = Math.max(endRow, newLastRow)
for (let renderRow = startRow; renderRow <= renderEnd; renderRow++)
this.UpdateLine(renderRow)
}
private GetCursorXOffset(): number {
@@ -395,23 +527,55 @@ export class Terminal {
const clamped = Math.min(col, textLength)
let width = 0
const textNode = line.firstChild
if (textNode && textNode.nodeType === Node.TEXT_NODE && clamped > 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) {
this.cursorRange.setStart(textNode, 0)
this.cursorRange.setEnd(textNode, clamped)
width = this.cursorRange.getBoundingClientRect().width
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 => {
@@ -420,7 +584,7 @@ export class Terminal {
this.UpdateCursor()
}
private SetupMetricsRefresh() {
private CreateMetricsRefreshListener() {
if ('fonts' in document) {
document.fonts.ready.then(() => {
this.RefreshMetrics()