happy bday!
This commit is contained in:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal 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
23
.prettierrc.mjs
Normal 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
3
Dockerfile
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
COPY dist /usr/share/nginx/html
|
||||||
17
clean.ts
Normal file
17
clean.ts
Normal 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
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
34666
en_eo_dict_clean.json
Normal file
File diff suppressed because it is too large
Load Diff
17
index.html
Normal file
17
index.html
Normal 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
32
package.json
Normal 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
1
public/vite.svg
Normal 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
6
shell/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/target
|
||||||
|
**/*.rs.bk
|
||||||
|
Cargo.lock
|
||||||
|
bin/
|
||||||
|
pkg/
|
||||||
|
wasm-pack.log
|
||||||
3
shell/.rustfmt.toml
Normal file
3
shell/.rustfmt.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
max_width = 120
|
||||||
|
tab_spaces = 2
|
||||||
|
format_macro_matchers = true
|
||||||
28
shell/Cargo.toml
Normal file
28
shell/Cargo.toml
Normal 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
26
shell/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
3
shell/rust-toolchain.toml
Normal file
3
shell/rust-toolchain.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[toolchain]
|
||||||
|
channel = "nightly"
|
||||||
|
targets = ["wasm32-unknown-unknown"]
|
||||||
188
shell/src/core/challenge.rs
Normal file
188
shell/src/core/challenge.rs
Normal 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
235
shell/src/core/coreutils.rs
Normal 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
5
shell/src/core/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
pub mod program;
|
||||||
|
pub mod vfs;
|
||||||
|
|
||||||
|
mod challenge;
|
||||||
|
mod coreutils;
|
||||||
17
shell/src/core/program.rs
Normal file
17
shell/src/core/program.rs
Normal 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
249
shell/src/core/vfs.rs
Normal 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
13
shell/src/ffi/console.rs
Normal 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
24
shell/src/ffi/input.rs
Normal 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
3
shell/src/ffi/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod console;
|
||||||
|
pub mod input;
|
||||||
|
pub mod xterm;
|
||||||
27
shell/src/ffi/xterm.rs
Normal file
27
shell/src/ffi/xterm.rs
Normal 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
134
shell/src/lib.rs
Normal 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
4
shell/src/utils.rs
Normal 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
34
src/ffi.ts
Normal 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
18
src/handler.ts
Normal 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
4
src/main.ts
Normal 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
20
src/style.css
Normal 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
53
src/terminal.ts
Normal 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
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
23
tsconfig.json
Normal file
23
tsconfig.json
Normal 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
17
vite.config.js
Normal 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',
|
||||||
|
},
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user