@@ -1,10 +1,501 @@
|
||||
/** Full-screen TUI for /bin/edit (stdin/key helpers in edit-stream-read.js; bareEditWrite in edit-ansi.js). */
|
||||
/** 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'
|
||||
@@ -63,10 +554,12 @@ async function bareOsRunEditTui(ctx, opts) {
|
||||
ctx.env && typeof ctx.env === 'object'
|
||||
? /** @type {Record<string, string>} */ (ctx.env)
|
||||
: {}
|
||||
const cols = /** @type {{ columns?: number }} */ (stdout).columns ||
|
||||
const cols =
|
||||
/** @type {{ columns?: number }} */ (stdout).columns ||
|
||||
parseInt(env.COLUMNS || '80', 10) ||
|
||||
80
|
||||
const rows = /** @type {{ rows?: number }} */ (stdout).rows ||
|
||||
const rows =
|
||||
/** @type {{ rows?: number }} */ (stdout).rows ||
|
||||
parseInt(env.LINES || '24', 10) ||
|
||||
24
|
||||
return { cols: Math.max(40, cols), rows: Math.max(8, rows) }
|
||||
@@ -116,13 +609,23 @@ async function bareOsRunEditTui(ctx, opts) {
|
||||
return
|
||||
}
|
||||
|
||||
if (mode === 'prompt_search' || mode === 'prompt_goto' || mode === 'prompt_saveas') {
|
||||
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)
|
||||
const vis = bareEditPaintLineWindow(
|
||||
line,
|
||||
spans,
|
||||
scrollCol,
|
||||
cols,
|
||||
useColor
|
||||
)
|
||||
out += bareEditCup(1 + r, 1) + '\x1b[K' + vis
|
||||
}
|
||||
const barBody =
|
||||
@@ -142,7 +645,13 @@ async function bareOsRunEditTui(ctx, opts) {
|
||||
const lineIndex = scrollRow + r
|
||||
const line = buf.lines[lineIndex] || ''
|
||||
const spans = bareEditHighlightLine(line, lang)
|
||||
const vis = bareEditPaintLineWindow(line, spans, scrollCol, cols, useColor)
|
||||
const vis = bareEditPaintLineWindow(
|
||||
line,
|
||||
spans,
|
||||
scrollCol,
|
||||
cols,
|
||||
useColor
|
||||
)
|
||||
out += bareEditCup(1 + r, 1) + '\x1b[K' + vis
|
||||
}
|
||||
|
||||
@@ -306,7 +815,8 @@ async function bareOsRunEditTui(ctx, opts) {
|
||||
continue
|
||||
}
|
||||
if (ev.type === 'key' && ev.ch && ev.ch !== '\n' && ev.ch !== '\t') {
|
||||
if (ev.ch >= ' ' && promptBuf.length < maxPromptLen) promptBuf += ev.ch
|
||||
if (ev.ch >= ' ' && promptBuf.length < maxPromptLen)
|
||||
promptBuf += ev.ch
|
||||
draw()
|
||||
continue
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user