happy bday!

This commit is contained in:
Joey Eamigh
2024-05-26 22:45:45 -04:00
commit be3a13eee1
34 changed files with 233005 additions and 0 deletions

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

23
.prettierrc.mjs Normal file
View File

@@ -0,0 +1,23 @@
/** @type {import("prettier").Config} */
export default {
bracketSpacing: true,
bracketSameLine: true,
singleQuote: true,
trailingComma: 'all',
arrowParens: 'avoid',
semi: true,
overrides: [
{
files: ['*.ts', '*.js', '*.tsx', '*.jsx', '*.cjs', '*.mjs', '*.astro'],
options: {
printWidth: 120,
},
},
{
files: ['*.html'],
options: {
printWidth: 100,
},
},
],
};

3
Dockerfile Normal file
View File

@@ -0,0 +1,3 @@
FROM nginx:alpine
COPY dist /usr/share/nginx/html

BIN
bun.lockb Executable file

Binary file not shown.

17
clean.ts Normal file
View File

@@ -0,0 +1,17 @@
import dictionary from './en_eo_dict.json' assert { type: 'json' };
const wordRegex = /^[a-z]+$/;
const cleanDict = dictionary
.filter(({ word }) => wordRegex.test(word))
.filter(({ translation }) => wordRegex.test(translation))
.filter((e) => e.snc_index === null)
.reduce((acc, cur) => {
const wordExists = acc.some((item) => item.word === cur.word);
const translationExists = acc.some((item) => item.translation === cur.translation);
if (wordExists || translationExists) return acc;
return [...acc, cur];
}, [] as typeof dictionary)
.map(({ word, translation }) => ({ word, translation }));
await Bun.write('en_eo_dict_clean.json', JSON.stringify(cleanDict, null, 2));

197087
en_eo_dict.json Normal file

File diff suppressed because it is too large Load Diff

34666
en_eo_dict_clean.json Normal file

File diff suppressed because it is too large Load Diff

17
index.html Normal file
View File

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>KPL 20 Puzzle</title>
<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=Fira+Code:wght@400&display=swap" rel="stylesheet" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

32
package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "kirsten20",
"private": true,
"version": "0.0.0",
"type": "module",
"workspaces": [
"shell"
],
"scripts": {
"dev": "vite",
"build": "bun run build-wasm && tsc && vite build",
"build-wasm": "cd shell && bun run build",
"dev-wasm": "cd shell && bun run dev",
"preview": "vite preview",
"deploy": "bun run build && docker buildx build --platform linux/amd64 -t registry.24hgr.love/kirsten20:latest . && docker push registry.24hgr.love/kirsten20:latest"
},
"dependencies": {
"@xterm/addon-canvas": "^0.7.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-unicode11": "^0.8.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0",
"shell": "*"
},
"devDependencies": {
"typescript": "^5.2.2",
"vite": "^5.2.0",
"vite-plugin-wasm": "^3.3.0",
"vite-plugin-watch": "^0.3.1"
}
}

1
public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

