441 lines
13 KiB
JavaScript
441 lines
13 KiB
JavaScript
/** Full-screen TUI for /bin/edit (stdin/key helpers in edit-stream-read.js; bareEditWrite in edit-ansi.js). */
|
|
|
|
/**
|
|
* @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 = ''
|
|
|
|
const envEditEarly =
|
|
ctx.env && typeof ctx.env === 'object'
|
|
? /** @type {Record<string, string>} */ (ctx.env)
|
|
: {}
|
|
let maxPromptLen = 4096
|
|
const maxPromptRaw = envEditEarly.BARE_EDIT_MAX_PROMPT
|
|
if (maxPromptRaw != null && String(maxPromptRaw) !== '') {
|
|
const n = parseInt(String(maxPromptRaw), 10)
|
|
if (Number.isFinite(n) && n > 0) maxPromptLen = Math.min(n, 65536)
|
|
}
|
|
|
|
/**
|
|
* @param {string} text
|
|
* @param {'saveas'|'search'|'goto'} kind
|
|
*/
|
|
function promptLineFromPaste(text, kind) {
|
|
let line = String(text).split(/\r?\n/)[0] ?? ''
|
|
line = line.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, '')
|
|
if (line.length > maxPromptLen) line = line.slice(0, maxPromptLen)
|
|
if (kind === 'goto') {
|
|
const m = /^(\s*\d+)/.exec(line)
|
|
return m ? m[1].trim() : ''
|
|
}
|
|
return line
|
|
}
|
|
|
|
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
|
|
/** When true, bracketed paste mode was enabled (?2004h); must send ?2004l on exit. */
|
|
let useBracketPaste = 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 bareEditNoAltScreen =
|
|
envEditEarly.BARE_EDIT_NO_ALTSCREEN != null &&
|
|
String(envEditEarly.BARE_EDIT_NO_ALTSCREEN) !== ''
|
|
if (!bareEditNoAltScreen) {
|
|
bareEditWrite(ctx, stdout, '\x1b[?1049h')
|
|
useAltScreen = true
|
|
}
|
|
|
|
const bareEditNoBracketPaste =
|
|
envEditEarly.BARE_EDIT_NO_BRACKETED_PASTE != null &&
|
|
String(envEditEarly.BARE_EDIT_NO_BRACKETED_PASTE) !== ''
|
|
if (!bareEditNoBracketPaste) {
|
|
bareEditWrite(ctx, stdout, '\x1b[?2004h')
|
|
useBracketPaste = 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 === 'paste') {
|
|
if (mode === 'prompt_saveas') {
|
|
promptBuf = promptLineFromPaste(ev.text || '', 'saveas')
|
|
} else if (mode === 'prompt_search') {
|
|
promptBuf = promptLineFromPaste(ev.text || '', 'search')
|
|
} else if (mode === 'prompt_goto') {
|
|
promptBuf = promptLineFromPaste(ev.text || '', 'goto')
|
|
}
|
|
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.length < maxPromptLen) promptBuf += ev.ch
|
|
draw()
|
|
continue
|
|
}
|
|
continue
|
|
}
|
|
|
|
if (ev.type === 'paste') {
|
|
bareEditInsertPasteText(buf, ev.text || '')
|
|
draw()
|
|
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 (useBracketPaste) {
|
|
bareEditWrite(ctx, stdout, '\x1b[?2004l')
|
|
}
|
|
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()
|
|
}
|
|
}
|
|
}
|