diff --git a/bin/env.ts b/bin/env.ts index da1b76d..b96757a 100644 --- a/bin/env.ts +++ b/bin/env.ts @@ -3,9 +3,10 @@ // Show some debugging information. import { NOSE_STARTED, NOSE_SYS_BIN, NOSE_DIR, GIT_SHA } from "@/config" +import { highlightToHTML } from "../lib/highlight" export default function () { - return [ + return highlightToHTML([ `NODE_ENV=${process.env.NODE_ENV || "(none)"}`, `BUN_HOT=${process.env.BUN_HOT || "(none)"}`, `PORT=${process.env.PORT || "(none)"}`, @@ -15,5 +16,5 @@ export default function () { `NOSE_SYS_BIN=${NOSE_SYS_BIN}`, `NOSE_DIR=${NOSE_DIR}`, `GIT_SHA=${GIT_SHA.slice(0, 8)}`, - ].join("\n") + ].join("\n")) } diff --git a/bin/load.ts b/bin/load.ts index fdf1ea8..427824b 100644 --- a/bin/load.ts +++ b/bin/load.ts @@ -1,15 +1,15 @@ // Load a project so you can work on it. import { apps } from "@/webapp" -import { sessionGet } from "@/session" +import { sessionGet, sessionSet } from "@/session" export default function (project: string) { const state = sessionGet() if (!project) throw `usage: load ` if (state && apps().includes(project)) { - state.project = project - state.cwd = "" + sessionSet("project", project) + sessionSet("cwd", "") } else { return { error: `failed to load ${project}` } } diff --git a/bin/session.tsx b/bin/session.tsx new file mode 100644 index 0000000..fb3979c --- /dev/null +++ b/bin/session.tsx @@ -0,0 +1,6 @@ +import { sessionGet } from "@/session" +import { highlightToHTML } from "../lib/highlight" + +export default function () { + return highlightToHTML(JSON.stringify(sessionGet(), null, 2)) +} \ No newline at end of file diff --git a/bin/state.ts b/bin/state.ts index fe08919..41c49da 100644 --- a/bin/state.ts +++ b/bin/state.ts @@ -1,6 +1,7 @@ -import { NOSE_DATA } from "@/config" import { join } from "path" +import { NOSE_DATA } from "@/config" +import { highlightToHTML } from "../lib/highlight" export default async function () { - return JSON.parse(await Bun.file(join(NOSE_DATA, "state.json")).text()) + return highlightToHTML(await Bun.file(join(NOSE_DATA, "state.json")).text()) } \ No newline at end of file diff --git a/bin/upload.tsx b/bin/upload.tsx new file mode 100644 index 0000000..ed87833 --- /dev/null +++ b/bin/upload.tsx @@ -0,0 +1,34 @@ +import { join } from "path" +import { projectDir } from "@/project" +import { sessionGet } from "@/session" + +export default function () { + const project = sessionGet("project") + if (!project) return { error: "No project loaded" } + + return <> +
+ +
+
+ +
+ +} + +export async function POST(c: Context) { + const cwd = sessionGet("cwd") || projectDir() + if (!cwd) throw "No project loaded" + + const form = await c.req.formData() + const file = form.get("file") + + if (file && file instanceof File) { + const arrayBuffer = await file.arrayBuffer() + await Bun.write(join(cwd, file.name), arrayBuffer) + + return `Uploaded ${file.name}` + } + + return { error: "No file received" } +} diff --git a/lib/highlight.ts b/lib/highlight.ts index ba17356..d5f5c73 100644 --- a/lib/highlight.ts +++ b/lib/highlight.ts @@ -42,6 +42,10 @@ export function highlight(code: string): string { return `` + tokens.map(t => tokenToHTML(t)).join("") } +export function highlightToHTML(code: string): { html: string } { + return { html: `
${highlight(code)}
` } +} + export function tokenize(src: string): Program { const tokens: Token[] = [] let i = 0 diff --git a/src/commands.ts b/src/commands.ts index cffa15e..db04b02 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -47,6 +47,12 @@ export async function commandSource(name: string): Promise { return Bun.file(path).text() } +export async function loadCommandModule(cmd: string) { + const path = commandPath(cmd) + if (!path) return + return await import(path + "?t+" + Date.now()) +} + let sysCmdWatcher let usrCmdWatcher function startWatchers() { diff --git a/src/css/main.css b/src/css/main.css index 06b1aac..7c60067 100644 --- a/src/css/main.css +++ b/src/css/main.css @@ -122,4 +122,29 @@ body[data-mode=tall] #content { textarea { color: var(--c64-light-blue); max-width: 97%; +} + +form { + padding: 10px; + margin: 15px; + background: var(--c64-light-blue); +} + +input[type="file"]::file-selector-button { + font-family: 'C64ProMono', monospace; + color: var(--c64-dark-blue); + background: var(--white); + border-radius: 2px; + border: 2px solid #fff; + padding: 4px 8px; + cursor: pointer; +} + +input[type="file"] { + color: var(--c64-dark-blue); +} + +input[type="submit"] { + color: var(--c64-dark-blue); + padding: 5px; } \ No newline at end of file diff --git a/src/js/commands.ts b/src/js/commands.ts index a2bae55..2e79dab 100644 --- a/src/js/commands.ts +++ b/src/js/commands.ts @@ -4,11 +4,12 @@ import { scrollback } from "./dom.js" import { resize } from "./resize.js" import { autoScroll } from "./scrollback.js" -import { sessionID } from "./session.js" +import { sessionId } from "./session.js" export const commands: string[] = [] export const browserCommands: Record any> = { + "browser-session": () => sessionId, clear: () => scrollback.innerHTML = "", commands: () => commands.join(" "), fullscreen: () => document.body.requestFullscreen(), @@ -18,7 +19,6 @@ export const browserCommands: Record any> = { autoScroll() }, reload: () => window.location.reload(), - session: () => sessionID, } export function cacheCommands(cmds: string[]) { diff --git a/src/js/completion.ts b/src/js/completion.ts index 5942e08..c859308 100644 --- a/src/js/completion.ts +++ b/src/js/completion.ts @@ -15,7 +15,6 @@ function handleCompletion(e: KeyboardEvent) { const input = cmdInput.value for (const command of commands) { - console.log(input, command) if (command.startsWith(input)) { cmdInput.value = command return diff --git a/src/js/form.ts b/src/js/form.ts new file mode 100644 index 0000000..2a10d5a --- /dev/null +++ b/src/js/form.ts @@ -0,0 +1,48 @@ +//// +// All forms are submitted via ajax. + +import type { CommandResult, CommandOutput } from "../shared/types.js" +import { sessionId } from "./session.js" +import { setStatus, replaceOutput } from "./scrollback.js" +import { focusInput } from "./focus.js" + +export function initForm() { + document.addEventListener("submit", submitHandler) +} + +export const submitHandler = async (e: SubmitEvent) => { + e.preventDefault() + + const form = e.target + if (!(form instanceof HTMLFormElement)) return + + const li = form.closest(".output") + if (!(li instanceof HTMLLIElement)) return + + const id = li.dataset.id + if (!id) return + + let output: CommandOutput + let error = false + + try { + const fd = new FormData(form) + + const data: CommandResult = await fetch("/cmd" + new URL(form.action).pathname, { + method: "POST", + headers: { "X-Session": sessionId }, // don't set Content-Type manually + body: fd + }).then(r => r.json()) + + output = data.output + error = data.status === "error" + } catch (e: any) { + output = e.message || e.toString() + error = true + } + + if (error) setStatus(id, "error") + + replaceOutput(id, output) + focusInput() +} diff --git a/src/js/history.ts b/src/js/history.ts index aaf75d4..72adf6a 100644 --- a/src/js/history.ts +++ b/src/js/history.ts @@ -40,7 +40,6 @@ function navigateHistory(e: KeyboardEvent) { } else if (e.key === "ArrowDown" || (e.ctrlKey && e.key === "n")) { e.preventDefault() - console.log(idx, savedInput) if (idx <= 0) { cmdInput.value = savedInput idx = -1 diff --git a/src/js/main.ts b/src/js/main.ts index ed940e7..feb6711 100644 --- a/src/js/main.ts +++ b/src/js/main.ts @@ -2,6 +2,7 @@ import { initCompletion } from "./completion.js" import { initCursor } from "./cursor.js" import { initEditor } from "./editor.js" import { initFocus } from "./focus.js" +import { initForm } from "./form.js" import { initGamepad } from "./gamepad.js" import { initHistory } from "./history.js" import { initHyperlink } from "./hyperlink.js" @@ -14,6 +15,7 @@ import { startConnection } from "./websocket.js" initCompletion() initCursor() initFocus() +initForm() initEditor() initGamepad() initHistory() diff --git a/src/js/scrollback.ts b/src/js/scrollback.ts index cbb1d9f..ccf1d47 100644 --- a/src/js/scrollback.ts +++ b/src/js/scrollback.ts @@ -36,22 +36,15 @@ export function setStatus(id: string, status: InputStatus) { const statusEl = document.querySelector(`[data-id="${id}"].input .status`) if (!statusEl) return - statusEl.className = "" - - switch (status) { - case "waiting": - statusEl.classList.add("yellow") - break - case "streaming": - statusEl.classList.add("purple") - break - case "ok": - statusEl.classList.add("green") - break - case "error": - statusEl.classList.add("red") - break + const colors = { + waiting: "yellow", + streaming: "purple", + ok: "green", + error: "red" } + + statusEl.classList.remove(...Object.values(colors)) + statusEl.classList.add(colors[status]) } export function addOutput(id: string, output: CommandOutput) { @@ -80,7 +73,6 @@ export function addErrorMessage(message: string) { addOutput("", { html: `${message}` }) } - export function appendOutput(id: string, output: CommandOutput) { const item = document.querySelector(`[data-id="${id}"].output`) @@ -125,14 +117,14 @@ function processOutput(output: CommandOutput): ["html" | "text", string] { content = output } else if (Array.isArray(output)) { content = output.join(" ") - } else if ("html" in output) { + } else if (typeof output === "object" && "html" in output) { html = true content = output.html if (output.script) eval(output.script) - } else if ("text" in output) { + } else if (typeof output === "object" && "text" in output) { content = output.text if (output.script) eval(output.script) - } else if ("script" in output) { + } else if (typeof output === "object" && "script" in output) { eval(output.script!) } else { content = JSON.stringify(output) diff --git a/src/js/session.ts b/src/js/session.ts index f0c0907..165e2ba 100644 --- a/src/js/session.ts +++ b/src/js/session.ts @@ -4,4 +4,4 @@ import { randomId } from "../shared/utils.js" -export const sessionID = randomId() \ No newline at end of file +export const sessionId = randomId() \ No newline at end of file diff --git a/src/js/websocket.ts b/src/js/websocket.ts index d4407da..8c31b3a 100644 --- a/src/js/websocket.ts +++ b/src/js/websocket.ts @@ -2,7 +2,7 @@ // The terminal communicates with the shell via websockets. import type { Message } from "../shared/types.js" -import { sessionID } from "./session.js" +import { sessionId } from "./session.js" import { handleMessage } from "./shell.js" import { addErrorMessage } from "./scrollback.js" @@ -37,7 +37,7 @@ export function send(msg: Message) { return } - if (!msg.session) msg.session = sessionID + if (!msg.session) msg.session = sessionId ws?.readyState === 1 && ws.send(JSON.stringify(msg)) console.log("-> send", msg) } diff --git a/src/server.tsx b/src/server.tsx index 4ffbd89..5783a4f 100644 --- a/src/server.tsx +++ b/src/server.tsx @@ -11,7 +11,8 @@ import { NOSE_ICON, NOSE_BIN, NOSE_WWW, NOSE_DATA, NOSE_DIR } from "./config" import { transpile, isFile, tilde } from "./utils" import { serveApp } from "./webapp" import { initDNS } from "./dns" -import { commands, commandPath } from "./commands" +import { commands, commandPath, loadCommandModule } from "./commands" +import { runInSession, processExecOutput } from "./shell" import { send, addWebsocket, removeWebsocket, closeWebsockets } from "./websocket" import { Layout } from "./html/layout" @@ -89,6 +90,25 @@ app.get("/source/:name", async c => { }) }) +app.on(["GET", "POST"], ["/cmd/:name"], async c => { + const sessionId = c.req.header("X-Session") || "0" + const cmd = c.req.param("name") + const method = c.req.method + + try { + const mod = await loadCommandModule(cmd) + if (!mod || !mod[method]) + return c.json({ error: `No ${method} export in ${cmd}` }, 500) + + return c.json(await runInSession(sessionId, async () => { + const [status, output] = processExecOutput(await mod[method](c)) + return { status, output } + })) + } catch (e: any) { + return c.json({ status: "error", output: e.message || e.toString() }, 500) + } +}) + app.get("/", c => c.html()) // diff --git a/src/shell.ts b/src/shell.ts index 97c550c..34d0ca0 100644 --- a/src/shell.ts +++ b/src/shell.ts @@ -4,7 +4,7 @@ import type { CommandResult, CommandOutput } from "./shared/types" import type { Session } from "./session" -import { commandExists, commandPath } from "./commands" +import { commandExists, loadCommandModule } from "./commands" import { ALS } from "./session" const sessions: Map = new Map() @@ -29,8 +29,13 @@ export async function runCommand(sessionId: string, taskId: string, input: strin return { status, output } } +export async function runInSession(sessionId: string, fn: () => Promise) { + const state = getState(sessionId) + return await ALS.run(state, async () => await fn()) +} + async function exec(cmd: string, args: string[]): Promise<["ok" | "error", CommandOutput]> { - const module = await import(commandPath(cmd) + "?t+" + Date.now()) + const module = await loadCommandModule(cmd) if (module?.game) return ["ok", { game: cmd }] @@ -62,13 +67,14 @@ export function processExecOutput(output: string | any): ["ok" | "error", Comman } } -function getState(sessionId: string, taskId: string, ws?: any): Session { +function getState(sessionId: string, taskId?: string, ws?: any): Session { let state = sessions.get(sessionId) if (!state) { state = { sessionId: sessionId, project: "" } sessions.set(sessionId, state) } - state.taskId = taskId + if (taskId) + state.taskId = taskId if (ws) state.ws = ws return state } diff --git a/src/sneaker.ts b/src/sneaker.ts index 70fe785..5906af7 100644 --- a/src/sneaker.ts +++ b/src/sneaker.ts @@ -26,7 +26,6 @@ export function initSneakers() { for (const key in state) { if (key.startsWith(PREFIX)) { const app = key.replace(PREFIX, "") - console.log("sharing", app, state[key]) connectSneaker(app, state[key]) } }