/** * The vault in a terminal. The forge runs this on a pty when someone * `ssh`es to the app, over the same data as the page and through the same * actions, as the account at the keyboard. j/k move, enter reads the note * under the cursor, / searches, a adds a line to today's note, n makes a * new note, d removes one, q leaves. Plain ANSI, no dependencies. */ import { addNote, canDelete, deleteNote, listNotes, openOrCreate, searchNotes, today, updateNote, type Note, } from "./actions.ts"; import { openDb } from "./db.ts"; import { ssh, title, where } from "./env.ts"; import { whoAtTty } from "./who.ts"; const db = openDb(); const me = whoAtTty(); const esc = "\x1b["; const dim = (s: string): string => `${esc}2m${s}${esc}0m`; const bold = (s: string): string => `${esc}1m${s}${esc}0m`; const out = (s: string): void => { process.stdout.write(s); }; let cursor = 0; let reading: Note | undefined; let scroll = 0; let typing: { what: "append" | "new" | "search"; text: string } | undefined; let filter = ""; let said = ""; const notes = (): Note[] => { const listed = listNotes(db, me); if (!listed.ok) return []; if (filter === "") return listed.value; const hits = searchNotes(db, me, { q: filter, limit: 500 }); if (!hits.ok) return []; const ids = hits.value.map((h) => h.id); return ids.flatMap((id) => listed.value.filter((n) => n.id === id)); }; const rows = (): number => process.stdout.rows ?? 24; function draw(): void { const lines = [ `${bold(title)} ${dim(where)} ${dim(me.role === "anon" ? "guest" : me.name)}`, ssh === undefined ? "" : dim(`connect: ${ssh}`), "", ]; if (me.role === "anon") { lines.push("Sign in with a key the forge knows to open your vault.", "", dim("q quit")); out(`${esc}2J${esc}H${lines.join("\r\n")}`); return; } if (reading !== undefined) { const body = reading.body.split("\n"); const room = Math.max(3, rows() - 6); lines.push(bold(reading.title), ""); lines.push(...body.slice(scroll, scroll + room)); lines.push("", dim(`j/k scroll · esc back · ${scroll + 1}-${Math.min(body.length, scroll + room)} of ${body.length}`)); out(`${esc}2J${esc}H${lines.join("\r\n")}`); return; } const list = notes(); if (filter !== "") lines.push(dim(`search: ${filter} (esc clears)`)); if (list.length === 0) lines.push(dim(filter === "" ? "No notes yet. Press n to write one." : "Nothing matches.")); const room = Math.max(3, rows() - 8); const start = Math.max(0, Math.min(cursor - Math.floor(room / 2), list.length - room)); list.slice(start, start + room).forEach((note, k) => { const i = start + k; const name = `${note.pinned === 1 ? "★ " : ""}${note.title}`; lines.push(`${i === cursor ? "▸" : " "} ${i === cursor ? bold(name) : name} ${dim(note.updated_at.slice(0, 16).replace("T", " "))}`); }); lines.push(""); if (typing !== undefined) lines.push(`${{ append: `Add to ${today()}`, new: "New note title", search: "Search" }[typing.what]}: ${typing.text}${esc}7m ${esc}0m`); else if (said !== "") lines.push(said); else lines.push(dim("j/k move · enter read · / search · a add to today · n new · d delete · q quit")); out(`${esc}2J${esc}H${lines.join("\r\n")}`); } function submit(what: "append" | "new" | "search", text: string): void { if (what === "search") { filter = text.trim(); cursor = 0; return; } if (text.trim() === "") return; if (what === "new") { const added = addNote(db, me, { title: text }); said = added.ok ? `Wrote “${added.value.title}”.` : added.message; return; } const day = today(); const opened = openOrCreate(db, me, { title: day, body: `# ${day}\n` }); if (!opened.ok) { said = opened.message; return; } const body = opened.value.body.replace(/\s*$/, ""); const saved = updateNote(db, me, { id: opened.value.id, body: `${body}\n\n- ${text.trim()}\n` }); said = saved.ok ? `Added to ${day}.` : saved.message; } process.stdin.setRawMode?.(true); process.stdin.resume(); process.stdin.setEncoding("utf8"); draw(); process.stdin.on("data", (key: string) => { said = ""; if (typing !== undefined) { if (key === "\r") { submit(typing.what, typing.text); typing = undefined; } else if (key === "\x1b") typing = undefined; else if (key === "\x7f") typing.text = typing.text.slice(0, -1); else if (key >= " ") typing.text += key; draw(); return; } if (key === "q" || key === "\x03") { out(`${esc}2J${esc}H`); process.exit(0); } if (me.role === "anon") return draw(); if (reading !== undefined) { const max = Math.max(0, reading.body.split("\n").length - 1); if (key === "j" || key === "\x1b[B") scroll = Math.min(scroll + 1, max); if (key === "k" || key === "\x1b[A") scroll = Math.max(scroll - 1, 0); if (key === "\x1b" || key === "h") reading = undefined; return draw(); } const list = notes(); if (key === "j" || key === "\x1b[B") cursor = Math.min(cursor + 1, Math.max(0, list.length - 1)); if (key === "k" || key === "\x1b[A") cursor = Math.max(cursor - 1, 0); if (key === "\r" && list[cursor] !== undefined) { reading = list[cursor]; scroll = 0; } if (key === "\x1b" && filter !== "") { filter = ""; cursor = 0; } if (key === "/") typing = { what: "search", text: filter }; if (key === "a") typing = { what: "append", text: "" }; if (key === "n") typing = { what: "new", text: "" }; if (key === "d") { const note = list[cursor]; if (note === undefined) said = "Nothing to remove."; else if (!canDelete(me, note)) said = "Only the author can remove a note."; else { const removed = deleteNote(db, me, { id: note.id }); said = removed.ok ? `Removed “${note.title}”.` : removed.message; cursor = Math.min(cursor, Math.max(0, list.length - 2)); } } draw(); }); process.stdout.on("resize", draw);