Files
bare-operating-system/packages/bare-os-seeder/kernel/bin/nano
T
2026-04-04 07:44:07 -04:00

1370 lines
37 KiB
Plaintext

/* BARE_OS_BIN_API 1.0.0 — bump when staged /bin script semantics change (see developer guide). */
/** Shared helpers for drive-resident /bin scripts (prepended before each command). */
function bareStdin(ctx) {
return typeof ctx.shellStdin === 'string' ? ctx.shellStdin : ''
}
/** @param {number} mode @param {'file' | 'directory' | 'symlink'} type */
function bareFormatModeString(mode, type) {
const typeChar = type === 'directory' ? 'd' : type === 'symlink' ? 'l' : '-'
const perm = mode & 0o777
const r = (bit) => (perm & bit ? 'r' : '-')
const w = (bit) => (perm & bit ? 'w' : '-')
const x = (bit) => (perm & bit ? 'x' : '-')
return (
typeChar +
r(0o400) +
w(0o200) +
x(0o100) +
r(0o040) +
w(0o020) +
x(0o010) +
r(0o004) +
w(0o002) +
x(0o001)
)
}
/** @param {number} mtimeMs @param {number} [nowMs] */
function bareFormatLsMtime(mtimeMs, nowMs) {
const now = nowMs != null ? nowMs : Date.now()
const d = new Date(mtimeMs)
const months = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec'
]
const mon = months[d.getMonth()]
const day = String(d.getDate()).padStart(2, ' ')
const sixMo = 180 * 24 * 3600 * 1000
if (Math.abs(now - mtimeMs) > sixMo) {
const yr = String(d.getFullYear()).padStart(4, ' ')
return mon + ' ' + day + ' ' + yr
}
const hh = String(d.getHours()).padStart(2, '0')
const mm = String(d.getMinutes()).padStart(2, '0')
return mon + ' ' + day + ' ' + hh + ':' + mm
}
/** @param {number} size */
function barePosixBlocks(size) {
return Math.ceil(Number(size) / 512) || 0
}
/**
* Raw stdout for NUL/binary when **`process.stdout.write`** is missing.
* If **`ctx.bareOsBinWrite(Uint8Array|string)`** is set (tests / host), use it.
* @param {Record<string, unknown>} ctx
* @param {string | Uint8Array} chunk
* @returns {boolean}
*/
function bareOsEmitRaw(ctx, chunk) {
if (typeof ctx.bareOsBinWrite === 'function') {
const b4 = ctx.b4a
const u8 =
typeof chunk === 'string'
? b4 && typeof b4.from === 'function'
? b4.from(chunk)
: new TextEncoder().encode(chunk)
: chunk
ctx.bareOsBinWrite(u8 instanceof Uint8Array ? u8 : new Uint8Array(u8))
return true
}
const w = globalThis.process?.stdout?.write
if (typeof w === 'function') {
w.call(globalThis.process.stdout, chunk)
return true
}
return false
}
/** ANSI helpers for /bin/edit (preamble; no import in src). */
const EDIT_ANSI_RESET = '\x1b[0m'
/**
* @param {Record<string, unknown>} [ctx]
* @returns {import('stream').Writable | undefined}
*/
function bareEditResolveStdout(ctx) {
if (!ctx || typeof ctx !== 'object') return globalThis.process?.stdout
const c = /** @type {{ replStdout?: unknown, stdout?: unknown }} */ (ctx)
const out = c.replStdout || c.stdout || globalThis.process?.stdout
return /** @type {import('stream').Writable | undefined} */ (out)
}
/**
* @param {Record<string, unknown>} [ctx]
*/
function bareEditUseColor(ctx) {
const env =
ctx && typeof ctx === 'object' && ctx.env && typeof ctx.env === 'object'
? /** @type {Record<string, string>} */ (ctx.env)
: {}
if (env.NO_COLOR != null && String(env.NO_COLOR) !== '') return false
const out = bareEditResolveStdout(ctx)
return Boolean(out && /** @type {{ isTTY?: boolean }} */ (out).isTTY)
}
/**
* @param {'keyword'|'string'|'comment'|'number'|'status'|'inverse'|'dim'} cls
* @param {boolean} on
*/
function bareEditSgr(cls, on) {
if (!on) return ''
switch (cls) {
case 'keyword':
return '\x1b[36m'
case 'string':
return '\x1b[32m'
case 'comment':
return '\x1b[90m'
case 'number':
return '\x1b[33m'
case 'status':
return '\x1b[44m\x1b[97m'
case 'inverse':
return '\x1b[7m'
case 'dim':
return '\x1b[2m'
default:
return ''
}
}
/**
* @param {string} fullLine
* @param {Array<{ start: number, end: number, cls: string }>} spans offsets into fullLine
* @param {number} visStart first column (0-based)
* @param {number} maxLen max code units to show
* @param {boolean} useColor
*/
function bareEditPaintLineWindow(fullLine, spans, visStart, maxLen, useColor) {
const slice = fullLine.slice(visStart, visStart + maxLen)
if (!useColor) return slice
const n = slice.length
const relSpans = spans
.map((s) => ({
start: Math.max(0, s.start - visStart),
end: Math.min(n, s.end - visStart),
cls: s.cls
}))
.filter((s) => s.end > 0 && s.start < n)
.sort((a, b) => a.start - b.start)
let out = ''
let pos = 0
for (const sp of relSpans) {
if (sp.start > pos) out += slice.slice(pos, sp.start)
out +=
bareEditSgr(/** @type {'keyword'} */ (sp.cls), true) +
slice.slice(sp.start, sp.end) +
EDIT_ANSI_RESET
pos = sp.end
}
if (pos < n) out += slice.slice(pos)
return out
}
/**
* Move cursor (1-based row/col, DEC origin). Clamp to sane bounds for escape parsing.
* @param {number} row1
* @param {number} col1
*/
function bareEditCup(row1, col1) {
const r = Math.max(1, Math.min(Math.floor(row1), 9999))
const c = Math.max(1, Math.min(Math.floor(col1), 9999))
return '\x1b[' + r + ';' + c + 'H'
}
/** Syntax highlighting for /bin/edit (line-oriented, best-effort). */
const EDIT_KW_JS =
/^(?:const|let|var|function|return|async|await|if|else|for|while|do|switch|case|break|continue|default|try|catch|finally|throw|new|typeof|instanceof|in|of|class|extends|super|this|static|import|export|from|as|default|void|delete|yield|enum|interface|type|public|private|protected|readonly)$/
/**
* @param {string} path
*/
function bareEditDetectLang(path) {
const p = String(path || '').toLowerCase()
const dot = p.lastIndexOf('.')
const ext = dot >= 0 ? p.slice(dot) : ''
if (ext === '.json') return 'json'
if (ext === '.md' || ext === '.markdown') return 'md'
if (ext === '.sh' || ext === '.bash' || ext === '.zsh') return 'shell'
if (
ext === '.js' ||
ext === '.mjs' ||
ext === '.cjs' ||
ext === '.ts' ||
ext === '.tsx' ||
ext === '.jsx'
)
return 'js'
return 'plain'
}
/**
* Merge overlapping spans (later wins) — not used if we build non-overlapping.
* @param {Array<{ start: number, end: number, cls: string }>} spans
*/
function bareEditMergeSpans(spans) {
const s = spans.filter((x) => x.end > x.start).sort((a, b) => a.start - b.start || b.end - a.end)
/** @type {typeof spans} */
const out = []
for (const cur of s) {
const last = out[out.length - 1]
if (!last || cur.start >= last.end) {
out.push({ ...cur })
} else if (cur.end > last.end) {
if (cur.start > last.start) {
out[out.length - 1] = { start: last.start, end: cur.start, cls: last.cls }
out.push({ ...cur })
} else {
out[out.length - 1] = { ...cur }
}
}
}
return out
}
/**
* @param {string} line
* @returns {Array<{ start: number, end: number, cls: string }>}
*/
function bareEditSpansStringsCommentsJs(line) {
/** @type {Array<{ start: number, end: number, cls: string }>} */
const spans = []
let i = 0
while (i < line.length) {
const c = line[i]
const next = line[i + 1]
if (c === '/' && next === '/') {
spans.push({ start: i, end: line.length, cls: 'comment' })
break
}
if (c === '/' && next === '*') {
let j = i + 2
while (j < line.length - 1) {
if (line[j] === '*' && line[j + 1] === '/') {
j += 2
break
}
j++
}
if (j > line.length) j = line.length
spans.push({ start: i, end: j, cls: 'comment' })
i = j
continue
}
if (c === '"' || c === "'" || c === '`') {
const q = c
const start = i
i++
while (i < line.length) {
if (line[i] === '\\') {
i += 2
continue
}
if (line[i] === q) {
i++
break
}
i++
}
spans.push({ start, end: i, cls: 'string' })
continue
}
i++
}
return bareEditMergeSpans(spans)
}
/**
* @param {string} segment code only (no strings/comments inside)
*/
function bareEditSpansKeywordsNumbers(segment, offset) {
/** @type {Array<{ start: number, end: number, cls: string }>} */
const out = []
const re = /\b([A-Za-z_$][\w$]*)\b|\b(\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)\b/g
let m
while ((m = re.exec(segment)) !== null) {
if (m[1]) {
if (EDIT_KW_JS.test(m[1])) {
out.push({
start: offset + m.index,
end: offset + m.index + m[1].length,
cls: 'keyword'
})
}
} else if (m[2]) {
out.push({
start: offset + m.index,
end: offset + m.index + m[2].length,
cls: 'number'
})
}
}
return out
}
/**
* @param {string} line
* @param {number} gapStart
* @param {number} gapEnd
* @param {Array<{ start: number, end: number, cls: string }>} base
*/
function bareEditFillGapKeywords(line, gapStart, gapEnd, base) {
if (gapEnd <= gapStart) return
const seg = line.slice(gapStart, gapEnd)
const extra = bareEditSpansKeywordsNumbers(seg, gapStart)
for (const e of extra) base.push(e)
}
/**
* @param {string} line
*/
function bareEditHighlightJsLine(line) {
const sc = bareEditSpansStringsCommentsJs(line)
if (sc.length === 0) {
const all = /** @type {typeof sc} */ ([])
bareEditFillGapKeywords(line, 0, line.length, all)
return all.sort((a, b) => a.start - b.start)
}
/** @type {typeof sc} */
const out = [...sc]
let cursor = 0
for (const sp of sc) {
bareEditFillGapKeywords(line, cursor, sp.start, out)
cursor = sp.end
}
bareEditFillGapKeywords(line, cursor, line.length, out)
return out.sort((a, b) => a.start - b.start)
}
/**
* @param {string} line
*/
function bareEditHighlightJsonLine(line) {
const t = line.trimStart()
if (t.startsWith('//')) {
return [{ start: line.indexOf('//'), end: line.length, cls: 'comment' }]
}
/** @type {Array<{ start: number, end: number, cls: string }>} */
const spans = []
const re = /("(?:\\.|[^"\\])*")\s*:|("(?:\\.|[^"\\])*")|(\btrue|false|null\b)|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g
let m
while ((m = re.exec(line)) !== null) {
if (m[1]) {
const keyEnd = m.index + m[1].length
spans.push({ start: m.index, end: keyEnd, cls: 'keyword' })
} else if (m[2]) {
spans.push({
start: m.index,
end: m.index + m[2].length,
cls: 'string'
})
} else if (m[3]) {
spans.push({
start: m.index,
end: m.index + m[3].length,
cls: 'keyword'
})
} else if (m[4]) {
spans.push({
start: m.index,
end: m.index + m[4].length,
cls: 'number'
})
}
}
return spans.sort((a, b) => a.start - b.start)
}
/**
* @param {string} line
*/
function bareEditHighlightShellLine(line) {
const idx = line.indexOf('#')
if (idx >= 0) {
return [{ start: idx, end: line.length, cls: 'comment' }]
}
/** @type {Array<{ start: number, end: number, cls: string }>} */
const spans = []
const kw =
/^\s*(if|then|else|elif|fi|for|while|do|done|case|esac|function|return|export|local|readonly|source|\.)[\s#;]|\b(if|then|else|elif|fi|for|in|do|done|case|esac|function|return|export|local|readonly)\b/g
let m
while ((m = kw.exec(line)) !== null) {
const word = m[1] || m[2]
if (!word) continue
const start = m.index + m[0].indexOf(word)
spans.push({ start, end: start + word.length, cls: 'keyword' })
}
const dq = /"(?:\\.|[^"\\])*"/g
while ((m = dq.exec(line)) !== null) {
spans.push({ start: m.index, end: m.index + m[0].length, cls: 'string' })
}
const sq = /'[^']*'/g
while ((m = sq.exec(line)) !== null) {
spans.push({ start: m.index, end: m.index + m[0].length, cls: 'string' })
}
return spans.sort((a, b) => a.start - b.start)
}
/**
* @param {string} line
*/
function bareEditHighlightMdLine(line) {
/** @type {Array<{ start: number, end: number, cls: string }>} */
const spans = []
if (/^\s*#{1,6}\s/.test(line)) {
const m = line.match(/^\s*(#{1,6}\s.*)$/)
if (m) {
const i = line.indexOf(m[1])
spans.push({ start: i, end: line.length, cls: 'keyword' })
}
return spans
}
if (/^\s*(?:[-*+]|\d+\.)\s/.test(line)) {
const m = line.match(/^\s*((?:[-*+]|\d+\.)\s.*)$/)
if (m) {
const i = line.indexOf(m[1])
spans.push({ start: i, end: line.length, cls: 'comment' })
}
return spans
}
const bold = /\*\*[^*]+\*\*|__[^_]+__/g
let m
while ((m = bold.exec(line)) !== null) {
spans.push({ start: m.index, end: m.index + m[0].length, cls: 'string' })
}
const code = /`[^`]+`/g
while ((m = code.exec(line)) !== null) {
spans.push({ start: m.index, end: m.index + m[0].length, cls: 'number' })
}
return spans.sort((a, b) => a.start - b.start)
}
/**
* @param {string} line
* @param {string} lang
*/
function bareEditHighlightLine(line, lang) {
switch (lang) {
case 'js':
return bareEditHighlightJsLine(line)
case 'json':
return bareEditHighlightJsonLine(line)
case 'shell':
return bareEditHighlightShellLine(line)
case 'md':
return bareEditHighlightMdLine(line)
default:
return []
}
}
/** Text buffer + cursor + undo for /bin/edit. */
const EDIT_UNDO_MAX = 200
/**
* @param {string} text
*/
function bareEditCreateBuffer(text) {
const lines =
text === '' ? [''] : String(text).replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n')
return {
lines,
row: 0,
col: 0,
dirty: false,
/** @type {{ lines: string[], row: number, col: number }[]} */
undo: [],
/** @type {{ lines: string[], row: number, col: number }[]} */
redo: []
}
}
/**
* @param {{ lines: string[], row: number, col: number, dirty: boolean, undo: unknown[], redo: unknown[] }} buf
*/
function bareEditSnapshot(buf) {
return {
lines: buf.lines.slice(),
row: buf.row,
col: buf.col
}
}
/**
* @param {{ lines: string[], row: number, col: number, dirty: boolean, undo: unknown[], redo: unknown[] }} buf
*/
function bareEditPushUndo(buf) {
buf.undo.push(bareEditSnapshot(buf))
if (buf.undo.length > EDIT_UNDO_MAX) buf.undo.shift()
buf.redo = []
}
/**
* @param {{ lines: string[], row: number, col: number, dirty: boolean, undo: unknown[], redo: unknown[] }} buf
*/
function bareEditUndo(buf) {
const prev = buf.undo.pop()
if (!prev) return false
buf.redo.push(bareEditSnapshot(buf))
buf.lines = /** @type {{ lines: string[] }} */ (prev).lines.slice()
buf.row = prev.row
buf.col = prev.col
buf.dirty = true
return true
}
/**
* @param {{ lines: string[], row: number, col: number, dirty: boolean, undo: unknown[], redo: unknown[] }} buf
*/
function bareEditRedo(buf) {
const next = buf.redo.pop()
if (!next) return false
buf.undo.push(bareEditSnapshot(buf))
buf.lines = /** @type {{ lines: string[] }} */ (next).lines.slice()
buf.row = next.row
buf.col = next.col
buf.dirty = true
return true
}
/**
* @param {{ lines: string[], row: number, col: number, dirty: boolean, undo: unknown[], redo: unknown[] }} buf
*/
function bareEditClampCursor(buf) {
if (buf.row < 0) buf.row = 0
if (buf.row >= buf.lines.length) buf.row = buf.lines.length - 1
const line = buf.lines[buf.row] || ''
if (buf.col < 0) buf.col = 0
if (buf.col > line.length) buf.col = line.length
}
/**
* @param {{ lines: string[], row: number, col: number, dirty: boolean, undo: unknown[], redo: unknown[] }} buf
* @param {string} ch single code unit (MVP)
*/
function bareEditInsertChar(buf, ch) {
bareEditPushUndo(buf)
const line = buf.lines[buf.row]
buf.lines[buf.row] = line.slice(0, buf.col) + ch + line.slice(buf.col)
buf.col += ch.length
buf.dirty = true
}
/**
* @param {{ lines: string[], row: number, col: number, dirty: boolean, undo: unknown[], redo: unknown[] }} buf
*/
function bareEditNewline(buf) {
bareEditPushUndo(buf)
const line = buf.lines[buf.row]
const rest = line.slice(buf.col)
buf.lines[buf.row] = line.slice(0, buf.col)
buf.lines.splice(buf.row + 1, 0, rest)
buf.row++
buf.col = 0
buf.dirty = true
}
/**
* @param {{ lines: string[], row: number, col: number, dirty: boolean, undo: unknown[], redo: unknown[] }} buf
*/
function bareEditBackspace(buf) {
if (buf.col > 0) {
bareEditPushUndo(buf)
const line = buf.lines[buf.row]
buf.lines[buf.row] = line.slice(0, buf.col - 1) + line.slice(buf.col)
buf.col--
buf.dirty = true
return
}
if (buf.row > 0) {
bareEditPushUndo(buf)
const prevLen = buf.lines[buf.row - 1].length
buf.lines[buf.row - 1] += buf.lines[buf.row]
buf.lines.splice(buf.row, 1)
buf.row--
buf.col = prevLen
buf.dirty = true
}
}
/**
* @param {{ lines: string[], row: number, col: number, dirty: boolean, undo: unknown[], redo: unknown[] }} buf
*/
function bareEditDelete(buf) {
const line = buf.lines[buf.row]
if (buf.col < line.length) {
bareEditPushUndo(buf)
buf.lines[buf.row] = line.slice(0, buf.col) + line.slice(buf.col + 1)
buf.dirty = true
return
}
if (buf.row < buf.lines.length - 1) {
bareEditPushUndo(buf)
buf.lines[buf.row] += buf.lines[buf.row + 1]
buf.lines.splice(buf.row + 1, 1)
buf.dirty = true
}
}
/**
* @param {{ lines: string[], row: number, col: number, dirty: boolean, undo: unknown[], redo: unknown[] }} buf
* @param {'home'|'end'|'up'|'down'|'left'|'right'} key
*/
function bareEditMoveKey(buf, key) {
if (key === 'home') {
buf.col = 0
return
}
if (key === 'end') {
buf.col = buf.lines[buf.row].length
return
}
if (key === 'up') {
if (buf.row > 0) {
buf.row--
buf.col = Math.min(buf.col, buf.lines[buf.row].length)
}
return
}
if (key === 'down') {
if (buf.row < buf.lines.length - 1) {
buf.row++
buf.col = Math.min(buf.col, buf.lines[buf.row].length)
}
return
}
if (key === 'left') {
if (buf.col > 0) buf.col--
else if (buf.row > 0) {
buf.row--
buf.col = buf.lines[buf.row].length
}
return
}
if (key === 'right') {
const line = buf.lines[buf.row]
if (buf.col < line.length) buf.col++
else if (buf.row < buf.lines.length - 1) {
buf.row++
buf.col = 0
}
}
}
/**
* @param {{ lines: string[], row: number, col: number, dirty: boolean, undo: unknown[], redo: unknown[] }} buf
*/
function bareEditJoinAll(buf) {
return buf.lines.join('\n')
}
/**
* @param {{ lines: string[], row: number, col: number, dirty: boolean, undo: unknown[], redo: unknown[] }} buf
* @param {string} needle
* @param {number} fromRow
* @param {number} fromCol
* @returns {{ row: number, col: number } | null}
*/
function bareEditFindNext(buf, needle, fromRow, fromCol) {
if (!needle) return null
for (let r = fromRow; r < buf.lines.length; r++) {
const line = buf.lines[r] || ''
const start = r === fromRow ? fromCol : 0
const idx = line.indexOf(needle, start)
if (idx >= 0) return { row: r, col: idx }
}
for (let r = 0; r < fromRow; r++) {
const idx = (buf.lines[r] || '').indexOf(needle)
if (idx >= 0) return { row: r, col: idx }
}
const line = buf.lines[fromRow] || ''
const idx = line.indexOf(needle, 0)
if (idx >= 0 && idx < fromCol) return { row: fromRow, col: idx }
return null
}
/**
* @param {{ lines: string[], row: number, col: number }} buf
* @param {number} targetRow 1-based
*/
function bareEditGotoLine(buf, targetRow) {
const r = Math.max(1, Math.min(buf.lines.length, targetRow)) - 1
buf.row = r
buf.col = Math.min(buf.col, buf.lines[r].length)
}
/** TTY key parsing for /bin/edit: consume one logical key from a mutable byte queue. */
/**
* @param {number} b1
*/
function bareEditUtf8TrailCount(b1) {
if (b1 >= 0xc0 && b1 < 0xe0) return 1
if (b1 >= 0xe0 && b1 < 0xf0) return 2
if (b1 >= 0xf0) return 3
return 0
}
/**
* @param {number[]} bytes
*/
function bareEditUtf8DecodeKey(bytes) {
try {
const u = new Uint8Array(bytes)
if (typeof TextDecoder !== 'undefined') {
return new TextDecoder('utf-8', { fatal: false }).decode(u)
}
} catch {
/* fall through */
}
let s = ''
for (const b of bytes) s += String.fromCharCode(b)
return s
}
/**
* @param {string} seq CSI payload after ESC [, including final byte (e.g. "A", "1;5A", "3~")
*/
function bareEditCsiToEvent(seq) {
if (seq === '3~') return { type: 'ctrl', code: 'delete' }
if (seq === '5~') return { type: 'nav', key: 'pageup' }
if (seq === '6~') return { type: 'nav', key: 'pagedown' }
const last = seq.charAt(seq.length - 1)
if (last === '~') {
if (seq === '1~' || seq === '7~') return { type: 'nav', key: 'home' }
if (seq === '4~' || seq === '8~') return { type: 'nav', key: 'end' }
return { type: 'unknown' }
}
if (last === 'A' || last === 'B' || last === 'C' || last === 'D') {
const map = { A: 'up', B: 'down', C: 'right', D: 'left' }
return { type: 'nav', key: map[last] }
}
if (last === 'H') return { type: 'nav', key: 'home' }
if (last === 'F') return { type: 'nav', key: 'end' }
return { type: 'unknown' }
}
/**
* @param {number} b3 byte after ESC O
*/
function bareEditSs3ToEvent(b3) {
if (b3 === 72) return { type: 'nav', key: 'home' }
if (b3 === 70) return { type: 'nav', key: 'end' }
if (b3 === 65) return { type: 'nav', key: 'up' }
if (b3 === 66) return { type: 'nav', key: 'down' }
if (b3 === 67) return { type: 'nav', key: 'right' }
if (b3 === 68) return { type: 'nav', key: 'left' }
return { type: 'unknown' }
}
/**
* @param {number[]} q mutable queue (front = index 0)
* @returns {Record<string, unknown> | null} null if more bytes needed
*/
function bareEditTryConsumeKey(q) {
if (!q.length) return null
const b1 = q[0]
if (b1 === 3) {
q.shift()
return { type: 'ctrl', code: 'interrupt' }
}
if (b1 === 8 || b1 === 127) {
q.shift()
return { type: 'ctrl', code: 'backspace' }
}
if (b1 === 13 || b1 === 10) {
q.shift()
return { type: 'key', ch: '\n' }
}
if (b1 === 9) {
q.shift()
return { type: 'key', ch: '\t' }
}
if (b1 === 27) {
if (q.length < 2) return null
const b2 = q[1]
if (b2 === 91) {
let i = 2
while (i < q.length) {
const b = q[i]
if (b >= 0x40 && b <= 0x7e) {
const seq = String.fromCharCode.apply(null, q.slice(2, i + 1))
q.splice(0, i + 1)
return bareEditCsiToEvent(seq)
}
i++
}
return null
}
if (b2 === 79) {
if (q.length < 3) return null
const b3 = q[2]
q.splice(0, 3)
return bareEditSs3ToEvent(b3)
}
q.splice(0, 2)
return { type: 'key', ch: String.fromCharCode(b2) }
}
if (b1 < 0x20) {
q.shift()
return { type: 'ctrl', code: b1 }
}
const need = bareEditUtf8TrailCount(b1)
if (q.length < 1 + need) return null
const chunk = q.splice(0, 1 + need)
return { type: 'key', ch: bareEditUtf8DecodeKey(chunk) }
}
/** 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()
}
}
}
async function run(ctx, argv) {
const args = argv.slice(1)
if (args.includes('-h') || args.includes('--help')) {
ctx.console.log(
'usage: ' +
(argv[0] || 'edit') +
' [file]\n' +
'Terminal editor with syntax highlighting. Requires a TTY.\n' +
'See man edit for key bindings.'
)
return
}
let path = 'Untitled'
for (const a of args) {
if (a && !String(a).startsWith('-')) {
path = String(a)
break
}
}
const stdin = ctx.replStdin
const stdout = ctx.replStdout || ctx.stdout
if (!stdin || !/** @type {{ isTTY?: boolean }} */ (stdin).isTTY) {
ctx.console.error('edit: a terminal (TTY) is required')
ctx.exitCode = 1
return
}
if (!stdout) {
ctx.console.error('edit: missing stdout')
ctx.exitCode = 1
return
}
let initial = ''
try {
const fileBuf = await ctx.vfs.readFile(path)
if (fileBuf) initial = ctx.b4a.toString(fileBuf)
} catch {
initial = ''
}
const maxChars = 2000000
if (initial.length > maxChars) {
ctx.console.error('edit: file too large (max ' + maxChars + ' characters)')
ctx.exitCode = 1
return
}
await bareOsRunEditTui(ctx, {
path,
initialText: initial,
argv0: argv[0] || 'edit'
})
}