/* 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} 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} [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} [ctx] */ function bareEditUseColor(ctx) { const env = ctx && typeof ctx === 'object' && ctx.env && typeof ctx.env === 'object' ? /** @type {Record} */ (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 } /** * Insert pasted text as one undo step (normalized newlines; tab → two spaces). * @param {{ lines: string[], row: number, col: number, dirty: boolean, undo: unknown[], redo: unknown[] }} buf * @param {string} text */ function bareEditInsertPasteText(buf, text) { const normalized = String(text).replace(/\r\n/g, '\n').replace(/\r/g, '\n') if (!normalized) return bareEditPushUndo(buf) for (const ch of normalized) { if (ch === '\n') { 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 } else if (ch === '\t') { const line = buf.lines[buf.row] buf.lines[buf.row] = line.slice(0, buf.col) + ' ' + line.slice(buf.col) buf.col += 2 } else { 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 bareEditClampCursor(buf) } /** * @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' } const tildeNum = /^(\d+)~$/.exec(seq) if (tildeNum) { const n = parseInt(tildeNum[1], 10) const fnMap = { 11: 1, 12: 2, 13: 3, 14: 4, 15: 5, 17: 6, 18: 7, 19: 8, 20: 9, 21: 10, 23: 11, 24: 12 } if (fnMap[n] != null) return { type: 'fn', n: fnMap[n] } } return { type: 'unknown' } } if (last === 'Z') return { type: 'nav', key: 'stab' } 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' } if (b3 === 80) return { type: 'fn', n: 1 } if (b3 === 81) return { type: 'fn', n: 2 } if (b3 === 82) return { type: 'fn', n: 3 } if (b3 === 83) return { type: 'fn', n: 4 } return { type: 'unknown' } } /** ESC [ 200 ~ */ const BARE_EDIT_BRACKET_PASTE_START = [27, 91, 50, 48, 48, 126] /** ESC [ 201 ~ */ const BARE_EDIT_BRACKET_PASTE_END = [27, 91, 50, 48, 49, 126] /** * @param {number[]} q * @param {number[]} prefix */ function bareEditStartsWithBytes(q, prefix) { if (q.length < prefix.length) return false for (let i = 0; i < prefix.length; i++) { if (q[i] !== prefix[i]) return false } return true } /** * True if q could still become BARE_EDIT_BRACKET_PASTE_START with more bytes. * @param {number[]} q */ function bareEditCouldBeBracketPastePrefix(q) { const pre = BARE_EDIT_BRACKET_PASTE_START const n = Math.min(q.length, pre.length) for (let i = 0; i < n; i++) { if (q[i] !== pre[i]) return false } return true } /** * @param {number[]} bytes */ function bareEditDecodePasteInner(bytes) { try { if (typeof TextDecoder !== 'undefined') { return new TextDecoder('utf-8', { fatal: false }).decode( new Uint8Array(bytes) ) } } catch { /* fall through */ } let s = '' for (const b of bytes) s += String.fromCharCode(b) return s } /** * If queue begins with ESC [ 200 ~ but no closing ESC [ 201 ~ (stdin closed), return inner bytes as text. * @param {number[]} q * @returns {string|undefined} */ function bareEditFinalizeBracketedPasteOnEof(q) { const SL = BARE_EDIT_BRACKET_PASTE_START.length if (q.length >= SL && bareEditStartsWithBytes(q, BARE_EDIT_BRACKET_PASTE_START)) { const inner = q.slice(SL) q.length = 0 return bareEditDecodePasteInner(inner) } return undefined } /** * xterm bracketed paste: ESC [ 200 ~ … ESC [ 201 ~ * @param {number[]} q mutable queue * @returns {string|null|undefined} string if consumed; null if incomplete; undefined if not bracketed paste at front */ function bareEditTryConsumeBracketedPaste(q) { const SL = BARE_EDIT_BRACKET_PASTE_START.length const EL = BARE_EDIT_BRACKET_PASTE_END.length if (!q.length) return undefined if (q.length < SL) { return bareEditCouldBeBracketPastePrefix(q) ? null : undefined } if (!bareEditStartsWithBytes(q, BARE_EDIT_BRACKET_PASTE_START)) { return undefined } for (let i = SL; i <= q.length - EL; i++) { let ok = true for (let j = 0; j < EL; j++) { if (q[i + j] !== BARE_EDIT_BRACKET_PASTE_END[j]) { ok = false break } } if (ok) { const inner = q.slice(SL, i) q.splice(0, i + EL) return bareEditDecodePasteInner(inner) } } return null } /** * @param {number[]} q mutable queue (front = index 0) * @returns {Record | 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, dispose?: () => void, _keyq?: number[] }} reader */ async function bareEditReadKey(reader) { reader._keyq = reader._keyq || [] const q = reader._keyq for (;;) { const pasteText = bareEditTryConsumeBracketedPaste(q) if (pasteText !== undefined && pasteText !== null) { return { type: 'paste', text: pasteText } } if (pasteText === null) { const b = await reader.nextByte() if (b === undefined) { const eofPaste = bareEditFinalizeBracketedPasteOnEof(q) if (eofPaste !== undefined) { return { type: 'paste', text: eofPaste } } 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) continue } const ev = bareEditTryConsumeKey(q) if (ev) return ev const b = await reader.nextByte() if (b === undefined) { const eofPaste = bareEditFinalizeBracketedPasteOnEof(q) if (eofPaste !== undefined) { return { type: 'paste', text: eofPaste } } 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} 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} 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} */ (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} */ (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() } } } 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' }) }