486 lines
14 KiB
JavaScript
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()
|
|
}
|
|
}
|
|
}
|