Add edit program
This commit is contained in:
@@ -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 booter’s **`bare-os-theme-presets.js`** (see [docs/themes/README.md](../../docs/themes/README.md)).
|
||||
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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" }]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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')
|
||||
Reference in New Issue
Block a user