Files
bare-operating-system/packages/bare-os-coreutils/lib/edit-tui.js
T
2026-04-04 07:44:07 -04:00

486 lines
14 KiB
JavaScript

/** Full-screen TUI for /bin/edit (raw TTY, fish suspend/resume). */
/**
* @param {unknown} chunk
* @returns {number[]}
*/
function bareEditChunkBytes(chunk) {
if (chunk == null) return []
if (typeof chunk === 'string') {
const out = []
for (let i = 0; i < chunk.length; i++) out.push(chunk.charCodeAt(i) & 0xff)
return out
}
const len = /** @type {{ length: number, [k: number]: number }} */ (chunk).length
const out = []
for (let i = 0; i < len; i++) out.push(Number(chunk[i]) & 0xff)
return out
}
/**
* @param {{ nextByte: () => Promise<number|undefined>, dispose?: () => void, _keyq?: number[] }} reader
*/
async function bareEditReadKey(reader) {
reader._keyq = reader._keyq || []
const q = reader._keyq
for (;;) {
const ev = bareEditTryConsumeKey(q)
if (ev) return ev
const b = await reader.nextByte()
if (b === undefined) {
if (!q.length) return { type: 'eof' }
if (q.length === 1 && q[0] === 27) {
q.length = 0
return { type: 'key', ch: '\x1b' }
}
const lone = q.shift()
if (lone !== undefined && lone < 0x20) {
return { type: 'ctrl', code: lone }
}
if (lone !== undefined) {
return { type: 'key', ch: String.fromCharCode(lone) }
}
return { type: 'eof' }
}
q.push(b)
}
}
/**
* @param {import('stream').Readable} stdin
*/
function bareEditCreateStdinReader(stdin) {
/** @type {number[]} */
const bytes = []
/** @type {(() => void)[]} */
const waiters = []
function drain() {
while (waiters.length && bytes.length) {
const w = waiters.shift()
if (w) w()
}
}
/** @param {unknown} chunk */
function onData(chunk) {
bytes.push(...bareEditChunkBytes(chunk))
drain()
}
stdin.on('data', onData)
return {
nextByte() {
if (bytes.length) return Promise.resolve(bytes.shift())
return new Promise((resolve) => {
waiters.push(() => resolve(bytes.shift()))
})
},
dispose() {
stdin.removeListener('data', onData)
}
}
}
/**
* @param {Record<string, unknown>} ctx
* @param {import('stream').Writable} stdout
* @param {string} s
*/
function bareEditWrite(ctx, stdout, s) {
if (!stdout || typeof stdout.write !== 'function') return
try {
stdout.write(s)
} catch {
try {
ctx.console?.error?.('edit: stdout write failed')
} catch {
/* ignore */
}
}
}
/**
* @param {Record<string, unknown>} ctx
* @param {{ path: string, initialText: string, argv0?: string }} opts
*/
async function bareOsRunEditTui(ctx, opts) {
const vfs = ctx.vfs
const b4a = ctx.b4a
const path = opts.path || 'Untitled'
const display = opts.argv0 || 'edit'
const lang = bareEditDetectLang(path)
const buf = bareEditCreateBuffer(opts.initialText)
const useColor = bareEditUseColor(ctx)
const stdin = /** @type {import('stream').Readable | undefined} */ (
ctx.replStdin
)
const stdout = bareEditResolveStdout(ctx)
if (!stdin || !stdout) {
ctx.console.error('edit: missing stdin/stdout')
ctx.exitCode = 1
return
}
let scrollRow = 0
let scrollCol = 0
let savePath = path
/** @type {'edit'|'quit_confirm'|'help'|'prompt_search'|'prompt_goto'|'prompt_saveas'} */
let mode = 'edit'
let promptBuf = ''
let promptTitle = ''
let lastSearch = ''
function termDims() {
const env =
ctx.env && typeof ctx.env === 'object'
? /** @type {Record<string, string>} */ (ctx.env)
: {}
const cols = /** @type {{ columns?: number }} */ (stdout).columns ||
parseInt(env.COLUMNS || '80', 10) ||
80
const rows = /** @type {{ rows?: number }} */ (stdout).rows ||
parseInt(env.LINES || '24', 10) ||
24
return { cols: Math.max(40, cols), rows: Math.max(8, rows) }
}
function ensureScroll() {
const { cols, rows } = termDims()
const contentH = rows - 2
if (buf.row < scrollRow) scrollRow = buf.row
if (buf.row >= scrollRow + contentH) scrollRow = buf.row - contentH + 1
if (scrollRow < 0) scrollRow = 0
if (scrollCol > buf.col) scrollCol = buf.col
if (buf.col - scrollCol >= cols) scrollCol = buf.col - cols + 1
if (scrollCol < 0) scrollCol = 0
}
function draw() {
const { cols, rows } = termDims()
const contentH = Math.max(1, rows - 2)
ensureScroll()
let out = ''
out += '\x1b[?25l\x1b[2J\x1b[H'
if (mode === 'help') {
out +=
'Bare OS edit — help\r\n\r\n' +
'^O ^S Save ^X Exit\r\n' +
'^W Search ^_ Go to line\r\n' +
'^Z Undo ^Y Redo\r\n' +
'Arrows move cursor; Home/End; Backspace/Del\r\n\r\n' +
'Press any key to return.\r\n'
out += bareEditCup(8, 1) + '\x1b[?25h'
bareEditWrite(ctx, stdout, out)
return
}
if (mode === 'quit_confirm') {
for (let row = 1; row <= contentH; row++) {
out += bareEditCup(row, 1) + '\x1b[K'
}
const msg = 'Save modified buffer? Y Yes N No ^C Cancel'
const st = bareEditSgr('status', useColor) + msg + EDIT_ANSI_RESET
out += bareEditCup(contentH + 1, 1) + '\x1b[K' + st
out += bareEditCup(contentH + 1, 1) + '\x1b[?25h'
bareEditWrite(ctx, stdout, out)
return
}
if (mode === 'prompt_search' || mode === 'prompt_goto' || mode === 'prompt_saveas') {
const editorLines = Math.max(0, contentH - 1)
for (let r = 0; r < editorLines; r++) {
const lineIndex = scrollRow + r
const line = buf.lines[lineIndex] || ''
const spans = bareEditHighlightLine(line, lang)
const vis = bareEditPaintLineWindow(line, spans, scrollCol, cols, useColor)
out += bareEditCup(1 + r, 1) + '\x1b[K' + vis
}
const barBody =
bareEditSgr('status', useColor) +
promptTitle +
promptBuf +
(useColor ? '\x1b[0m' : '')
const promptRow = editorLines + 1
out += bareEditCup(promptRow, 1) + '\x1b[K' + barBody
const promptCol = 1 + promptTitle.length + promptBuf.length
out += bareEditCup(promptRow, promptCol) + '\x1b[?25h'
bareEditWrite(ctx, stdout, out)
return
}
for (let r = 0; r < contentH; r++) {
const lineIndex = scrollRow + r
const line = buf.lines[lineIndex] || ''
const spans = bareEditHighlightLine(line, lang)
const vis = bareEditPaintLineWindow(line, spans, scrollCol, cols, useColor)
out += bareEditCup(1 + r, 1) + '\x1b[K' + vis
}
const dirtyMark = buf.dirty ? ' [Modified]' : ''
const statusLine =
(useColor ? bareEditSgr('status', true) : '') +
display +
' ' +
savePath +
dirtyMark +
' ' +
(buf.row + 1) +
',' +
(buf.col + 1) +
(useColor ? EDIT_ANSI_RESET : '')
const helpLine =
(useColor ? bareEditSgr('dim', true) : '') +
'^G Help ^O/^S Save ^X Exit ^W Find ^_ Line ^Z Undo ^Y Redo' +
(useColor ? EDIT_ANSI_RESET : '')
out += bareEditCup(contentH + 1, 1) + '\x1b[K' + statusLine
out += bareEditCup(contentH + 2, 1) + '\x1b[K' + helpLine
const curRow = 1 + (buf.row - scrollRow)
const curCol = 1 + (buf.col - scrollCol)
const tr = Math.min(Math.max(1, curRow), contentH)
const tc = Math.min(Math.max(1, curCol), cols)
out += bareEditCup(tr, tc) + '\x1b[?25h'
bareEditWrite(ctx, stdout, out)
}
async function doSave(targetPath) {
const text = bareEditJoinAll(buf)
try {
await vfs.writeFile(targetPath, b4a.from(text))
buf.dirty = false
savePath = targetPath
return true
} catch (e) {
ctx.console.error('edit: ' + ((e && e.message) || String(e)))
return false
}
}
const reader = bareEditCreateStdinReader(stdin)
let suspended = false
/** When true, alternate-screen mode was entered (?1049h); {@link bareEditWrite} must send ?1049l on exit. */
let useAltScreen = false
try {
if (typeof ctx.suspendReplForSubprocess === 'function') {
ctx.suspendReplForSubprocess()
suspended = true
}
if (typeof stdin.setRawMode === 'function') stdin.setRawMode(true)
if (typeof stdin.resume === 'function') stdin.resume()
const envEdit =
ctx.env && typeof ctx.env === 'object'
? /** @type {Record<string, string>} */ (ctx.env)
: {}
const bareEditNoAltScreen =
envEdit.BARE_EDIT_NO_ALTSCREEN != null &&
String(envEdit.BARE_EDIT_NO_ALTSCREEN) !== ''
if (!bareEditNoAltScreen) {
bareEditWrite(ctx, stdout, '\x1b[?1049h')
useAltScreen = true
}
draw()
for (;;) {
const ev = await bareEditReadKey(reader)
if (ev.type === 'eof') break
if (mode === 'help') {
mode = 'edit'
draw()
continue
}
if (mode === 'quit_confirm') {
if (ev.type === 'ctrl' && ev.code === 'interrupt') {
mode = 'edit'
draw()
continue
}
if (ev.type === 'key' && ev.ch) {
const u = ev.ch.toUpperCase()
if (u === 'Y') {
await doSave(savePath)
break
}
if (u === 'N') break
}
continue
}
if (
mode === 'prompt_search' ||
mode === 'prompt_goto' ||
mode === 'prompt_saveas'
) {
if (ev.type === 'ctrl' && ev.code === 'interrupt') {
mode = 'edit'
draw()
continue
}
if (ev.type === 'key' && ev.ch === '\n') {
if (mode === 'prompt_search') {
lastSearch = promptBuf
if (lastSearch) {
const hit = bareEditFindNext(
buf,
lastSearch,
buf.row,
buf.col + 1
)
if (!hit) {
const hit2 = bareEditFindNext(buf, lastSearch, 0, 0)
if (hit2) {
buf.row = hit2.row
buf.col = hit2.col
}
} else {
buf.row = hit.row
buf.col = hit.col
}
}
} else if (mode === 'prompt_goto') {
const n = parseInt(promptBuf.trim(), 10)
if (Number.isFinite(n)) bareEditGotoLine(buf, n)
} else if (mode === 'prompt_saveas') {
const p = promptBuf.trim()
if (p) {
savePath = p
await doSave(savePath)
}
}
mode = 'edit'
draw()
continue
}
if (ev.type === 'ctrl' && ev.code === 'backspace') {
promptBuf = promptBuf.slice(0, -1)
draw()
continue
}
if (ev.type === 'key' && ev.ch && ev.ch !== '\n' && ev.ch !== '\t') {
if (ev.ch >= ' ') promptBuf += ev.ch
draw()
continue
}
continue
}
if (ev.type === 'nav') {
if (ev.key === 'pageup') {
const { rows } = termDims()
const step = Math.max(1, rows - 4)
buf.row = Math.max(0, buf.row - step)
bareEditClampCursor(buf)
} else if (ev.key === 'pagedown') {
const { rows } = termDims()
const step = Math.max(1, rows - 4)
buf.row = Math.min(buf.lines.length - 1, buf.row + step)
bareEditClampCursor(buf)
} else bareEditMoveKey(buf, ev.key)
draw()
continue
}
if (ev.type === 'ctrl') {
if (ev.code === 'backspace') {
bareEditBackspace(buf)
draw()
continue
}
if (ev.code === 'delete') {
bareEditDelete(buf)
draw()
continue
}
if (ev.code === 'interrupt') {
/* ignore in edit */
continue
}
const code = typeof ev.code === 'number' ? ev.code : 0
if (code === 27) {
/* lone ESC fragment; do not insert */
continue
}
if (code === 24) {
if (buf.dirty) {
mode = 'quit_confirm'
draw()
} else break
continue
}
if (code === 19) {
await doSave(savePath)
draw()
continue
}
if (code === 15) {
mode = 'prompt_saveas'
promptTitle = 'File: '
promptBuf = savePath
draw()
continue
}
if (code === 23) {
mode = 'prompt_search'
promptTitle = 'Search: '
promptBuf = lastSearch
draw()
continue
}
if (code === 7) {
mode = 'help'
draw()
continue
}
if (code === 31) {
mode = 'prompt_goto'
promptTitle = 'Go to line: '
promptBuf = ''
draw()
continue
}
if (code === 26) {
bareEditUndo(buf)
draw()
continue
}
if (code === 25) {
bareEditRedo(buf)
draw()
continue
}
continue
}
if (ev.type === 'key' && ev.ch) {
if (ev.ch === '\n') bareEditNewline(buf)
else if (ev.ch === '\t') bareEditInsertChar(buf, ' ')
else bareEditInsertChar(buf, ev.ch)
draw()
}
}
} finally {
try {
if (useAltScreen) {
bareEditWrite(ctx, stdout, '\x1b[?1049l')
} else {
bareEditWrite(ctx, stdout, '\x1b[2J\x1b[H')
}
bareEditWrite(ctx, stdout, '\x1b[?25h\x1b[0m')
} catch {
/* ignore */
}
reader.dispose()
try {
if (typeof stdin.setRawMode === 'function') stdin.setRawMode(false)
} catch {
/* ignore */
}
if (suspended && typeof ctx.resumeReplAfterSubprocess === 'function') {
ctx.resumeReplAfterSubprocess()
}
}
}