save files

This commit is contained in:
Chris Wanstrath 2025-09-21 21:02:24 -07:00
parent 4558c157d4
commit a8b1dc91a3
7 changed files with 133 additions and 6 deletions

60
nose/bin/edit.ts Normal file
View File

@ -0,0 +1,60 @@
import { escapeHTML } from "bun"
import { readdirSync } from "fs"
import { join, extname } from "path"
import type { CommandOutput } from "@/shared/types"
import { NOSE_WWW } from "@/config"
import { getState } from "@/state"
import { appPath } from "@/webapp"
import { isBinaryFile } from "@/utils"
import { countChar } from "@/shared/utils"
export default async function (path: string) {
const state = getState()
if (!state) return { error: "no state" }
const project = state.project
if (!project) return { error: "no project loaded" }
const root = appPath(project)
if (!root) return { error: "error loading project" }
let files: string[] = []
for (const file of readdirSync(root, { withFileTypes: true })) {
files.push(file.name)
}
if (root === NOSE_WWW) {
files = files.filter(file => file.endsWith(`${project}.ts`) || file.endsWith(`${project}.tsx`))
}
if (!files.includes(path))
return { error: `file not found: ${path}` }
return await readFile(join(root, path))
}
async function readFile(path: string): Promise<CommandOutput> {
const ext = extname(path).slice(1)
const file = Bun.file(path)
switch (ext) {
case "jpeg": case "jpg": case "png": case "gif": case "webp":
const img = await file.arrayBuffer()
return { html: `<img src="data:image/${ext};base64,${Buffer.from(img).toString('base64')}" />` }
case "mp3": case "wav":
case "mp4": case "mov": case "avi": case "mkv": case "webm":
return "Not implemented"
default:
if (await isBinaryFile(path))
throw "Cannot display binary file"
const text = await file.text()
const rows = countChar(text, "\n") + 1
return {
html: `<textarea class="editor" spellcheck="false" rows=${rows} data-path="${path}" >${text}</textarea>`
}
}
}

View File

@ -1,4 +1,9 @@
.editor {
width: 100%;
background-color: var(--white);
background-color: transparent;
border: none;
}
.editor:focus {
outline: none;
}

View File

@ -1,4 +1,8 @@
import { scrollback } from "./dom.js"
import { send } from "./websocket.js"
import { focusInput } from "./focus.js"
const INDENT_SIZE = 2
export function initEditor() {
document.addEventListener("input", adjustHeight)
@ -18,10 +22,54 @@ function focusTextareaOnCreation() {
for (const m of mutations)
for (const node of Array.from(m.addedNodes))
if (node instanceof HTMLElement && node.childNodes[0] instanceof HTMLElement && node.childNodes[0].matches("textarea")) {
node.childNodes[0].focus()
const editor = node.childNodes[0]
editor.focus()
editor.addEventListener("keydown", keydownHandler)
return
}
})
observer.observe(scrollback, { childList: true })
}
function keydownHandler(e: KeyboardEvent) {
const target = e.target as HTMLTextAreaElement
if (e.key === "Tab") {
e.preventDefault()
if (e.shiftKey)
removeTab(target)
else
insertTab(target)
} else if (e.ctrlKey && e.key === "c") {
focusInput()
} else if ((e.ctrlKey && e.key === "s") || (e.ctrlKey && e.key === "Enter")) {
e.preventDefault()
send({
id: target.dataset.path,
type: "save-file",
data: target.value
})
}
}
function insertTab(editor: HTMLTextAreaElement) {
const start = editor.selectionStart
const end = editor.selectionEnd
const lineStart = editor.value.lastIndexOf("\n", start - 1) + 1
editor.value = editor.value.slice(0, lineStart) + " " + editor.value.slice(lineStart)
editor.selectionStart = start + INDENT_SIZE
editor.selectionEnd = end + INDENT_SIZE
}
function removeTab(editor: HTMLTextAreaElement) {
const start = editor.selectionStart
const end = editor.selectionEnd
const lineStart = editor.value.lastIndexOf("\n", start - 1) + 1
if (editor.value.slice(lineStart, lineStart + 2) === " ") {
editor.value = editor.value.slice(0, lineStart) + editor.value.slice(lineStart + 2)
editor.selectionStart = start - INDENT_SIZE
editor.selectionEnd = end - INDENT_SIZE
}
}

View File

@ -34,6 +34,8 @@ export function handleMessage(msg: Message) {
handleOutput(msg)
} else if (msg.type === "commands") {
cacheCommands(msg.data as string[])
} else if (msg.type === "error") {
console.error(msg.data)
} else {
console.error("unknown message type", msg)
}

View File

@ -1,6 +1,7 @@
import { Hono } from "hono"
import { serveStatic, upgradeWebSocket, websocket } from "hono/bun"
import { prettyJSON } from "hono/pretty-json"
import { basename } from "path"
import color from "kleur"
import type { Message } from "./shared/types"
@ -73,8 +74,17 @@ app.get("/ws", upgradeWebSocket(async c => {
if (!data) return
if (data.type === "input") {
const result = await runCommand(data.session || "", data.id || "", data.data as string)
send(ws, { id: data.id, type: "output", data: result })
} else if (data.type === "save-file") {
if (data.id && typeof data.data === "string" && isFile(data.id)) {
await Bun.write(data.id, data.data)
send(ws, { type: "output", data: { status: "ok", output: `saved ${basename(data.id)}` } })
}
} else {
send(ws, { type: "error", data: `unknown message: ${data.type}` })
}
},
onClose: (event, ws) => {
removeWebsocket(ws)

View File

@ -1,10 +1,12 @@
export type Message = {
session?: string
id?: string
type: "input" | "output" | "commands" | "error"
type: MessageType
data: CommandResult | string | string[]
}
export type MessageType = "error" | "input" | "output" | "commands" | "save-file"
export type CommandOutput = string | { html: string }
export type CommandResult = {

View File

@ -57,7 +57,7 @@ function processExecOutput(output: string | any): ["ok" | "error", CommandOutput
function getState(session: string, id: string): State {
let state = sessions.get(session)
if (!state) {
state = { session, project: "" }
state = { session, project: "test" }
sessions.set(session, state)
}
state.id = id