Add edit program

This commit is contained in:
Raven Scott
2026-04-03 21:59:05 -04:00
parent 57b1296981
commit 32d3eaa833
40 changed files with 7211 additions and 412 deletions
+3 -1
View File
@@ -33,7 +33,9 @@ Or `node packages/bare-os-coreutils/build.mjs`.
**Source of truth:** **`lib/commands.mjs`** — **`COREUTILS_COMMANDS`** (imported by **`build.mjs`** and **`scripts/build-man-db.mjs`**). Each name must have **`man/pages/<name>.json`**.
`awk`, `basename`, `cat`, `chgrp`, `chmod`, `chown`, `cksum`, `clear`, `cp`, `crontab`, `cut`, `date`, `dirname`, `dircolors`, `du`, `echo`, `env`, `exit`, `false`, `find`, `getconf`, `git-pear`, `grep`, `head`, `hdms`, `help`, `hostname`, `id`, `jq`, `ln`, `login`, `logout`, `logname`, `ls`, `man`, `mkdir`, `mkfifo`, `mktemp`, `mv`, `nl`, `od`, `pathchk`, `printenv`, `printf`, `pwd`, `readlink`, `rm`, `rmdir`, `savevault`, `sed`, `seq`, `sleep`, `sort`, `stat`, `tail`, `tee`, `test`, `theme`, `time`, `touch`, `tr`, `true`, `tty`, `uname`, `wc`, `which`, `whoami`, `xargs`
`awk`, `basename`, `cat`, `chgrp`, `chmod`, `chown`, `cksum`, `clear`, `cp`, `crontab`, `cut`, `date`, `dirname`, `dircolors`, `du`, `edit`, `echo`, `env`, `exit`, `false`, `find`, `getconf`, `git-pear`, `grep`, `head`, `hdms`, `help`, `hostname`, `id`, `jq`, `ln`, `login`, `logout`, `logname`, `ls`, `man`, `mkdir`, `mkfifo`, `mktemp`, `mv`, `nano`, `nl`, `od`, `pathchk`, `printenv`, `printf`, `pwd`, `readlink`, `rm`, `rmdir`, `savevault`, `sed`, `seq`, `sleep`, `sort`, `stat`, `tail`, `tee`, `test`, `theme`, `time`, `touch`, `tr`, `true`, `tty`, `uname`, `wc`, `which`, `whoami`, `xargs`
**`edit`** is a full-screen TTY buffer editor (syntax highlighting, search, save). **`nano`** is built from the same **`src/edit.js`** with the same **`lib/edit-*.js`** preamble; **`/bin/nano`** exists for familiarity, and the stock shell alias **`nano``edit`** routes **`nano`** to that utility (see **`packages/bare-os-booter/lib/shell.js`**). Both require a real TTY (**`stdout.isTTY`**).
**`ls`** prepends **[`bare-os-lscolors`](../bare-os-lscolors/bare-os-lscolors.js)** for **`LS_COLORS`** / dircolors parsing. **`dircolors`** and **`theme`** integrate with the booters **`bare-os-theme-presets.js`** (see [docs/themes/README.md](../../docs/themes/README.md)).
+24 -3
View File
@@ -17,7 +17,27 @@ const preamble = {
jq: ['jq-engine.js'],
man: ['man-render.js'],
ls: ['bare-os-lscolors.js', 'ls-colors.js'],
dircolors: ['bare-os-lscolors.js']
dircolors: ['bare-os-lscolors.js'],
edit: [
'edit-ansi.js',
'edit-highlight.js',
'edit-buffer.js',
'edit-key-parse.js',
'edit-tui.js'
],
nano: [
'edit-ansi.js',
'edit-highlight.js',
'edit-buffer.js',
'edit-key-parse.js',
'edit-tui.js'
]
}
/** @param {string} binName */
function commandSourceFile(binName) {
if (binName === 'nano') return 'edit'
return binName
}
const commands = COREUTILS_COMMANDS
@@ -36,7 +56,7 @@ export async function build() {
for (const name of commands) {
let pre = ''
const extras = preamble[name]
const extras = preamble[name] || preamble[commandSourceFile(name)]
if (extras) {
for (const f of extras) {
const chunkPath =
@@ -48,7 +68,8 @@ export async function build() {
pre += chunk + '\n'
}
}
const body = await readFile(join(__dirname, 'src', `${name}.js`), 'utf8')
const srcName = commandSourceFile(name)
const body = await readFile(join(__dirname, 'src', `${srcName}.js`), 'utf8')
const out = runtime + '\n' + pre + body
await writeFile(join(kernelBin, name), out)
await writeFile(join(seederKernelBin, name), out)
@@ -18,6 +18,7 @@ export const COREUTILS_COMMANDS = [
'dirname',
'dircolors',
'du',
'edit',
'echo',
'env',
'exit',
@@ -42,6 +43,7 @@ export const COREUTILS_COMMANDS = [
'mkfifo',
'mktemp',
'mv',
'nano',
'nl',
'od',
'pathchk',
@@ -0,0 +1,98 @@
/** 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'
}
@@ -0,0 +1,235 @@
/** Text buffer + cursor + undo for /bin/edit. */
const EDIT_UNDO_MAX = 200
/**
* @param {string} text
*/
function bareEditCreateBuffer(text) {
const lines =
text === '' ? [''] : String(text).replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n')
return {
lines,
row: 0,
col: 0,
dirty: false,
/** @type {{ lines: string[], row: number, col: number }[]} */
undo: [],
/** @type {{ lines: string[], row: number, col: number }[]} */
redo: []
}
}
/**
* @param {{ lines: string[], row: number, col: number, dirty: boolean, undo: unknown[], redo: unknown[] }} buf
*/
function bareEditSnapshot(buf) {
return {
lines: buf.lines.slice(),
row: buf.row,
col: buf.col
}
}
/**
* @param {{ lines: string[], row: number, col: number, dirty: boolean, undo: unknown[], redo: unknown[] }} buf
*/
function bareEditPushUndo(buf) {
buf.undo.push(bareEditSnapshot(buf))
if (buf.undo.length > EDIT_UNDO_MAX) buf.undo.shift()
buf.redo = []
}
/**
* @param {{ lines: string[], row: number, col: number, dirty: boolean, undo: unknown[], redo: unknown[] }} buf
*/
function bareEditUndo(buf) {
const prev = buf.undo.pop()
if (!prev) return false
buf.redo.push(bareEditSnapshot(buf))
buf.lines = /** @type {{ lines: string[] }} */ (prev).lines.slice()
buf.row = prev.row
buf.col = prev.col
buf.dirty = true
return true
}
/**
* @param {{ lines: string[], row: number, col: number, dirty: boolean, undo: unknown[], redo: unknown[] }} buf
*/
function bareEditRedo(buf) {
const next = buf.redo.pop()
if (!next) return false
buf.undo.push(bareEditSnapshot(buf))
buf.lines = /** @type {{ lines: string[] }} */ (next).lines.slice()
buf.row = next.row
buf.col = next.col
buf.dirty = true
return true
}
/**
* @param {{ lines: string[], row: number, col: number, dirty: boolean, undo: unknown[], redo: unknown[] }} buf
*/
function bareEditClampCursor(buf) {
if (buf.row < 0) buf.row = 0
if (buf.row >= buf.lines.length) buf.row = buf.lines.length - 1
const line = buf.lines[buf.row] || ''
if (buf.col < 0) buf.col = 0
if (buf.col > line.length) buf.col = line.length
}
/**
* @param {{ lines: string[], row: number, col: number, dirty: boolean, undo: unknown[], redo: unknown[] }} buf
* @param {string} ch single code unit (MVP)
*/
function bareEditInsertChar(buf, ch) {
bareEditPushUndo(buf)
const line = buf.lines[buf.row]
buf.lines[buf.row] = line.slice(0, buf.col) + ch + line.slice(buf.col)
buf.col += ch.length
buf.dirty = true
}
/**
* @param {{ lines: string[], row: number, col: number, dirty: boolean, undo: unknown[], redo: unknown[] }} buf
*/
function bareEditNewline(buf) {
bareEditPushUndo(buf)
const line = buf.lines[buf.row]
const rest = line.slice(buf.col)
buf.lines[buf.row] = line.slice(0, buf.col)
buf.lines.splice(buf.row + 1, 0, rest)
buf.row++
buf.col = 0
buf.dirty = true
}
/**
* @param {{ lines: string[], row: number, col: number, dirty: boolean, undo: unknown[], redo: unknown[] }} buf
*/
function bareEditBackspace(buf) {
if (buf.col > 0) {
bareEditPushUndo(buf)
const line = buf.lines[buf.row]
buf.lines[buf.row] = line.slice(0, buf.col - 1) + line.slice(buf.col)
buf.col--
buf.dirty = true
return
}
if (buf.row > 0) {
bareEditPushUndo(buf)
const prevLen = buf.lines[buf.row - 1].length
buf.lines[buf.row - 1] += buf.lines[buf.row]
buf.lines.splice(buf.row, 1)
buf.row--
buf.col = prevLen
buf.dirty = true
}
}
/**
* @param {{ lines: string[], row: number, col: number, dirty: boolean, undo: unknown[], redo: unknown[] }} buf
*/
function bareEditDelete(buf) {
const line = buf.lines[buf.row]
if (buf.col < line.length) {
bareEditPushUndo(buf)
buf.lines[buf.row] = line.slice(0, buf.col) + line.slice(buf.col + 1)
buf.dirty = true
return
}
if (buf.row < buf.lines.length - 1) {
bareEditPushUndo(buf)
buf.lines[buf.row] += buf.lines[buf.row + 1]
buf.lines.splice(buf.row + 1, 1)
buf.dirty = true
}
}
/**
* @param {{ lines: string[], row: number, col: number, dirty: boolean, undo: unknown[], redo: unknown[] }} buf
* @param {'home'|'end'|'up'|'down'|'left'|'right'} key
*/
function bareEditMoveKey(buf, key) {
if (key === 'home') {
buf.col = 0
return
}
if (key === 'end') {
buf.col = buf.lines[buf.row].length
return
}
if (key === 'up') {
if (buf.row > 0) {
buf.row--
buf.col = Math.min(buf.col, buf.lines[buf.row].length)
}
return
}
if (key === 'down') {
if (buf.row < buf.lines.length - 1) {
buf.row++
buf.col = Math.min(buf.col, buf.lines[buf.row].length)
}
return
}
if (key === 'left') {
if (buf.col > 0) buf.col--
else if (buf.row > 0) {
buf.row--
buf.col = buf.lines[buf.row].length
}
return
}
if (key === 'right') {
const line = buf.lines[buf.row]
if (buf.col < line.length) buf.col++
else if (buf.row < buf.lines.length - 1) {
buf.row++
buf.col = 0
}
}
}
/**
* @param {{ lines: string[], row: number, col: number, dirty: boolean, undo: unknown[], redo: unknown[] }} buf
*/
function bareEditJoinAll(buf) {
return buf.lines.join('\n')
}
/**
* @param {{ lines: string[], row: number, col: number, dirty: boolean, undo: unknown[], redo: unknown[] }} buf
* @param {string} needle
* @param {number} fromRow
* @param {number} fromCol
* @returns {{ row: number, col: number } | null}
*/
function bareEditFindNext(buf, needle, fromRow, fromCol) {
if (!needle) return null
for (let r = fromRow; r < buf.lines.length; r++) {
const line = buf.lines[r] || ''
const start = r === fromRow ? fromCol : 0
const idx = line.indexOf(needle, start)
if (idx >= 0) return { row: r, col: idx }
}
for (let r = 0; r < fromRow; r++) {
const idx = (buf.lines[r] || '').indexOf(needle)
if (idx >= 0) return { row: r, col: idx }
}
const line = buf.lines[fromRow] || ''
const idx = line.indexOf(needle, 0)
if (idx >= 0 && idx < fromCol) return { row: fromRow, col: idx }
return null
}
/**
* @param {{ lines: string[], row: number, col: number }} buf
* @param {number} targetRow 1-based
*/
function bareEditGotoLine(buf, targetRow) {
const r = Math.max(1, Math.min(buf.lines.length, targetRow)) - 1
buf.row = r
buf.col = Math.min(buf.col, buf.lines[r].length)
}
@@ -0,0 +1,286 @@
/** 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 []
}
}
@@ -0,0 +1,121 @@
/** TTY key parsing for /bin/edit: consume one logical key from a mutable byte queue. */
/**
* @param {number} b1
*/
function bareEditUtf8TrailCount(b1) {
if (b1 >= 0xc0 && b1 < 0xe0) return 1
if (b1 >= 0xe0 && b1 < 0xf0) return 2
if (b1 >= 0xf0) return 3
return 0
}
/**
* @param {number[]} bytes
*/
function bareEditUtf8DecodeKey(bytes) {
try {
const u = new Uint8Array(bytes)
if (typeof TextDecoder !== 'undefined') {
return new TextDecoder('utf-8', { fatal: false }).decode(u)
}
} catch {
/* fall through */
}
let s = ''
for (const b of bytes) s += String.fromCharCode(b)
return s
}
/**
* @param {string} seq CSI payload after ESC [, including final byte (e.g. "A", "1;5A", "3~")
*/
function bareEditCsiToEvent(seq) {
if (seq === '3~') return { type: 'ctrl', code: 'delete' }
if (seq === '5~') return { type: 'nav', key: 'pageup' }
if (seq === '6~') return { type: 'nav', key: 'pagedown' }
const last = seq.charAt(seq.length - 1)
if (last === '~') {
if (seq === '1~' || seq === '7~') return { type: 'nav', key: 'home' }
if (seq === '4~' || seq === '8~') return { type: 'nav', key: 'end' }
return { type: 'unknown' }
}
if (last === 'A' || last === 'B' || last === 'C' || last === 'D') {
const map = { A: 'up', B: 'down', C: 'right', D: 'left' }
return { type: 'nav', key: map[last] }
}
if (last === 'H') return { type: 'nav', key: 'home' }
if (last === 'F') return { type: 'nav', key: 'end' }
return { type: 'unknown' }
}
/**
* @param {number} b3 byte after ESC O
*/
function bareEditSs3ToEvent(b3) {
if (b3 === 72) return { type: 'nav', key: 'home' }
if (b3 === 70) return { type: 'nav', key: 'end' }
if (b3 === 65) return { type: 'nav', key: 'up' }
if (b3 === 66) return { type: 'nav', key: 'down' }
if (b3 === 67) return { type: 'nav', key: 'right' }
if (b3 === 68) return { type: 'nav', key: 'left' }
return { type: 'unknown' }
}
/**
* @param {number[]} q mutable queue (front = index 0)
* @returns {Record<string, unknown> | null} null if more bytes needed
*/
function bareEditTryConsumeKey(q) {
if (!q.length) return null
const b1 = q[0]
if (b1 === 3) {
q.shift()
return { type: 'ctrl', code: 'interrupt' }
}
if (b1 === 8 || b1 === 127) {
q.shift()
return { type: 'ctrl', code: 'backspace' }
}
if (b1 === 13 || b1 === 10) {
q.shift()
return { type: 'key', ch: '\n' }
}
if (b1 === 9) {
q.shift()
return { type: 'key', ch: '\t' }
}
if (b1 === 27) {
if (q.length < 2) return null
const b2 = q[1]
if (b2 === 91) {
let i = 2
while (i < q.length) {
const b = q[i]
if (b >= 0x40 && b <= 0x7e) {
const seq = String.fromCharCode.apply(null, q.slice(2, i + 1))
q.splice(0, i + 1)
return bareEditCsiToEvent(seq)
}
i++
}
return null
}
if (b2 === 79) {
if (q.length < 3) return null
const b3 = q[2]
q.splice(0, 3)
return bareEditSs3ToEvent(b3)
}
q.splice(0, 2)
return { type: 'key', ch: String.fromCharCode(b2) }
}
if (b1 < 0x20) {
q.shift()
return { type: 'ctrl', code: b1 }
}
const need = bareEditUtf8TrailCount(b1)
if (q.length < 1 + need) return null
const chunk = q.splice(0, 1 + need)
return { type: 'key', ch: bareEditUtf8DecodeKey(chunk) }
}
+466
View File
@@ -0,0 +1,466 @@
/** Full-screen TUI for /bin/edit (raw TTY, fish suspend/resume). */
/**
* @param {unknown} chunk
* @returns {number[]}
*/
function bareEditChunkBytes(chunk) {
if (chunk == null) return []
if (typeof chunk === 'string') {
const out = []
for (let i = 0; i < chunk.length; i++) out.push(chunk.charCodeAt(i) & 0xff)
return out
}
const len = /** @type {{ length: number, [k: number]: number }} */ (chunk).length
const out = []
for (let i = 0; i < len; i++) out.push(Number(chunk[i]) & 0xff)
return out
}
/**
* @param {{ nextByte: () => Promise<number|undefined>, dispose?: () => void, _keyq?: number[] }} reader
*/
async function bareEditReadKey(reader) {
reader._keyq = reader._keyq || []
const q = reader._keyq
for (;;) {
const ev = bareEditTryConsumeKey(q)
if (ev) return ev
const b = await reader.nextByte()
if (b === undefined) {
if (!q.length) return { type: 'eof' }
if (q.length === 1 && q[0] === 27) {
q.length = 0
return { type: 'key', ch: '\x1b' }
}
const lone = q.shift()
if (lone !== undefined && lone < 0x20) {
return { type: 'ctrl', code: lone }
}
if (lone !== undefined) {
return { type: 'key', ch: String.fromCharCode(lone) }
}
return { type: 'eof' }
}
q.push(b)
}
}
/**
* @param {import('stream').Readable} stdin
*/
function bareEditCreateStdinReader(stdin) {
/** @type {number[]} */
const bytes = []
/** @type {(() => void)[]} */
const waiters = []
function drain() {
while (waiters.length && bytes.length) {
const w = waiters.shift()
if (w) w()
}
}
/** @param {unknown} chunk */
function onData(chunk) {
bytes.push(...bareEditChunkBytes(chunk))
drain()
}
stdin.on('data', onData)
return {
nextByte() {
if (bytes.length) return Promise.resolve(bytes.shift())
return new Promise((resolve) => {
waiters.push(() => resolve(bytes.shift()))
})
},
dispose() {
stdin.removeListener('data', onData)
}
}
}
/**
* @param {Record<string, unknown>} ctx
* @param {import('stream').Writable} stdout
* @param {string} s
*/
function bareEditWrite(ctx, stdout, s) {
if (!stdout || typeof stdout.write !== 'function') return
try {
stdout.write(s)
} catch {
try {
ctx.console?.error?.('edit: stdout write failed')
} catch {
/* ignore */
}
}
}
/**
* @param {Record<string, unknown>} ctx
* @param {{ path: string, initialText: string, argv0?: string }} opts
*/
async function bareOsRunEditTui(ctx, opts) {
const vfs = ctx.vfs
const b4a = ctx.b4a
const path = opts.path || 'Untitled'
const display = opts.argv0 || 'edit'
const lang = bareEditDetectLang(path)
const buf = bareEditCreateBuffer(opts.initialText)
const useColor = bareEditUseColor(ctx)
const stdin = /** @type {import('stream').Readable | undefined} */ (
ctx.replStdin
)
const stdout = bareEditResolveStdout(ctx)
if (!stdin || !stdout) {
ctx.console.error('edit: missing stdin/stdout')
ctx.exitCode = 1
return
}
let scrollRow = 0
let scrollCol = 0
let savePath = path
/** @type {'edit'|'quit_confirm'|'help'|'prompt_search'|'prompt_goto'|'prompt_saveas'} */
let mode = 'edit'
let promptBuf = ''
let promptTitle = ''
let lastSearch = ''
function termDims() {
const env =
ctx.env && typeof ctx.env === 'object'
? /** @type {Record<string, string>} */ (ctx.env)
: {}
const cols = /** @type {{ columns?: number }} */ (stdout).columns ||
parseInt(env.COLUMNS || '80', 10) ||
80
const rows = /** @type {{ rows?: number }} */ (stdout).rows ||
parseInt(env.LINES || '24', 10) ||
24
return { cols: Math.max(40, cols), rows: Math.max(8, rows) }
}
function ensureScroll() {
const { cols, rows } = termDims()
const contentH = rows - 2
if (buf.row < scrollRow) scrollRow = buf.row
if (buf.row >= scrollRow + contentH) scrollRow = buf.row - contentH + 1
if (scrollRow < 0) scrollRow = 0
if (scrollCol > buf.col) scrollCol = buf.col
if (buf.col - scrollCol >= cols) scrollCol = buf.col - cols + 1
if (scrollCol < 0) scrollCol = 0
}
function draw() {
const { cols, rows } = termDims()
const contentH = Math.max(1, rows - 2)
ensureScroll()
let out = ''
out += '\x1b[?25l\x1b[2J\x1b[H'
if (mode === 'help') {
out +=
'Bare OS edit — help\r\n\r\n' +
'^O ^S Save ^X Exit\r\n' +
'^W Search ^_ Go to line\r\n' +
'^Z Undo ^Y Redo\r\n' +
'Arrows move cursor; Home/End; Backspace/Del\r\n\r\n' +
'Press any key to return.\r\n'
out += bareEditCup(8, 1) + '\x1b[?25h'
bareEditWrite(ctx, stdout, out)
return
}
if (mode === 'quit_confirm') {
for (let row = 1; row <= contentH; row++) {
out += bareEditCup(row, 1) + '\x1b[K'
}
const msg = 'Save modified buffer? Y Yes N No ^C Cancel'
const st = bareEditSgr('status', useColor) + msg + EDIT_ANSI_RESET
out += bareEditCup(contentH + 1, 1) + '\x1b[K' + st
out += bareEditCup(contentH + 1, 1) + '\x1b[?25h'
bareEditWrite(ctx, stdout, out)
return
}
if (mode === 'prompt_search' || mode === 'prompt_goto' || mode === 'prompt_saveas') {
const editorLines = Math.max(0, contentH - 1)
for (let r = 0; r < editorLines; r++) {
const lineIndex = scrollRow + r
const line = buf.lines[lineIndex] || ''
const spans = bareEditHighlightLine(line, lang)
const vis = bareEditPaintLineWindow(line, spans, scrollCol, cols, useColor)
out += bareEditCup(1 + r, 1) + '\x1b[K' + vis
}
const barBody =
bareEditSgr('status', useColor) +
promptTitle +
promptBuf +
(useColor ? '\x1b[0m' : '')
const promptRow = editorLines + 1
out += bareEditCup(promptRow, 1) + '\x1b[K' + barBody
const promptCol = 1 + promptTitle.length + promptBuf.length
out += bareEditCup(promptRow, promptCol) + '\x1b[?25h'
bareEditWrite(ctx, stdout, out)
return
}
for (let r = 0; r < contentH; r++) {
const lineIndex = scrollRow + r
const line = buf.lines[lineIndex] || ''
const spans = bareEditHighlightLine(line, lang)
const vis = bareEditPaintLineWindow(line, spans, scrollCol, cols, useColor)
out += bareEditCup(1 + r, 1) + '\x1b[K' + vis
}
const dirtyMark = buf.dirty ? ' [Modified]' : ''
const statusLine =
(useColor ? bareEditSgr('status', true) : '') +
display +
' ' +
savePath +
dirtyMark +
' ' +
(buf.row + 1) +
',' +
(buf.col + 1) +
(useColor ? EDIT_ANSI_RESET : '')
const helpLine =
(useColor ? bareEditSgr('dim', true) : '') +
'^G Help ^O/^S Save ^X Exit ^W Find ^_ Line ^Z Undo ^Y Redo' +
(useColor ? EDIT_ANSI_RESET : '')
out += bareEditCup(contentH + 1, 1) + '\x1b[K' + statusLine
out += bareEditCup(contentH + 2, 1) + '\x1b[K' + helpLine
const curRow = 1 + (buf.row - scrollRow)
const curCol = 1 + (buf.col - scrollCol)
const tr = Math.min(Math.max(1, curRow), contentH)
const tc = Math.min(Math.max(1, curCol), cols)
out += bareEditCup(tr, tc) + '\x1b[?25h'
bareEditWrite(ctx, stdout, out)
}
async function doSave(targetPath) {
const text = bareEditJoinAll(buf)
try {
await vfs.writeFile(targetPath, b4a.from(text))
buf.dirty = false
savePath = targetPath
return true
} catch (e) {
ctx.console.error('edit: ' + ((e && e.message) || String(e)))
return false
}
}
const reader = bareEditCreateStdinReader(stdin)
let suspended = false
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()
draw()
for (;;) {
const ev = await bareEditReadKey(reader)
if (ev.type === 'eof') break
if (mode === 'help') {
mode = 'edit'
draw()
continue
}
if (mode === 'quit_confirm') {
if (ev.type === 'ctrl' && ev.code === 'interrupt') {
mode = 'edit'
draw()
continue
}
if (ev.type === 'key' && ev.ch) {
const u = ev.ch.toUpperCase()
if (u === 'Y') {
await doSave(savePath)
break
}
if (u === 'N') break
}
continue
}
if (
mode === 'prompt_search' ||
mode === 'prompt_goto' ||
mode === 'prompt_saveas'
) {
if (ev.type === 'ctrl' && ev.code === 'interrupt') {
mode = 'edit'
draw()
continue
}
if (ev.type === 'key' && ev.ch === '\n') {
if (mode === 'prompt_search') {
lastSearch = promptBuf
if (lastSearch) {
const hit = bareEditFindNext(
buf,
lastSearch,
buf.row,
buf.col + 1
)
if (!hit) {
const hit2 = bareEditFindNext(buf, lastSearch, 0, 0)
if (hit2) {
buf.row = hit2.row
buf.col = hit2.col
}
} else {
buf.row = hit.row
buf.col = hit.col
}
}
} else if (mode === 'prompt_goto') {
const n = parseInt(promptBuf.trim(), 10)
if (Number.isFinite(n)) bareEditGotoLine(buf, n)
} else if (mode === 'prompt_saveas') {
const p = promptBuf.trim()
if (p) {
savePath = p
await doSave(savePath)
}
}
mode = 'edit'
draw()
continue
}
if (ev.type === 'ctrl' && ev.code === 'backspace') {
promptBuf = promptBuf.slice(0, -1)
draw()
continue
}
if (ev.type === 'key' && ev.ch && ev.ch !== '\n' && ev.ch !== '\t') {
if (ev.ch >= ' ') promptBuf += ev.ch
draw()
continue
}
continue
}
if (ev.type === 'nav') {
if (ev.key === 'pageup') {
const { rows } = termDims()
const step = Math.max(1, rows - 4)
buf.row = Math.max(0, buf.row - step)
bareEditClampCursor(buf)
} else if (ev.key === 'pagedown') {
const { rows } = termDims()
const step = Math.max(1, rows - 4)
buf.row = Math.min(buf.lines.length - 1, buf.row + step)
bareEditClampCursor(buf)
} else bareEditMoveKey(buf, ev.key)
draw()
continue
}
if (ev.type === 'ctrl') {
if (ev.code === 'backspace') {
bareEditBackspace(buf)
draw()
continue
}
if (ev.code === 'delete') {
bareEditDelete(buf)
draw()
continue
}
if (ev.code === 'interrupt') {
/* ignore in edit */
continue
}
const code = typeof ev.code === 'number' ? ev.code : 0
if (code === 27) {
/* lone ESC fragment; do not insert */
continue
}
if (code === 24) {
if (buf.dirty) {
mode = 'quit_confirm'
draw()
} else break
continue
}
if (code === 19) {
await doSave(savePath)
draw()
continue
}
if (code === 15) {
mode = 'prompt_saveas'
promptTitle = 'File: '
promptBuf = savePath
draw()
continue
}
if (code === 23) {
mode = 'prompt_search'
promptTitle = 'Search: '
promptBuf = lastSearch
draw()
continue
}
if (code === 7) {
mode = 'help'
draw()
continue
}
if (code === 31) {
mode = 'prompt_goto'
promptTitle = 'Go to line: '
promptBuf = ''
draw()
continue
}
if (code === 26) {
bareEditUndo(buf)
draw()
continue
}
if (code === 25) {
bareEditRedo(buf)
draw()
continue
}
continue
}
if (ev.type === 'key' && ev.ch) {
if (ev.ch === '\n') bareEditNewline(buf)
else if (ev.ch === '\t') bareEditInsertChar(buf, ' ')
else bareEditInsertChar(buf, ev.ch)
draw()
}
}
} finally {
try {
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()
}
}
}
@@ -0,0 +1,19 @@
{
"name": "edit",
"section": 1,
"title": "terminal file editor with syntax highlighting",
"synopsis": ["edit [file]", "edit -h|--help"],
"description": "Full-screen TUI editor (nano-style) for the Bare OS shell. Uses the session TTY (ctx.replStdin / replStdout), suspends the fish readline layer while active, and reads/writes paths via the VFS. Syntax highlighting is best-effort for JavaScript/TypeScript, JSON, shell, Markdown, and plain text. Files larger than 2,000,000 characters are rejected. The shell alias nano invokes edit.",
"options": [
{
"flag": "-h, --help",
"meaning": "Print usage and exit."
}
],
"keywords": ["edit", "nano", "editor", "tty", "syntax"],
"bareOsNotes": "Requires an interactive TTY (stdin.isTTY). Uses ctx.suspendReplForSubprocess / resumeReplAfterSubprocess around raw mode.",
"examples": [
{ "caption": "edit a file", "code": "edit README.md" },
{ "caption": "same via alias", "code": "nano foo.js" }
]
}
@@ -0,0 +1,11 @@
{
"name": "nano",
"section": 1,
"title": "alias for edit — terminal file editor",
"synopsis": ["nano [file]"],
"description": "The /bin/nano script is identical to edit. In the default shell, the name nano is also an alias for edit. See edit(1) for behavior, key bindings, and requirements.",
"options": [],
"keywords": ["nano", "edit", "editor"],
"bareOsNotes": "Same implementation as edit; see man edit.",
"examples": [{ "caption": "open a file", "code": "nano ~/.barerc" }]
}
+2 -1
View File
@@ -5,6 +5,7 @@
"type": "module",
"description": "Build JS /bin utilities for bare-operating-system (concat + stage to kernel/)",
"scripts": {
"build": "node ./build.mjs"
"build": "node ./build.mjs",
"test": "node ./test/edit-key-parse.test.mjs"
}
}
+50
View File
@@ -0,0 +1,50 @@
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'
})
}
+1 -1
View File
@@ -1,6 +1,6 @@
async function run(ctx, argv) {
ctx.console.log(
'Bare OS — default user: guest | shell builtins: alias, barerc, cd, command, export, exit, login, logout, readonly, type, umask, unalias, unset, : | /bin: awk basename cat chgrp chmod chown cksum clear cp crontab curl cut date dirname dircolors du echo env exit false find getconf grep head hdms help hostname id journalctl jq ln login logout logname ls man mkdir mkfifo mv nl od pathchk printenv printf pwd readlink rm rmdir savevault sed seq sleep sort stat systemctl tail tee test theme time touch tr true tty uname wc wget which whoami xargs'
'Bare OS — default user: guest | shell builtins: alias, barerc, cd, command, export, exit, login, logout, readonly, type, umask, unalias, unset, : | /bin: awk basename cat chgrp chmod chown cksum clear cp crontab curl cut date dirname dircolors du edit echo env exit false find getconf grep head hdms help hostname id journalctl jq ln login logout logname ls man mkdir mkfifo mktemp nano mv nl od pathchk printenv printf pwd readlink rm rmdir savevault sed seq sleep sort stat systemctl tail tee test theme time touch tr true tty uname wc wget which whoami xargs'
)
ctx.console.log(
'Docs: man <command> | man handbook (full handbook, section 7) | man bare-os-shell | man -k <word> | man -l'
@@ -0,0 +1,65 @@
/**
* Loads lib/edit-key-parse.js in an isolated function scope (same source as /bin/edit preamble).
*/
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const parseSrc = readFileSync(join(__dirname, '../lib/edit-key-parse.js'), 'utf8')
const hook = {}
new Function(
'exports',
`${parseSrc}\nexports.bareEditTryConsumeKey = bareEditTryConsumeKey;`
)(hook)
const { bareEditTryConsumeKey } = hook
function consumeAll(bytes) {
const q = [...bytes]
const out = []
while (q.length) {
const ev = bareEditTryConsumeKey(q)
assert.ok(ev, 'expected complete key, queue=' + JSON.stringify(q))
out.push(ev)
}
return out
}
assert.equal(bareEditTryConsumeKey([97])?.type, 'key')
assert.equal(bareEditTryConsumeKey([97])?.ch, 'a')
const upCsi = consumeAll([27, 91, 65])
assert.deepEqual(upCsi, [{ type: 'nav', key: 'up' }])
const upCsiNum = consumeAll([27, 91, 49, 59, 53, 65])
assert.deepEqual(upCsiNum, [{ type: 'nav', key: 'up' }])
const leftCsi = consumeAll([27, 91, 68])
assert.deepEqual(leftCsi, [{ type: 'nav', key: 'left' }])
const ss3Up = consumeAll([27, 79, 65])
assert.deepEqual(ss3Up, [{ type: 'nav', key: 'up' }])
const ss3Left = consumeAll([27, 79, 68])
assert.deepEqual(ss3Left, [{ type: 'nav', key: 'left' }])
const del = consumeAll([27, 91, 51, 126])
assert.deepEqual(del, [{ type: 'ctrl', code: 'delete' }])
/* ESC must not be consumed as generic ctrl before CSI: order test */
const q = [27, 91, 66]
const ev1 = bareEditTryConsumeKey(q)
assert.deepEqual(ev1, { type: 'nav', key: 'down' })
assert.equal(q.length, 0)
/* Incomplete CSI waits for more bytes */
const q2 = [27, 91]
assert.equal(bareEditTryConsumeKey(q2), null)
assert.deepEqual(q2, [27, 91])
q2.push(67)
const ev2 = bareEditTryConsumeKey(q2)
assert.deepEqual(ev2, { type: 'nav', key: 'right' })
console.log('edit-key-parse tests ok')