6
shell/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
/target
**/*.rs.bk
Cargo.lock
bin/
pkg/
wasm-pack.log

3
shell/.rustfmt.toml Normal file
View File

@@ -0,0 +1,3 @@
max_width = 120
tab_spaces = 2
format_macro_matchers = true

28
shell/Cargo.toml Normal file
View File

@@ -0,0 +1,28 @@
[package]
name = "shell"
version = "0.1.0"
authors = ["Joey Eamigh <55670930+JoeyEamigh@users.noreply.github.com>"]
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[features]
default = ["console_error_panic_hook"]
[dependencies]
wasm-bindgen = "0.2.84"
yansi = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
once_cell = "1"
rand = "0.8"
getrandom = { version = "0.2", features = ["js"] }
console_error_panic_hook = { version = "0.1.7", optional = true }
[dev-dependencies]
wasm-bindgen-test = "0.3.34"
[profile.release]
opt-level = "s"

26
shell/package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "shell",
"collaborators": [
"Joey Eamigh <55670930+JoeyEamigh@users.noreply.github.com>"
],
"version": "0.1.0",
"scripts": {
"dev": "wasm-pack build --debug --no-pack",
"build": "wasm-pack build --no-pack"
},
"files": [
"./pkg/shell_bg.wasm",
"./pkg/shell.js",
"./pkg/shell_bg.js",
"./pkg/shell.d.ts"
],
"module": "./pkg/shell.js",
"types": "./pkg/shell.d.ts",
"sideEffects": [
"./pkg/shell.js",
"./pkg/snippets/*"
],
"dependencies": {
"@xterm/xterm": "^5.5.0"
}
}

View File

@@ -0,0 +1,3 @@
[toolchain]
channel = "nightly"
targets = ["wasm32-unknown-unknown"]

188
shell/src/core/challenge.rs Normal file
View File

@@ -0,0 +1,188 @@
use once_cell::sync::OnceCell;
use serde::Deserialize;
use std::collections::HashMap;
use super::{coreutils, program, vfs::DICTIONARY};
use crate::ffi::xterm;
macro_rules! console_log { ($($t:tt)*) => (crate::ffi::console::log(&format_args!($($t)*).to_string())) }
static DICT: OnceCell<Vec<DictEntry>> = OnceCell::new();
#[derive(Deserialize)]
struct DictEntry {
pub word: String,
pub translation: String,
}
pub fn challenge(term: &xterm::Terminal, _args: Vec<&str>) -> program::Program {
coreutils::clear(term, vec![]);
term.writeln("welcome to the challenge!");
term.writeln(
"\nyour task today is to translate 15 words from esperanto to english, then 15 words from english to esperanto",
);
term.writeln("\ngood luck!");
term.writeln("\npress return to continue");
handle_input
}
fn handle_input(term: &xterm::Terminal, state: &mut program::ProgramState, char: char) -> Result<Option<usize>, ()> {
let data = if let Some(data) = state.data.downcast_mut::<HashMap<String, usize>>() {
data
} else {
state.data = Box::new(HashMap::<String, usize>::new());
state.data.downcast_mut::<HashMap<String, usize>>().unwrap()
};
let correct = data.get("correct").copied().unwrap_or(0);
let incorrect = data.get("incorrect").copied().unwrap_or(0);
let remaining = data.get("remaining").copied().unwrap_or(30);
match char {
'\r' => {
if data.is_empty() {
coreutils::clear(term, vec![]);
data.insert("correct".to_string(), 0);
data.insert("incorrect".to_string(), 0);
data.insert("remaining".to_string(), 30);
draw_score(term, correct, incorrect, remaining);
draw_question(term, data);
term.write("> ");
return Ok(None);
}
if remaining == 0 {
use yansi::Paint;
term.writeln(&format!(
"\n\n🎉 {} 🎉\n\nclick here to claim your prize: {}\n\n",
"happy birthday kirsten!".green().bright(),
"https://www.youtube.com/watch?v=dQw4w9WgXcQ".cyan().bright()
));
return Ok(Some(0));
}
handle_answer(term, data, &state.buffer);
state.buffer.clear();
return Ok(None);
}
'\u{7f}' => {
if state.buffer.is_empty() {
return Ok(None);
}
state.buffer.pop();
term.write("\u{8} \u{8}");
}
_ => {
state.buffer.push(char);
term.write_char(char);
}
}
Ok(None)
}
fn handle_answer(term: &xterm::Terminal, data: &mut HashMap<String, usize>, answer: &str) {
let dict = DICT.get_or_init(|| serde_json::from_str(DICTIONARY).expect("failed to parse dictionary"));
let index = data.get("index").copied().unwrap_or(0);
let mut correct = data.get("correct").copied().unwrap_or(0);
let mut incorrect = data.get("incorrect").copied().unwrap_or(0);
let mut remaining = data.get("remaining").copied().unwrap_or(30);
console_log!("index: {}", index);
console_log!("translation: {}", dict[index].translation);
console_log!("word: {}", dict[index].word);
console_log!("answer: {}", answer);
console_log!("correct: {}", dict[index].translation == *answer);
if correct < 15 {
if dict[index].translation == *answer {
correct += 1;
remaining -= 1;
data.insert("correct".to_string(), correct);
data.insert("remaining".to_string(), remaining);
} else {
incorrect += 1;
data.insert("incorrect".to_string(), incorrect);
}
} else if dict[index].word == *answer {
correct += 1;
remaining -= 1;
data.insert("correct".to_string(), correct);
data.insert("remaining".to_string(), remaining);
} else {
incorrect += 1;
data.insert("incorrect".to_string(), incorrect);
}
if remaining == 0 {
use yansi::Paint;
coreutils::clear(term, vec![]);
term.writeln(&format!("{}", "challenge complete!".green().bright()));
draw_score(term, correct, incorrect, remaining);
term.writeln("\npress return to claim your reward!");
return;
}
data.insert("index".to_string(), rand::random::<usize>() % dict.len());
coreutils::clear(term, vec![]);
draw_score(term, correct, incorrect, remaining);
draw_question(term, data);
term.write("> ");
}
fn draw_question(term: &xterm::Terminal, data: &mut HashMap<String, usize>) {
if data.is_empty() {
return;
}
let dict = DICT.get_or_init(|| serde_json::from_str(DICTIONARY).expect("failed to parse dictionary"));
let index = data
.get("index")
.copied()
.unwrap_or(rand::random::<usize>() % dict.len());
data.insert("index".to_string(), index);
let correct = data.get("correct").copied().unwrap_or(0);
use yansi::Paint;
if correct < 10 {
term.writeln(&format!(
"what is the english translation of the esperanto word '{}'?",
dict[index].word.yellow()
));
} else {
term.writeln(&format!(
"what is the esperanto translation of the english word '{}'?",
dict[index].translation.yellow()
));
}
}
fn draw_score(term: &xterm::Terminal, correct: usize, incorrect: usize, remaining: usize) {
use yansi::Paint;
let score = format!(
"correct: {}; incorrect: {}; remaining: {}",
correct.green(),
incorrect.red(),
remaining.cyan()
);
term.writeln(&score);
}

235
shell/src/core/coreutils.rs Normal file
View File

@@ -0,0 +1,235 @@
use super::vfs::{Directory, RawVfsEntry, VfsDir, VfsEntry, VirtualFileSystem};
use crate::{core::vfs::VfsFile, ffi::xterm};
pub fn ls(vfs: &mut VirtualFileSystem, term: &xterm::Terminal, args: Vec<&str>) {
use yansi::Paint;
let path = args.first().unwrap_or(&"").to_string();
let dir = parse_path(vfs, &path, false).unwrap_or(vfs.current.clone());
let children = &dir.borrow().children;
let mut files = children.iter().collect::<Vec<_>>();
files.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase()));
let files = files
.into_iter()
.map(|(name, entry)| match entry {
VfsEntry::Dir(_) => format!("{}", name.cyan().bright()),
VfsEntry::File(file) => match file {
VfsFile::VfsCallable(_) => format!("{}", name.green().bright()),
VfsFile::Callable(_) => format!("{}", name.green().bright()),
VfsFile::InputCallable(_) => format!("{}", name.green().bright()),
// VfsFile::Data(_) => format!("{}", Red.paint(name)),
_ => name.to_string(),
},
})
.collect::<Vec<_>>();
term.writeln(&files.join("\n"));
}
pub fn cd(vfs: &mut VirtualFileSystem, term: &xterm::Terminal, args: Vec<&str>) {
let path = args.first().unwrap_or(&"").to_string();
if path.is_empty() {
vfs.current = vfs.home.clone();
return;
} else if path == "/" {
vfs.current = vfs.root.clone();
return;
}
if let Some(dir) = parse_path(vfs, &path, false) {
vfs.current = dir;
} else {
term.writeln(&format!("cd: {}: no such directory", path));
}
}
pub fn clear(term: &xterm::Terminal, _args: Vec<&str>) {
term.write("\u{1b}[2J\u{1b}[H");
}
pub fn pwd(vfs: &mut VirtualFileSystem, term: &xterm::Terminal, _args: Vec<&str>) {
term.writeln(&vfs.cwd());
}
pub fn whoami(term: &xterm::Terminal, _args: Vec<&str>) {
term.writeln("kpl");
}
pub fn arch(term: &xterm::Terminal, _args: Vec<&str>) {
term.writeln("wasm32");
}
pub fn uname(term: &xterm::Terminal, _args: Vec<&str>) {
term.writeln("jinux 0.1.0");
}
pub fn mkdir(vfs: &mut VirtualFileSystem, term: &xterm::Terminal, args: Vec<&str>) {
let path = args.first().unwrap_or(&"").to_string();
if path.is_empty() {
term.writeln("usage: mkdir directory_name");
return;
}
if let Some(dir) = parse_path(vfs, &path, true) {
let name = parse_name(&path);
vfs.new_entry(
&dir,
&name,
RawVfsEntry::Dir(Directory {
children: Default::default(),
name: name.clone(),
parent: Some(dir.clone()),
}),
);
} else {
term.writeln(&format!("touch: {}: no such entry", path));
}
}
pub fn rm(vfs: &mut VirtualFileSystem, term: &xterm::Terminal, args: Vec<&str>) {
let path = args.first().unwrap_or(&"").to_string();
if path.is_empty() {
term.writeln("usage: rm name");
return;
}
if let Some(dir) = parse_path(vfs, &path, true) {
let name = parse_name(&path);
dir.borrow_mut().children.remove(&name);
} else {
term.writeln(&format!("rm: {}: no such file or directory", path));
}
}
pub fn touch(vfs: &mut VirtualFileSystem, term: &xterm::Terminal, args: Vec<&str>) {
let path = args.first().unwrap_or(&"").to_string();
if path.is_empty() {
term.writeln("usage: touch file_name");
return;
}
if let Some(dir) = parse_path(vfs, &path, true) {
let name = parse_name(&path);
vfs.new_entry(&dir, &name, RawVfsEntry::File(VfsFile::Data("".to_string())));
} else {
term.writeln(&format!("touch: {}: no such entry", path));
}
}
pub fn cat(vfs: &mut VirtualFileSystem, term: &xterm::Terminal, args: Vec<&str>) {
let path = args.first().unwrap_or(&"").to_string();
if path.is_empty() {
term.writeln("usage: cat file_name");
return;
}
let dir = parse_path(vfs, &path, true).unwrap_or(vfs.current.clone());
let name = parse_name(&path);
let binding = dir.borrow();
let entry = binding.children.get(&name);
if let Some(VfsEntry::File(file)) = entry {
if let VfsFile::Data(data) = file {
term.writeln(data);
}
} else {
term.writeln(&format!("cat: {}: no such file", path));
}
}
pub fn help(vfs: &mut VirtualFileSystem, term: &xterm::Terminal, _args: Vec<&str>) {
let root = vfs.root.borrow();
let bin = root.children.get("bin");
if let Some(VfsEntry::Dir(bin)) = bin {
let commands = bin
.borrow()
.children
.keys()
.map(|name| name.to_string())
.collect::<Vec<_>>();
term.writeln(&format!("commands in path: {}", commands.join(", ")));
} else {
term.writeln("no commands found");
}
}
fn parse_path(vfs: &VirtualFileSystem, path: &str, parent: bool) -> Option<VfsDir> {
if path == "/" {
return Some(vfs.root.clone());
}
let mut path = path.to_string();
let mut dir = if path.starts_with('/') {
path = path[1..].to_string();
vfs.root.clone()
} else {
vfs.current.clone()
};
let path = path.trim().split('/').collect::<Vec<_>>();
for name in path {
if name == ".." {
let parent = dir.borrow().parent.clone();
if let Some(parent) = parent {
dir = parent;
}
} else if name == "." {
continue;
} else {
let binding = dir.borrow().clone();
let child = binding.children.get(name);
if let Some(VfsEntry::Dir(child)) = child {
dir = child.clone();
} else if parent {
return Some(dir);
} else {
return None;
}
}
}
if parent {
Some(dir.borrow().parent.clone().unwrap_or(vfs.root.clone()))
} else {
Some(dir)
}
}
fn parse_name(path: &str) -> String {
let mut path = path.to_string();
if path.ends_with('/') {
path.pop();
}
let parts = path.split('/').collect::<Vec<_>>();
parts.last().unwrap_or(&"").to_string()
}
pub fn callable(vfs: &VirtualFileSystem, path: &str) -> Option<VfsFile> {
let root = vfs.root.borrow();
let dir = parse_path(vfs, path, true).unwrap_or(vfs.current.clone());
let name = parse_name(path);
if let Some(entry) = dir.borrow().children.get(&name)
&& let VfsEntry::File(file) = entry
{
return Some(file.clone());
}
let bin = root.children.get("bin");
if let Some(VfsEntry::Dir(bin)) = bin
&& let Some(VfsEntry::File(file)) = bin.borrow().children.get(&name)
{
return Some(file.clone());
}
None
}

5
shell/src/core/mod.rs Normal file
View File

@@ -0,0 +1,5 @@
pub mod program;
pub mod vfs;
mod challenge;
mod coreutils;

17
shell/src/core/program.rs Normal file
View File

@@ -0,0 +1,17 @@
use crate::ffi::xterm;
pub type Program = fn(&xterm::Terminal, &mut ProgramState, char) -> Result<Option<usize>, ()>;
pub struct ProgramState {
pub buffer: String,
pub data: Box<dyn std::any::Any>,
}
impl Default for ProgramState {
fn default() -> Self {
ProgramState {
buffer: String::new(),
data: Box::new(()),
}
}
}

249
shell/src/core/vfs.rs Normal file
View File

@@ -0,0 +1,249 @@
use std::{cell::RefCell, collections::HashMap, rc::Rc};
use crate::{core::challenge, ffi::xterm};
use super::{coreutils, program};
pub const DICTIONARY: &str = include_str!("../../../en_eo_dict_clean.json");
#[derive(Clone)]
pub enum VfsFile {
VfsCallable(fn(&mut VirtualFileSystem, &xterm::Terminal, Vec<&str>)),
Callable(fn(&xterm::Terminal, Vec<&str>)),
InputCallable(fn(&xterm::Terminal, Vec<&str>) -> program::Program),
Data(String),
}
impl std::fmt::Debug for VfsFile {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
VfsFile::VfsCallable(_) => write!(f, "VfsCallable"),
VfsFile::Callable(_) => write!(f, "Callable"),
VfsFile::InputCallable(_) => write!(f, "InputCallable"),
VfsFile::Data(_) => write!(f, "Data"),
}
}
}
#[derive(Clone)]
pub struct Directory {
pub name: String,
pub children: HashMap<String, VfsEntry>,
pub parent: Option<VfsDir>,
}
impl std::fmt::Debug for Directory {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut dbg = f.debug_struct("Directory");
dbg.field("name", &self.name).field("children", &self.children);
if let Some(parent) = &self.parent {
dbg.field("parent", &parent.borrow().name);
} else {
dbg.field("parent", &"None");
}
dbg.finish()
}
}
pub type VfsDir = Rc<RefCell<Directory>>;
#[derive(Clone, Debug)]
pub enum VfsEntry {
File(VfsFile),
Dir(VfsDir),
}
#[derive(Clone, Debug)]
pub enum RawVfsEntry {
File(VfsFile),
Dir(Directory),
}
#[derive(Clone, Debug)]
pub struct VirtualFileSystem {
pub root: VfsDir,
pub home: VfsDir,
pub current: VfsDir,
}
impl VirtualFileSystem {
pub fn new() -> VirtualFileSystem {
let (root, home) = build_root_fs();
VirtualFileSystem {
current: home.clone(),
home,
root,
}
}
pub fn cwd(&self) -> String {
crate::ffi::console::log(&format!("vfs: {:#?}", self));
let mut path = Vec::new();
let mut current = self.current.clone();
crate::ffi::console::log(&format!("current: {:#?}", current));
loop {
let name = current.borrow().name.clone();
path.push(name.clone());
crate::ffi::console::log(&format!("name: {:?}; path: {:?}", name, path));
let parent = current.borrow().parent.clone();
if let Some(parent) = parent {
current = parent;
} else {
break;
}
}
path.reverse();
let cwd = path.join("/")[1..].to_string();
if cwd.is_empty() {
return "/".to_string();
}
cwd
}
pub fn call(&mut self, term: &xterm::Terminal, path: &str, args: Vec<&str>) -> Result<Option<program::Program>, ()> {
let callable = coreutils::callable(self, path);
if let Some(VfsFile::VfsCallable(callable)) = callable {
callable(self, term, args);
Ok(None)
} else if let Some(VfsFile::Callable(callable)) = callable {
callable(term, args);
Ok(None)
} else if let Some(VfsFile::InputCallable(callable)) = callable {
Ok(Some(callable(term, args)))
} else if let Some(VfsFile::Data(data)) = callable {
term.writeln(&data);
Ok(None)
} else {
Err(())
}
}
pub fn new_entry(&mut self, parent: &VfsDir, name: &str, entry: RawVfsEntry) {
match entry {
RawVfsEntry::File(file) => add_child(parent, name, VfsEntry::File(file)),
RawVfsEntry::Dir(dir) => add_child(parent, name, VfsEntry::Dir(Rc::new(RefCell::new(dir)))),
}
}
}
fn add_child(parent: &VfsDir, name: &str, child: VfsEntry) {
parent.borrow_mut().children.insert(name.to_string(), child);
}
fn build_root_fs() -> (VfsDir, VfsDir) {
let root = Rc::new(RefCell::new(Directory {
name: "/".to_string(),
children: Default::default(),
parent: None,
}));
add_child(
&root,
"tmp",
VfsEntry::Dir(Rc::new(RefCell::new(Directory {
name: "tmp".to_string(),
children: HashMap::from_iter(vec![(
"some-trash".to_string(),
VfsEntry::File(VfsFile::Data("https://www.youtube.com/watch?v=dQw4w9WgXcQ".to_string())),
)]),
parent: Some(root.clone()),
}))),
);
add_child(
&root,
"bin",
VfsEntry::Dir(Rc::new(RefCell::new(Directory {
name: "bin".to_string(),
children: build_bin(),
parent: Some(root.clone()),
}))),
);
let home = Rc::new(RefCell::new(Directory {
name: "home".to_string(),
children: Default::default(),
parent: Some(root.clone()),
}));
use yansi::Paint;
let kpl = Rc::new(RefCell::new(Directory {
name: "kpl".to_string(),
children: HashMap::from_iter(vec![(
"README.txt".to_string(),
VfsEntry::File(VfsFile::Data(format!(
"🎉 Happy Birthday, Kirsten! 🎉\n\nThis shell is written in Rust!\n\nTo claim your present, beat {}\n\nHint: it's not in path (and sorry no tab completions)",
"bday".green().bright()
))),
)]),
parent: Some(home.clone()),
}));
add_child(&home, "kpl", VfsEntry::Dir(kpl.clone()));
add_child(&root, "home", VfsEntry::Dir(home));
let etc = Rc::new(RefCell::new(Directory {
name: "etc".to_string(),
children: HashMap::from_iter(vec![(
"en_eo_dict.json".to_string(),
VfsEntry::File(VfsFile::Data(DICTIONARY.to_string())),
)]),
parent: Some(root.clone()),
}));
add_child(&root, "etc", VfsEntry::Dir(etc));
let challenge = Rc::new(RefCell::new(Directory {
name: "challenge".to_string(),
children: HashMap::from_iter(vec![(
"bday".to_string(),
VfsEntry::File(VfsFile::InputCallable(challenge::challenge)),
)]),
parent: Some(root.clone()),
}));
add_child(&root, "challenge", VfsEntry::Dir(challenge));
(root, kpl)
}
fn build_bin() -> HashMap<String, VfsEntry> {
HashMap::from_iter(vec![
("ls".to_string(), VfsEntry::File(VfsFile::VfsCallable(coreutils::ls))),
("cd".to_string(), VfsEntry::File(VfsFile::VfsCallable(coreutils::cd))),
("clear".to_string(), VfsEntry::File(VfsFile::Callable(coreutils::clear))),
("pwd".to_string(), VfsEntry::File(VfsFile::VfsCallable(coreutils::pwd))),
(
"whoami".to_string(),
VfsEntry::File(VfsFile::Callable(coreutils::whoami)),
),
(
"mkdir".to_string(),
VfsEntry::File(VfsFile::VfsCallable(coreutils::mkdir)),
),
("rm".to_string(), VfsEntry::File(VfsFile::VfsCallable(coreutils::rm))),
(
"touch".to_string(),
VfsEntry::File(VfsFile::VfsCallable(coreutils::touch)),
),
("cat".to_string(), VfsEntry::File(VfsFile::VfsCallable(coreutils::cat))),
("arch".to_string(), VfsEntry::File(VfsFile::Callable(coreutils::arch))),
("uname".to_string(), VfsEntry::File(VfsFile::Callable(coreutils::uname))),
(
"help".to_string(),
VfsEntry::File(VfsFile::VfsCallable(coreutils::help)),
),
])
}

13
shell/src/ffi/console.rs Normal file
View File

@@ -0,0 +1,13 @@
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_namespace = console)]
pub fn log(s: &str);
#[wasm_bindgen(js_namespace = console, js_name = log)]
pub fn log_u32(a: u32);
#[wasm_bindgen(js_namespace = console, js_name = log)]
pub fn log_many(a: &str, b: &str);
}

24
shell/src/ffi/input.rs Normal file
View File

@@ -0,0 +1,24 @@
use wasm_bindgen::prelude::*;
#[wasm_bindgen(raw_module = "./src/handler.js")]
extern "C" {
pub type Keypress;
#[wasm_bindgen(method, getter, js_name = "key")]
pub fn key(this: &Keypress) -> char;
#[wasm_bindgen(method, getter, js_name = "keyCode")]
pub fn key_code(this: &Keypress) -> u32;
#[wasm_bindgen(method, getter, js_name = "altKey")]
pub fn alt_key(this: &Keypress) -> bool;
#[wasm_bindgen(method, getter, js_name = "ctrlKey")]
pub fn ctrl_key(this: &Keypress) -> bool;
#[wasm_bindgen(method, getter, js_name = "metaKey")]
pub fn meta_key(this: &Keypress) -> bool;
#[wasm_bindgen(method, getter, js_name = "shiftKey")]
pub fn shift_key(this: &Keypress) -> bool;
}

3
shell/src/ffi/mod.rs Normal file
View File

@@ -0,0 +1,3 @@
pub mod console;
pub mod input;
pub mod xterm;

27
shell/src/ffi/xterm.rs Normal file
View File

@@ -0,0 +1,27 @@
use wasm_bindgen::prelude::*;
#[wasm_bindgen(typescript_custom_section)]
const TERMINAL: &'static str = r#"
import type { Terminal } from '@xterm/xterm';
"#;
#[wasm_bindgen(module = "@xterm/xterm")]
extern "C" {
#[wasm_bindgen(typescript_type = "Terminal")]
pub type Terminal;
#[wasm_bindgen(constructor)]
fn new() -> Terminal;
#[wasm_bindgen(method)]
pub fn writeln(this: &Terminal, data: &str);
#[wasm_bindgen(method)]
pub fn write(this: &Terminal, data: &str);
#[wasm_bindgen(method, js_name = write)]
pub fn write_char(this: &Terminal, data: char);
#[wasm_bindgen(method)]
pub fn clear(this: &Terminal);
}

134
shell/src/lib.rs Normal file
View File

@@ -0,0 +1,134 @@
#![feature(let_chains)]
mod core;
mod ffi;
mod utils;
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub struct Shell {
term: ffi::xterm::Terminal,
buffer: String,
history: Vec<String>,
vfs: core::vfs::VirtualFileSystem,
program_state: core::program::ProgramState,
program: Option<core::program::Program>,
}
#[wasm_bindgen]
impl Shell {
#[wasm_bindgen(constructor)]
pub fn new(term: ffi::xterm::Terminal) -> Shell {
let shell = Shell {
term,
buffer: String::new(),
history: Vec::new(),
vfs: core::vfs::VirtualFileSystem::new(),
program_state: core::program::ProgramState::default(),
program: None,
};
shell.prompt();
shell
}
#[wasm_bindgen(js_name = handleKey)]
pub fn handle_key(&mut self, event: ffi::input::Keypress) {
let key = event.key();
crate::ffi::console::log(&format!("key: {:?}", key));
if key == '\u{3}' {
self.term.write_char('\n');
self.buffer.clear();
self.program = None;
self.program_state = core::program::ProgramState::default();
self.prompt();
}
if let Some(program) = self.program {
let result = program(&self.term, &mut self.program_state, key);
if let Ok(Some(_)) = result {
self.program = None;
self.program_state = core::program::ProgramState::default();
self.prompt();
} else if result.is_err() {
self.program = None;
self.program_state = core::program::ProgramState::default();
self.prompt();
}
return;
}
match key {
'\r' => {
self.term.write_char('\n');
if self.buffer.is_empty() {
self.prompt();
return;
}
let full_args = self.buffer.split_whitespace().collect::<Vec<_>>();
let command = full_args.first().unwrap_or(&"");
let args = full_args[1..].to_vec();
let call = self.vfs.call(&self.term, command, args);
if let Ok(Some(channel)) = call {
self.program = Some(channel);
self.history.push(self.buffer.clone());
self.buffer.clear();
return;
} else if call.is_err() {
self.term.writeln(&format!("{}: command not found", command));
}
self.history.push(self.buffer.clone());
self.buffer.clear();
self.prompt();
}
'\u{7f}' => {
if self.buffer.is_empty() {
return;
}
self.buffer.pop();
self.term.write("\u{8} \u{8}");
}
_ => {
self.buffer.push(key);
self.term.write_char(key);
}
}
}
#[wasm_bindgen(js_name = prompt)]
pub fn prompt(&self) {
let mut cwd = self.vfs.cwd();
if cwd.starts_with("/home/kpl") {
cwd = cwd.replace("/home/kpl", "~");
}
use yansi::Paint;
self.term.write(&format!("{}:{}$ ", "kpl@jsh".green(), cwd.cyan()));
}
}
#[wasm_bindgen(start)]
fn start() {
utils::set_panic_hook();
}

4
shell/src/utils.rs Normal file
View File

@@ -0,0 +1,4 @@
pub fn set_panic_hook() {
#[cfg(feature = "console_error_panic_hook")]
console_error_panic_hook::set_once();
}

34
src/ffi.ts Normal file
View File

@@ -0,0 +1,34 @@
export class Keypress {
constructor(
private readonly _key: String,
private readonly _keyCode: number,
private readonly _altKey: boolean,
private readonly _ctrlKey: boolean,
private readonly _metaKey: boolean,
private readonly _shiftKey: boolean
) {}
get key(): String {
return this._key;
}
get keyCode(): number {
return this._keyCode;
}
get altKey(): boolean {
return this._altKey;
}
get ctrlKey(): boolean {
return this._ctrlKey;
}
get metaKey(): boolean {
return this._metaKey;
}
get shiftKey(): boolean {
return this._shiftKey;
}
}

18
src/handler.ts Normal file
View File

@@ -0,0 +1,18 @@
import type { Terminal } from '@xterm/xterm';
export class Handler {
static instance: Handler;
constructor() {
if (Handler.instance) return Handler.instance;
Handler.instance = this;
}
public bound = false;
public term: Terminal | null = null;
public bind = (term: Terminal) => {
this.term = term;
this.bound = true;
};
public write = (data: string) => this.term!.write(data);
}

4
src/main.ts Normal file
View File

@@ -0,0 +1,4 @@
import './style.css';
import { setupTerminal } from './terminal.ts';
await setupTerminal(document.querySelector<HTMLDivElement>('#app')!);

20
src/style.css Normal file
View File

@@ -0,0 +1,20 @@
:root,
body,
#app {
height: 100%;
width: 100%;
font-size: 16px;
padding: 0;
margin: 0;
font-family: 'Fira Code', monospace;
font-weight: 400;
font-style: normal;
background-color: #000;
}
.terminal {
padding: 1rem;
}

53
src/terminal.ts Normal file
View File

@@ -0,0 +1,53 @@
import { Terminal } from '@xterm/xterm';
import { CanvasAddon } from '@xterm/addon-canvas';
import { FitAddon } from '@xterm/addon-fit';
import { WebLinksAddon } from '@xterm/addon-web-links';
import { WebglAddon } from '@xterm/addon-webgl';
import { Unicode11Addon } from '@xterm/addon-unicode11';
import '@xterm/xterm/css/xterm.css';
import { Shell } from 'shell';
import { Keypress } from './ffi';
const xtermjsTheme = {};
export async function setupTerminal(element: HTMLElement) {
const term = new Terminal({
allowProposedApi: true,
convertEol: true,
fontFamily: '"Fira Code", monospace',
fontSize: 16,
fontWeight: 'normal',
cursorBlink: true,
theme: xtermjsTheme,
});
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
const webglAddon = new WebglAddon();
term.loadAddon(webglAddon);
term.loadAddon(new CanvasAddon());
term.loadAddon(new WebLinksAddon());
term.loadAddon(new Unicode11Addon());
term.open(element);
const resizeObserver = new ResizeObserver(() => fitAddon.fit());
resizeObserver.observe(element);
fitAddon.fit();
term.focus();
const backend = new Shell(term);
term.onKey((e: { key: string; domEvent: KeyboardEvent }) => {
const ev = e.domEvent;
console.log('ev', ev);
const keypress = new Keypress(e.key, ev.keyCode, ev.altKey, ev.ctrlKey, ev.metaKey, ev.shiftKey);
backend.handleKey(keypress);
});
}

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

23
tsconfig.json Normal file
View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

17
vite.config.js Normal file
View File

@@ -0,0 +1,17 @@
// @ts-check
import wasm from 'vite-plugin-wasm';
import { watch } from 'vite-plugin-watch';
/** @type {import('vite').UserConfig} */
export default {
plugins: [
wasm(),
watch({
pattern: 'shell/src/**/*.rs',
command: 'bun run dev-wasm',
}),
],
build: {
target: 'esnext',
},
};