From 646a9f6b7a4e973d44e298afeb8148327d22099c Mon Sep 17 00:00:00 2001 From: binekrasik Date: Thu, 21 May 2026 23:42:14 +0200 Subject: [PATCH] chore: fix bugs & qol --- src/program/Cp.ts | 59 +++++++++ src/program/Ls.ts | 3 +- src/program/Mv.ts | 34 +++-- src/program/Tree.ts | 45 +++++++ src/shell/wush/InputManager.ts | 16 ++- src/shell/wush/Wush.ts | 4 + src/terminal/Terminal.ts | 235 +++++++++++++++++++++++++++++++++ 7 files changed, 383 insertions(+), 13 deletions(-) create mode 100644 src/program/Cp.ts create mode 100644 src/program/Tree.ts diff --git a/src/program/Cp.ts b/src/program/Cp.ts new file mode 100644 index 0000000..3f9cf1d --- /dev/null +++ b/src/program/Cp.ts @@ -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, stdout: SimpleStream, workdir: Item, args: string[]): Promise { + 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 + } +} diff --git a/src/program/Ls.ts b/src/program/Ls.ts index 9d30a9b..dfd5f19 100644 --- a/src/program/Ls.ts +++ b/src/program/Ls.ts @@ -9,7 +9,6 @@ export class Ls extends Program { async Exec(_: SimpleStream, stdout: SimpleStream, workdir: Item, args: string[]): Promise { // open the target directory, default to workdir - const item = args[1] ? await Item.openDir(Item.NormalizePath( args[1].startsWith('/') @@ -32,7 +31,7 @@ export class Ls extends Program { 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.GetName()}\n`) + 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 diff --git a/src/program/Mv.ts b/src/program/Mv.ts index 0b56ec2..1b9f1de 100644 --- a/src/program/Mv.ts +++ b/src/program/Mv.ts @@ -16,30 +16,46 @@ export class Mv extends Program { 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]}`)) - item2 = await Item.openDir(Item.NormalizePath(args[2].startsWith('/') ? args[2] : `${workdir.GetPath()}/${args[2]}`)) } 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 directory ${item1.GetPath()} does not exist.\n`) + if (!await item1.Exists()) { + stdout.emit(`mv: error: source item ${item1.GetPath()} does not exist.\n`) return 2 } - if (await item2.Exists()) { - stdout.emit(`mv: error: destination directory ${item2.GetPath()} already exists.\n`) + if (await item2.Exists() && !destIsDir) { + stdout.emit(`mv: error: destination item ${item2.GetPath()} already exists.\n`) return 2 } - await item2.Create() - await item1.Copy(item2) + // 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() - stdout.emit(`-> moved ${item1.GetPath()} -> ${item2.GetPath()}\n`) - return 0 } } diff --git a/src/program/Tree.ts b/src/program/Tree.ts new file mode 100644 index 0000000..88c6b58 --- /dev/null +++ b/src/program/Tree.ts @@ -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, stdout: SimpleStream, workdir: Item, args: string[]): Promise { + // 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, item: Item, depth: number): Promise { + 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) + } + } +} diff --git a/src/shell/wush/InputManager.ts b/src/shell/wush/InputManager.ts index c301a74..59dd661 100644 --- a/src/shell/wush/InputManager.ts +++ b/src/shell/wush/InputManager.ts @@ -140,6 +140,14 @@ export class InputManager { 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() @@ -149,8 +157,12 @@ export class InputManager { break case 'F5': - location.reload() - return + if (InputManager.HasModifierCombo(this.modifierKeys, ['alt'])) { + location.reload() + return + } + + break } // redirect input to a running program instead diff --git a/src/shell/wush/Wush.ts b/src/shell/wush/Wush.ts index 135f1ee..5646676 100644 --- a/src/shell/wush/Wush.ts +++ b/src/shell/wush/Wush.ts @@ -902,6 +902,10 @@ export class Wush extends Shell { this.terminal.NewPage() return true } + case 'm': { + this.terminal.ApplyCellStyleCodes(values) + return true + } default: return false } diff --git a/src/terminal/Terminal.ts b/src/terminal/Terminal.ts index 0661860..8170208 100644 --- a/src/terminal/Terminal.ts +++ b/src/terminal/Terminal.ts @@ -6,6 +6,24 @@ 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 @@ -16,6 +34,9 @@ export class Terminal { private lineElements: HTMLElement[] = [] private lineWrapped: boolean[] = [] private lineStyles: Map> = 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 @@ -76,6 +97,7 @@ export class Terminal { const width = this.GetWrapWidth() let row = this.cursorPosition.row let col = this.cursorPosition.col + const style = this.currentCellStyle while (col >= width) { this.EnsureLine(row) @@ -112,6 +134,7 @@ export class Terminal { } this.lines[row] = line + this.ApplyStyleRange(row, col, chunk.length, style) this.UpdateLine(row) remaining = remaining.slice(chunk.length) @@ -157,6 +180,7 @@ export class Terminal { } this.lines[row] = line + this.ApplyStyleRange(row, col, 1, this.currentCellStyle) this.UpdateLine(row) } @@ -200,6 +224,102 @@ export class Terminal { 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 @@ -220,6 +340,7 @@ export class Terminal { 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) } @@ -245,6 +366,7 @@ export class Terminal { line = line.slice(0, removeIndex) + line.slice(removeIndex + 1) this.lines[row] = line + this.ApplyRemoveStyle(row, removeIndex) this.UpdateLine(row) this.ReflowFromRow(row) } @@ -514,6 +636,119 @@ export class Terminal { 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() + 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() + 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