2345 lines
66 KiB
Plaintext
2345 lines
66 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
|
|
}
|
|
|
|
/** Session env map (`vfs.env`, then `ctx.env`). Never throws. */
|
|
function bareOsEnv(ctx) {
|
|
const v = ctx && ctx.vfs && ctx.vfs.env
|
|
if (v && typeof v === 'object') return v
|
|
const e = ctx && ctx.env
|
|
if (e && typeof e === 'object') return e
|
|
return {}
|
|
}
|
|
|
|
/**
|
|
* Strict POSIX-ish decimal integer (no octal, no exponent, no empty).
|
|
* @param {unknown} s
|
|
* @returns {number}
|
|
*/
|
|
function bareOsParseDecInt(s) {
|
|
const t = String(s == null ? '' : s).trim()
|
|
if (!/^[+-]?(?:0|[1-9][0-9]*)$/.test(t)) return NaN
|
|
const n = Number.parseInt(t, 10)
|
|
return Number.isSafeInteger(n) ? n : NaN
|
|
}
|
|
|
|
/** @param {unknown} s */
|
|
function bareOsParseNonNegInt(s) {
|
|
const n = bareOsParseDecInt(s)
|
|
return n >= 0 ? n : NaN
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {string} name
|
|
* @param {number} fallback
|
|
* @param {number} [min]
|
|
* @param {number} [max]
|
|
*/
|
|
function bareOsEnvInt(ctx, name, fallback, min, max) {
|
|
const raw = bareOsEnv(ctx)[name]
|
|
if (raw == null || raw === '') return fallback
|
|
const n = Number.parseInt(String(raw), 10)
|
|
if (!Number.isFinite(n)) return fallback
|
|
let v = n
|
|
if (min != null && v < min) v = min
|
|
if (max != null && v > max) v = max
|
|
return v
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {string} msg
|
|
* @param {number} [code]
|
|
*/
|
|
function bareOsFail(ctx, msg, code) {
|
|
if (msg) ctx.console.error(msg)
|
|
ctx.exitCode = code == null ? 1 : code
|
|
}
|
|
|
|
/** @param {unknown} e */
|
|
function bareOsIsNotFoundErr(e) {
|
|
const code = e && typeof e === 'object' ? e.code : ''
|
|
if (code === 'ENOENT') return true
|
|
const msg = String((e && e.message) || e || '')
|
|
return /ENOENT|No such file|not found/i.test(msg)
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {unknown} buf
|
|
* @returns {Uint8Array}
|
|
*/
|
|
function bareOsToU8(ctx, buf) {
|
|
if (!buf) return new Uint8Array(0)
|
|
if (buf instanceof Uint8Array) return buf
|
|
if (ctx && ctx.b4a && typeof ctx.b4a.from === 'function') return ctx.b4a.from(buf)
|
|
return new Uint8Array(buf)
|
|
}
|
|
|
|
/** @param {string} dir @param {string} name */
|
|
function bareOsJoinPath(dir, name) {
|
|
const d = String(dir || '').replace(/\/+$/, '')
|
|
const n = String(name || '').replace(/^\/+/, '')
|
|
if (!d || d === '/') return '/' + n
|
|
return d + '/' + n
|
|
}
|
|
|
|
/** @param {string} p */
|
|
function bareOsBaseName(p) {
|
|
const t = String(p || '').replace(/\/+$/, '')
|
|
if (!t || t === '/') return t === '/' ? '/' : ''
|
|
const i = t.lastIndexOf('/')
|
|
return i < 0 ? t : t.slice(i + 1) || t
|
|
}
|
|
|
|
/** @param {string} p */
|
|
function bareOsParentDir(p) {
|
|
const t = String(p || '').replace(/\/+$/, '') || '/'
|
|
if (t === '/') return '/'
|
|
const i = t.lastIndexOf('/')
|
|
return i <= 0 ? '/' : t.slice(0, i) || '/'
|
|
}
|
|
|
|
/** @param {string} p */
|
|
function bareOsNormPath(p) {
|
|
return String(p || '').replace(/\/+$/, '') || '/'
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {string} p
|
|
*/
|
|
function bareOsResolvePath(ctx, p) {
|
|
if (ctx && ctx.vfs && typeof ctx.vfs.resolveLogical === 'function') {
|
|
try {
|
|
return String(ctx.vfs.resolveLogical(p) || p)
|
|
} catch {
|
|
/* fall through */
|
|
}
|
|
}
|
|
return String(p || '')
|
|
}
|
|
|
|
/**
|
|
* True when dest is src or lives under src (self-copy / self-move).
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {string} src
|
|
* @param {string} dest
|
|
*/
|
|
function bareOsDestInsideSrc(ctx, src, dest) {
|
|
const s = bareOsNormPath(bareOsResolvePath(ctx, src))
|
|
const d = bareOsNormPath(bareOsResolvePath(ctx, dest))
|
|
if (s === d) return true
|
|
if (s === '/') return d !== '/'
|
|
return d === s || d.startsWith(s + '/')
|
|
}
|
|
|
|
const BARE_OS_B64_ALPH =
|
|
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
|
|
|
|
/** @param {Uint8Array} u8 */
|
|
function bareOsB64Encode(u8) {
|
|
let out = ''
|
|
let i = 0
|
|
for (; i + 2 < u8.length; i += 3) {
|
|
const n = (u8[i] << 16) | (u8[i + 1] << 8) | u8[i + 2]
|
|
out +=
|
|
BARE_OS_B64_ALPH[(n >> 18) & 63] +
|
|
BARE_OS_B64_ALPH[(n >> 12) & 63] +
|
|
BARE_OS_B64_ALPH[(n >> 6) & 63] +
|
|
BARE_OS_B64_ALPH[n & 63]
|
|
}
|
|
const rest = u8.length - i
|
|
if (rest === 1) {
|
|
const n = u8[i] << 16
|
|
out += BARE_OS_B64_ALPH[(n >> 18) & 63] + BARE_OS_B64_ALPH[(n >> 12) & 63] + '=='
|
|
} else if (rest === 2) {
|
|
const n = (u8[i] << 16) | (u8[i + 1] << 8)
|
|
out +=
|
|
BARE_OS_B64_ALPH[(n >> 18) & 63] +
|
|
BARE_OS_B64_ALPH[(n >> 12) & 63] +
|
|
BARE_OS_B64_ALPH[(n >> 6) & 63] +
|
|
'='
|
|
}
|
|
return out
|
|
}
|
|
|
|
/**
|
|
* RFC 4648 Base64 decode (also accepts URL-safe alphabet). Rejects junk.
|
|
* @param {string} s
|
|
* @returns {Uint8Array}
|
|
*/
|
|
function bareOsB64Decode(s) {
|
|
const t = String(s).replace(/\s+/g, '')
|
|
if (!t) return new Uint8Array(0)
|
|
if (t.length % 4 === 1) throw new Error('invalid base64 length')
|
|
let pad = 0
|
|
if (t.endsWith('==')) pad = 2
|
|
else if (t.endsWith('=')) pad = 1
|
|
const body = pad ? t.slice(0, t.length - pad) : t
|
|
const bytes = []
|
|
let buf = 0
|
|
let bits = 0
|
|
for (let i = 0; i < body.length; i++) {
|
|
const c = body[i]
|
|
let v = BARE_OS_B64_ALPH.indexOf(c)
|
|
if (v < 0) {
|
|
if (c === '-') v = 62
|
|
else if (c === '_') v = 63
|
|
else throw new Error('invalid base64 character')
|
|
}
|
|
buf = (buf << 6) | v
|
|
bits += 6
|
|
if (bits >= 8) {
|
|
bits -= 8
|
|
bytes.push((buf >> bits) & 255)
|
|
}
|
|
}
|
|
if (pad) {
|
|
const want = Math.floor((body.length * 6) / 8)
|
|
if (bytes.length > want) bytes.length = want
|
|
}
|
|
return new Uint8Array(bytes)
|
|
}
|
|
|
|
/**
|
|
* @param {string} s
|
|
* @returns {Uint8Array}
|
|
*/
|
|
function bareOsHexDecode(s) {
|
|
const t = String(s).replace(/\s+/g, '')
|
|
if (t.length % 2 !== 0) throw new Error('odd hex length')
|
|
const out = new Uint8Array(t.length / 2)
|
|
for (let i = 0; i < out.length; i++) {
|
|
const pair = t.slice(i * 2, i * 2 + 2)
|
|
if (!/^[0-9a-fA-F]{2}$/.test(pair)) throw new Error('invalid hex')
|
|
out[i] = Number.parseInt(pair, 16)
|
|
}
|
|
return out
|
|
}
|
|
|
|
/** @param {Uint8Array} u8 */
|
|
function bareOsHexEncode(u8) {
|
|
let s = ''
|
|
for (let i = 0; i < u8.length; i++) s += u8[i].toString(16).padStart(2, '0')
|
|
return s
|
|
}
|
|
|
|
/** 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'
|
|
}
|
|
|
|
/**
|
|
* @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 */
|
|
}
|
|
}
|
|
}
|
|
|
|
/** 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<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) }
|
|
}
|
|
|
|
/** Stdin chunk queue + async key reader for edit/chat TUIs (requires edit-key-parse.js first). */
|
|
|
|
/**
|
|
* @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 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Full-screen TUI for /bin/edit (TEA via ctx.tui; pre-SDK loop in Legacy). */
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} ctx
|
|
*/
|
|
function bareEditTuiRunOpts(ctx) {
|
|
const env =
|
|
ctx.env && typeof ctx.env === 'object'
|
|
? /** @type {Record<string, string>} */ (ctx.env)
|
|
: {}
|
|
/** @type {{ altScreen?: boolean, bracketedPaste?: boolean, buffer: 'cell' }} */
|
|
const opts = { buffer: 'cell' }
|
|
if (
|
|
(env.BARE_EDIT_NO_ALTSCREEN != null &&
|
|
String(env.BARE_EDIT_NO_ALTSCREEN) !== '') ||
|
|
(env.BARE_OS_TUI_NO_ALTSCREEN != null &&
|
|
String(env.BARE_OS_TUI_NO_ALTSCREEN) !== '')
|
|
) {
|
|
opts.altScreen = false
|
|
}
|
|
if (
|
|
env.BARE_EDIT_NO_BRACKETED_PASTE != null &&
|
|
String(env.BARE_EDIT_NO_BRACKETED_PASTE) !== ''
|
|
) {
|
|
opts.bracketedPaste = false
|
|
}
|
|
return opts
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} ctx
|
|
*/
|
|
function bareEditMaxPromptLen(ctx) {
|
|
const env =
|
|
ctx.env && typeof ctx.env === 'object'
|
|
? /** @type {Record<string, string>} */ (ctx.env)
|
|
: {}
|
|
let maxPromptLen = 4096
|
|
const maxPromptRaw = env.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)
|
|
}
|
|
return maxPromptLen
|
|
}
|
|
|
|
/**
|
|
* @param {string} text
|
|
* @param {'saveas'|'search'|'goto'} kind
|
|
* @param {number} maxPromptLen
|
|
*/
|
|
function bareEditPromptLineFromPaste(text, kind, maxPromptLen) {
|
|
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
|
|
}
|
|
|
|
/**
|
|
* Ctrl+_ (ASCII US, 0x1f) — Go to line. Decoder names it as ctrl+\x7f.
|
|
* @param {unknown} msg
|
|
*/
|
|
function bareEditIsGotoChord(msg) {
|
|
if (!msg || /** @type {{ type?: string }} */ (msg).type !== 'key')
|
|
return false
|
|
const m =
|
|
/** @type {{ ctrl?: boolean, sequence?: string, name?: string }} */ (msg)
|
|
if (!m.ctrl) return false
|
|
return m.sequence === '\x1f' || m.name === '\x7f'
|
|
}
|
|
|
|
/**
|
|
* TEA model for /bin/edit. Cell buffer so help / prompts overlay without reflow.
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {{ path: string, initialText: string, argv0?: string }} opts
|
|
*/
|
|
function bareEditCreateTuiApp(ctx, opts) {
|
|
const tui = ctx.tui
|
|
const vfs = ctx.vfs
|
|
const b4a = ctx.b4a
|
|
const size0 = tui && typeof tui.size === 'function' ? tui.size() : {}
|
|
const useColor =
|
|
typeof bareEditUseColor === 'function' ? bareEditUseColor(ctx) : true
|
|
return {
|
|
path: opts.path || 'Untitled',
|
|
display: opts.argv0 || 'edit',
|
|
lang: bareEditDetectLang(opts.path || ''),
|
|
buf: bareEditCreateBuffer(opts.initialText),
|
|
scrollRow: 0,
|
|
scrollCol: 0,
|
|
savePath: opts.path || 'Untitled',
|
|
mode: /** @type {'edit'|'quit_confirm'|'help'|'prompt_search'|'prompt_goto'|'prompt_saveas'} */ (
|
|
'edit'
|
|
),
|
|
promptBuf: '',
|
|
promptTitle: '',
|
|
lastSearch: '',
|
|
maxPromptLen: bareEditMaxPromptLen(ctx),
|
|
pendingQuit: false,
|
|
statusMsg: '',
|
|
width: size0.width || 80,
|
|
height: size0.height || 24,
|
|
init: function () {
|
|
return null
|
|
},
|
|
_contentH: function () {
|
|
return Math.max(1, (this.height || 24) - 2)
|
|
},
|
|
_ensureScroll: function () {
|
|
const cols = Math.max(40, this.width || 80)
|
|
const contentH = this._contentH()
|
|
const buf = this.buf
|
|
if (buf.row < this.scrollRow) this.scrollRow = buf.row
|
|
if (buf.row >= this.scrollRow + contentH)
|
|
this.scrollRow = buf.row - contentH + 1
|
|
if (this.scrollRow < 0) this.scrollRow = 0
|
|
if (this.scrollCol > buf.col) this.scrollCol = buf.col
|
|
if (buf.col - this.scrollCol >= cols) this.scrollCol = buf.col - cols + 1
|
|
if (this.scrollCol < 0) this.scrollCol = 0
|
|
},
|
|
_save: function (targetPath) {
|
|
const self = this
|
|
return function () {
|
|
return Promise.resolve()
|
|
.then(function () {
|
|
const text = bareEditJoinAll(self.buf)
|
|
return vfs.writeFile(targetPath, b4a.from(text))
|
|
})
|
|
.then(function () {
|
|
return { type: 'edit.saved', ok: true, path: targetPath }
|
|
})
|
|
.catch(function (error) {
|
|
return { type: 'edit.saved', ok: false, error: error }
|
|
})
|
|
}
|
|
},
|
|
_applySearch: function () {
|
|
this.lastSearch = this.promptBuf
|
|
if (!this.lastSearch) return
|
|
const hit = bareEditFindNext(
|
|
this.buf,
|
|
this.lastSearch,
|
|
this.buf.row,
|
|
this.buf.col + 1
|
|
)
|
|
if (!hit) {
|
|
const hit2 = bareEditFindNext(this.buf, this.lastSearch, 0, 0)
|
|
if (hit2) {
|
|
this.buf.row = hit2.row
|
|
this.buf.col = hit2.col
|
|
}
|
|
} else {
|
|
this.buf.row = hit.row
|
|
this.buf.col = hit.col
|
|
}
|
|
},
|
|
_promptKind: function () {
|
|
if (this.mode === 'prompt_search') return 'search'
|
|
if (this.mode === 'prompt_goto') return 'goto'
|
|
if (this.mode === 'prompt_saveas') return 'saveas'
|
|
return ''
|
|
},
|
|
update: function (msg) {
|
|
if (msg && msg.type === 'resize') {
|
|
this.width = msg.width || this.width
|
|
this.height = msg.height || this.height
|
|
this._ensureScroll()
|
|
return [this, null]
|
|
}
|
|
if (msg && msg.type === 'edit.saved') {
|
|
if (msg.ok) {
|
|
this.buf.dirty = false
|
|
this.savePath = String(msg.path || this.savePath)
|
|
this.statusMsg = ''
|
|
if (this.pendingQuit) return [this, tui.quit]
|
|
} else {
|
|
const err = msg.error
|
|
this.statusMsg =
|
|
'edit: ' +
|
|
(err && err.message ? err.message : String(err || 'save failed'))
|
|
this.pendingQuit = false
|
|
}
|
|
return [this, null]
|
|
}
|
|
if (this.mode === 'help') {
|
|
if (msg && msg.type === 'key') this.mode = 'edit'
|
|
return [this, null]
|
|
}
|
|
if (this.mode === 'quit_confirm') {
|
|
if (tui.key.matches(msg, 'ctrl+c')) {
|
|
this.mode = 'edit'
|
|
return [this, null]
|
|
}
|
|
if (tui.key.matches(msg, 'y', 'Y')) {
|
|
this.pendingQuit = true
|
|
this.mode = 'edit'
|
|
return [this, this._save(this.savePath)]
|
|
}
|
|
if (tui.key.matches(msg, 'n', 'N')) return [this, tui.quit]
|
|
return [this, null]
|
|
}
|
|
if (this._promptKind()) {
|
|
if (tui.key.matches(msg, 'ctrl+c')) {
|
|
this.mode = 'edit'
|
|
return [this, null]
|
|
}
|
|
if (msg && msg.type === 'paste') {
|
|
this.promptBuf = bareEditPromptLineFromPaste(
|
|
String(msg.text || ''),
|
|
/** @type {'saveas'|'search'|'goto'} */ (this._promptKind()),
|
|
this.maxPromptLen
|
|
)
|
|
return [this, null]
|
|
}
|
|
if (tui.key.matches(msg, 'enter')) {
|
|
if (this.mode === 'prompt_search') this._applySearch()
|
|
else if (this.mode === 'prompt_goto') {
|
|
const n = parseInt(this.promptBuf.trim(), 10)
|
|
if (Number.isFinite(n)) bareEditGotoLine(this.buf, n)
|
|
} else if (this.mode === 'prompt_saveas') {
|
|
const p = this.promptBuf.trim()
|
|
this.mode = 'edit'
|
|
if (p) {
|
|
this.savePath = p
|
|
return [this, this._save(this.savePath)]
|
|
}
|
|
}
|
|
this.mode = 'edit'
|
|
return [this, null]
|
|
}
|
|
if (tui.key.matches(msg, 'backspace')) {
|
|
this.promptBuf = this.promptBuf.slice(0, -1)
|
|
return [this, null]
|
|
}
|
|
if (
|
|
msg &&
|
|
msg.type === 'key' &&
|
|
!msg.ctrl &&
|
|
!msg.meta &&
|
|
typeof msg.sequence === 'string' &&
|
|
msg.sequence.length === 1 &&
|
|
msg.sequence >= ' ' &&
|
|
msg.sequence !== '\x7f'
|
|
) {
|
|
if (this.promptBuf.length < this.maxPromptLen)
|
|
this.promptBuf += msg.sequence
|
|
}
|
|
return [this, null]
|
|
}
|
|
if (msg && msg.type === 'paste') {
|
|
bareEditInsertPasteText(this.buf, msg.text || '')
|
|
this._ensureScroll()
|
|
return [this, null]
|
|
}
|
|
if (tui.key.matches(msg, 'ctrl+x')) {
|
|
if (this.buf.dirty) {
|
|
this.mode = 'quit_confirm'
|
|
return [this, null]
|
|
}
|
|
return [this, tui.quit]
|
|
}
|
|
if (tui.key.matches(msg, 'ctrl+s')) {
|
|
return [this, this._save(this.savePath)]
|
|
}
|
|
if (tui.key.matches(msg, 'ctrl+o')) {
|
|
this.mode = 'prompt_saveas'
|
|
this.promptTitle = 'File: '
|
|
this.promptBuf = this.savePath
|
|
return [this, null]
|
|
}
|
|
if (tui.key.matches(msg, 'ctrl+w')) {
|
|
this.mode = 'prompt_search'
|
|
this.promptTitle = 'Search: '
|
|
this.promptBuf = this.lastSearch
|
|
return [this, null]
|
|
}
|
|
if (tui.key.matches(msg, 'ctrl+g')) {
|
|
this.mode = 'help'
|
|
return [this, null]
|
|
}
|
|
if (bareEditIsGotoChord(msg)) {
|
|
this.mode = 'prompt_goto'
|
|
this.promptTitle = 'Go to line: '
|
|
this.promptBuf = ''
|
|
return [this, null]
|
|
}
|
|
if (tui.key.matches(msg, 'ctrl+z')) {
|
|
bareEditUndo(this.buf)
|
|
this._ensureScroll()
|
|
return [this, null]
|
|
}
|
|
if (tui.key.matches(msg, 'ctrl+y')) {
|
|
bareEditRedo(this.buf)
|
|
this._ensureScroll()
|
|
return [this, null]
|
|
}
|
|
if (tui.key.matches(msg, 'pageup')) {
|
|
const step = Math.max(1, (this.height || 24) - 4)
|
|
this.buf.row = Math.max(0, this.buf.row - step)
|
|
bareEditClampCursor(this.buf)
|
|
this._ensureScroll()
|
|
return [this, null]
|
|
}
|
|
if (tui.key.matches(msg, 'pagedown')) {
|
|
const step = Math.max(1, (this.height || 24) - 4)
|
|
this.buf.row = Math.min(this.buf.lines.length - 1, this.buf.row + step)
|
|
bareEditClampCursor(this.buf)
|
|
this._ensureScroll()
|
|
return [this, null]
|
|
}
|
|
if (tui.key.matches(msg, 'up', 'down', 'left', 'right', 'home', 'end')) {
|
|
const name = msg && msg.name
|
|
if (
|
|
name === 'up' ||
|
|
name === 'down' ||
|
|
name === 'left' ||
|
|
name === 'right' ||
|
|
name === 'home' ||
|
|
name === 'end'
|
|
) {
|
|
bareEditMoveKey(this.buf, name)
|
|
}
|
|
this._ensureScroll()
|
|
return [this, null]
|
|
}
|
|
if (tui.key.matches(msg, 'backspace')) {
|
|
bareEditBackspace(this.buf)
|
|
this._ensureScroll()
|
|
return [this, null]
|
|
}
|
|
if (tui.key.matches(msg, 'delete')) {
|
|
bareEditDelete(this.buf)
|
|
this._ensureScroll()
|
|
return [this, null]
|
|
}
|
|
if (tui.key.matches(msg, 'enter')) {
|
|
bareEditNewline(this.buf)
|
|
this._ensureScroll()
|
|
return [this, null]
|
|
}
|
|
if (tui.key.matches(msg, 'tab')) {
|
|
bareEditInsertChar(this.buf, ' ')
|
|
this._ensureScroll()
|
|
return [this, null]
|
|
}
|
|
if (tui.key.matches(msg, 'ctrl+c')) {
|
|
return [this, null]
|
|
}
|
|
if (
|
|
msg &&
|
|
msg.type === 'key' &&
|
|
!msg.ctrl &&
|
|
!msg.meta &&
|
|
typeof msg.sequence === 'string' &&
|
|
msg.sequence.length === 1 &&
|
|
msg.sequence >= ' '
|
|
) {
|
|
bareEditInsertChar(this.buf, msg.sequence)
|
|
this._ensureScroll()
|
|
}
|
|
return [this, null]
|
|
},
|
|
view: function () {
|
|
const cols = Math.max(40, this.width || 80)
|
|
const contentH = this._contentH()
|
|
this._ensureScroll()
|
|
const lines = []
|
|
for (let r = 0; r < contentH; r++) {
|
|
const lineIndex = this.scrollRow + r
|
|
const line = this.buf.lines[lineIndex] || ''
|
|
const spans = bareEditHighlightLine(line, this.lang)
|
|
lines.push(
|
|
bareEditPaintLineWindow(line, spans, this.scrollCol, cols, useColor)
|
|
)
|
|
}
|
|
const dirtyMark = this.buf.dirty ? ' [Modified]' : ''
|
|
const statusRaw =
|
|
this.statusMsg ||
|
|
this.display +
|
|
' ' +
|
|
this.savePath +
|
|
dirtyMark +
|
|
' ' +
|
|
(this.buf.row + 1) +
|
|
',' +
|
|
(this.buf.col + 1)
|
|
const helpRaw =
|
|
'^G Help ^O/^S Save ^X Exit ^W Find ^_ Line ^Z Undo ^Y Redo'
|
|
const st = tui.style
|
|
const status = st
|
|
? st()
|
|
.foreground('brightwhite')
|
|
.background('blue')
|
|
.width(cols)
|
|
.render(statusRaw)
|
|
: statusRaw
|
|
const help = st ? st().dim().width(cols).render(helpRaw) : helpRaw
|
|
return lines.join('\n') + '\n' + status + '\n' + help
|
|
},
|
|
overlays: function (size) {
|
|
const cols = (size && size.width) || this.width || 80
|
|
const rows = (size && size.height) || this.height || 24
|
|
const st = tui.style
|
|
/** @type {{ row: number, col: number, text: string }[]} */
|
|
const list = []
|
|
if (this.mode === 'help') {
|
|
const body =
|
|
'Bare OS edit — help\n\n' +
|
|
'^O ^S Save ^X Exit\n' +
|
|
'^W Search ^_ Go to line\n' +
|
|
'^Z Undo ^Y Redo\n' +
|
|
'Arrows move cursor; Home/End; Backspace/Del\n\n' +
|
|
'Press any key to return.'
|
|
const boxed = st
|
|
? st()
|
|
.border(st.borders.rounded)
|
|
.padding(1, 2)
|
|
.background('black')
|
|
.render(body)
|
|
: body
|
|
const h = st ? st.height(boxed) : boxed.split('\n').length
|
|
const w = st ? st.width(boxed) : 40
|
|
list.push({
|
|
row: Math.max(0, Math.floor((rows - h) / 2)),
|
|
col: Math.max(0, Math.floor((cols - w) / 2)),
|
|
text: boxed
|
|
})
|
|
return list
|
|
}
|
|
if (this.mode === 'quit_confirm') {
|
|
const msg = 'Save modified buffer? Y Yes N No ^C Cancel'
|
|
list.push({
|
|
row: Math.max(0, rows - 2),
|
|
col: 0,
|
|
text: st
|
|
? st()
|
|
.foreground('brightwhite')
|
|
.background('blue')
|
|
.width(cols)
|
|
.render(msg)
|
|
: msg
|
|
})
|
|
return list
|
|
}
|
|
if (this._promptKind()) {
|
|
const bar = this.promptTitle + this.promptBuf
|
|
list.push({
|
|
row: Math.max(0, rows - 2),
|
|
col: 0,
|
|
text: st
|
|
? st()
|
|
.foreground('brightwhite')
|
|
.background('blue')
|
|
.width(cols)
|
|
.render(bar)
|
|
: bar
|
|
})
|
|
return list
|
|
}
|
|
this._ensureScroll()
|
|
const cr = this.buf.row - this.scrollRow
|
|
const cc = this.buf.col - this.scrollCol
|
|
if (cr >= 0 && cr < rows - 2 && cc >= 0 && cc < cols) {
|
|
const line = this.buf.lines[this.buf.row] || ''
|
|
const ch = line.charAt(this.buf.col) || ' '
|
|
list.push({
|
|
row: cr,
|
|
col: cc,
|
|
text: st ? st().reverse().render(ch) : ch
|
|
})
|
|
}
|
|
return list
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {{ path: string, initialText: string, argv0?: string }} opts
|
|
*/
|
|
async function bareOsRunEditTui(ctx, opts) {
|
|
if (ctx.tui && typeof ctx.tui.run === 'function') {
|
|
await ctx.tui.run(bareEditCreateTuiApp(ctx, opts), bareEditTuiRunOpts(ctx))
|
|
return
|
|
}
|
|
await bareOsRunEditTuiLegacy(ctx, opts)
|
|
}
|
|
|
|
/**
|
|
* Pre-SDK key loop (BARE_OS_TUI=0).
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {{ path: string, initialText: string, argv0?: string }} opts
|
|
*/
|
|
async function bareOsRunEditTuiLegacy(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()
|
|
}
|
|
}
|
|
}
|
|
|
|
async function run(ctx, argv) {
|
|
const args = argv.slice(1)
|
|
if (args.includes('--version') || args.includes('-V')) {
|
|
const name = argv[0] || 'edit'
|
|
ctx.console.log(`${name} (Bare OS editor) 0.1`)
|
|
return
|
|
}
|
|
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'
|
|
})
|
|
}
|