951 lines
28 KiB
JavaScript
951 lines
28 KiB
JavaScript
/** Full-screen TUI for /bin/edit (TEA via ctx.tui; pre-SDK loop in Legacy). */
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} ctx
|
|
*/
|
|
function bareEditTuiRunOpts(ctx) {
|
|
const env =
|
|
ctx.env && typeof ctx.env === 'object'
|
|
? /** @type {Record<string, string>} */ (ctx.env)
|
|
: {}
|
|
/** @type {{ altScreen?: boolean, bracketedPaste?: boolean, buffer: 'cell' }} */
|
|
const opts = { buffer: 'cell' }
|
|
if (
|
|
(env.BARE_EDIT_NO_ALTSCREEN != null &&
|
|
String(env.BARE_EDIT_NO_ALTSCREEN) !== '') ||
|
|
(env.BARE_OS_TUI_NO_ALTSCREEN != null &&
|
|
String(env.BARE_OS_TUI_NO_ALTSCREEN) !== '')
|
|
) {
|
|
opts.altScreen = false
|
|
}
|
|
if (
|
|
env.BARE_EDIT_NO_BRACKETED_PASTE != null &&
|
|
String(env.BARE_EDIT_NO_BRACKETED_PASTE) !== ''
|
|
) {
|
|
opts.bracketedPaste = false
|
|
}
|
|
return opts
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} ctx
|
|
*/
|
|
function bareEditMaxPromptLen(ctx) {
|
|
const env =
|
|
ctx.env && typeof ctx.env === 'object'
|
|
? /** @type {Record<string, string>} */ (ctx.env)
|
|
: {}
|
|
let maxPromptLen = 4096
|
|
const maxPromptRaw = env.BARE_EDIT_MAX_PROMPT
|
|
if (maxPromptRaw != null && String(maxPromptRaw) !== '') {
|
|
const n = parseInt(String(maxPromptRaw), 10)
|
|
if (Number.isFinite(n) && n > 0) maxPromptLen = Math.min(n, 65536)
|
|
}
|
|
return maxPromptLen
|
|
}
|
|
|
|
/**
|
|
* @param {string} text
|
|
* @param {'saveas'|'search'|'goto'} kind
|
|
* @param {number} maxPromptLen
|
|
*/
|
|
function bareEditPromptLineFromPaste(text, kind, maxPromptLen) {
|
|
let line = String(text).split(/\r?\n/)[0] ?? ''
|
|
line = line.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, '')
|
|
if (line.length > maxPromptLen) line = line.slice(0, maxPromptLen)
|
|
if (kind === 'goto') {
|
|
const m = /^(\s*\d+)/.exec(line)
|
|
return m ? m[1].trim() : ''
|
|
}
|
|
return line
|
|
}
|
|
|
|
/**
|
|
* Ctrl+_ (ASCII US, 0x1f) — Go to line. Decoder names it as ctrl+\x7f.
|
|
* @param {unknown} msg
|
|
*/
|
|
function bareEditIsGotoChord(msg) {
|
|
if (!msg || /** @type {{ type?: string }} */ (msg).type !== 'key')
|
|
return false
|
|
const m =
|
|
/** @type {{ ctrl?: boolean, sequence?: string, name?: string }} */ (msg)
|
|
if (!m.ctrl) return false
|
|
return m.sequence === '\x1f' || m.name === '\x7f'
|
|
}
|
|
|
|
/**
|
|
* TEA model for /bin/edit. Cell buffer so help / prompts overlay without reflow.
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {{ path: string, initialText: string, argv0?: string }} opts
|
|
*/
|
|
function bareEditCreateTuiApp(ctx, opts) {
|
|
const tui = ctx.tui
|
|
const vfs = ctx.vfs
|
|
const b4a = ctx.b4a
|
|
const size0 = tui && typeof tui.size === 'function' ? tui.size() : {}
|
|
const useColor =
|
|
typeof bareEditUseColor === 'function' ? bareEditUseColor(ctx) : true
|
|
return {
|
|
path: opts.path || 'Untitled',
|
|
display: opts.argv0 || 'edit',
|
|
lang: bareEditDetectLang(opts.path || ''),
|
|
buf: bareEditCreateBuffer(opts.initialText),
|
|
scrollRow: 0,
|
|
scrollCol: 0,
|
|
savePath: opts.path || 'Untitled',
|
|
mode: /** @type {'edit'|'quit_confirm'|'help'|'prompt_search'|'prompt_goto'|'prompt_saveas'} */ (
|
|
'edit'
|
|
),
|
|
promptBuf: '',
|
|
promptTitle: '',
|
|
lastSearch: '',
|
|
maxPromptLen: bareEditMaxPromptLen(ctx),
|
|
pendingQuit: false,
|
|
statusMsg: '',
|
|
width: size0.width || 80,
|
|
height: size0.height || 24,
|
|
init: function () {
|
|
return null
|
|
},
|
|
_contentH: function () {
|
|
return Math.max(1, (this.height || 24) - 2)
|
|
},
|
|
_ensureScroll: function () {
|
|
const cols = Math.max(40, this.width || 80)
|
|
const contentH = this._contentH()
|
|
const buf = this.buf
|
|
if (buf.row < this.scrollRow) this.scrollRow = buf.row
|
|
if (buf.row >= this.scrollRow + contentH)
|
|
this.scrollRow = buf.row - contentH + 1
|
|
if (this.scrollRow < 0) this.scrollRow = 0
|
|
if (this.scrollCol > buf.col) this.scrollCol = buf.col
|
|
if (buf.col - this.scrollCol >= cols) this.scrollCol = buf.col - cols + 1
|
|
if (this.scrollCol < 0) this.scrollCol = 0
|
|
},
|
|
_save: function (targetPath) {
|
|
const self = this
|
|
return function () {
|
|
return Promise.resolve()
|
|
.then(function () {
|
|
const text = bareEditJoinAll(self.buf)
|
|
return vfs.writeFile(targetPath, b4a.from(text))
|
|
})
|
|
.then(function () {
|
|
return { type: 'edit.saved', ok: true, path: targetPath }
|
|
})
|
|
.catch(function (error) {
|
|
return { type: 'edit.saved', ok: false, error: error }
|
|
})
|
|
}
|
|
},
|
|
_applySearch: function () {
|
|
this.lastSearch = this.promptBuf
|
|
if (!this.lastSearch) return
|
|
const hit = bareEditFindNext(
|
|
this.buf,
|
|
this.lastSearch,
|
|
this.buf.row,
|
|
this.buf.col + 1
|
|
)
|
|
if (!hit) {
|
|
const hit2 = bareEditFindNext(this.buf, this.lastSearch, 0, 0)
|
|
if (hit2) {
|
|
this.buf.row = hit2.row
|
|
this.buf.col = hit2.col
|
|
}
|
|
} else {
|
|
this.buf.row = hit.row
|
|
this.buf.col = hit.col
|
|
}
|
|
},
|
|
_promptKind: function () {
|
|
if (this.mode === 'prompt_search') return 'search'
|
|
if (this.mode === 'prompt_goto') return 'goto'
|
|
if (this.mode === 'prompt_saveas') return 'saveas'
|
|
return ''
|
|
},
|
|
update: function (msg) {
|
|
if (msg && msg.type === 'resize') {
|
|
this.width = msg.width || this.width
|
|
this.height = msg.height || this.height
|
|
this._ensureScroll()
|
|
return [this, null]
|
|
}
|
|
if (msg && msg.type === 'edit.saved') {
|
|
if (msg.ok) {
|
|
this.buf.dirty = false
|
|
this.savePath = String(msg.path || this.savePath)
|
|
this.statusMsg = ''
|
|
if (this.pendingQuit) return [this, tui.quit]
|
|
} else {
|
|
const err = msg.error
|
|
this.statusMsg =
|
|
'edit: ' +
|
|
(err && err.message ? err.message : String(err || 'save failed'))
|
|
this.pendingQuit = false
|
|
}
|
|
return [this, null]
|
|
}
|
|
if (this.mode === 'help') {
|
|
if (msg && msg.type === 'key') this.mode = 'edit'
|
|
return [this, null]
|
|
}
|
|
if (this.mode === 'quit_confirm') {
|
|
if (tui.key.matches(msg, 'ctrl+c')) {
|
|
this.mode = 'edit'
|
|
return [this, null]
|
|
}
|
|
if (tui.key.matches(msg, 'y', 'Y')) {
|
|
this.pendingQuit = true
|
|
this.mode = 'edit'
|
|
return [this, this._save(this.savePath)]
|
|
}
|
|
if (tui.key.matches(msg, 'n', 'N')) return [this, tui.quit]
|
|
return [this, null]
|
|
}
|
|
if (this._promptKind()) {
|
|
if (tui.key.matches(msg, 'ctrl+c')) {
|
|
this.mode = 'edit'
|
|
return [this, null]
|
|
}
|
|
if (msg && msg.type === 'paste') {
|
|
this.promptBuf = bareEditPromptLineFromPaste(
|
|
String(msg.text || ''),
|
|
/** @type {'saveas'|'search'|'goto'} */ (this._promptKind()),
|
|
this.maxPromptLen
|
|
)
|
|
return [this, null]
|
|
}
|
|
if (tui.key.matches(msg, 'enter')) {
|
|
if (this.mode === 'prompt_search') this._applySearch()
|
|
else if (this.mode === 'prompt_goto') {
|
|
const n = parseInt(this.promptBuf.trim(), 10)
|
|
if (Number.isFinite(n)) bareEditGotoLine(this.buf, n)
|
|
} else if (this.mode === 'prompt_saveas') {
|
|
const p = this.promptBuf.trim()
|
|
this.mode = 'edit'
|
|
if (p) {
|
|
this.savePath = p
|
|
return [this, this._save(this.savePath)]
|
|
}
|
|
}
|
|
this.mode = 'edit'
|
|
return [this, null]
|
|
}
|
|
if (tui.key.matches(msg, 'backspace')) {
|
|
this.promptBuf = this.promptBuf.slice(0, -1)
|
|
return [this, null]
|
|
}
|
|
if (
|
|
msg &&
|
|
msg.type === 'key' &&
|
|
!msg.ctrl &&
|
|
!msg.meta &&
|
|
typeof msg.sequence === 'string' &&
|
|
msg.sequence.length === 1 &&
|
|
msg.sequence >= ' ' &&
|
|
msg.sequence !== '\x7f'
|
|
) {
|
|
if (this.promptBuf.length < this.maxPromptLen)
|
|
this.promptBuf += msg.sequence
|
|
}
|
|
return [this, null]
|
|
}
|
|
if (msg && msg.type === 'paste') {
|
|
bareEditInsertPasteText(this.buf, msg.text || '')
|
|
this._ensureScroll()
|
|
return [this, null]
|
|
}
|
|
if (tui.key.matches(msg, 'ctrl+x')) {
|
|
if (this.buf.dirty) {
|
|
this.mode = 'quit_confirm'
|
|
return [this, null]
|
|
}
|
|
return [this, tui.quit]
|
|
}
|
|
if (tui.key.matches(msg, 'ctrl+s')) {
|
|
return [this, this._save(this.savePath)]
|
|
}
|
|
if (tui.key.matches(msg, 'ctrl+o')) {
|
|
this.mode = 'prompt_saveas'
|
|
this.promptTitle = 'File: '
|
|
this.promptBuf = this.savePath
|
|
return [this, null]
|
|
}
|
|
if (tui.key.matches(msg, 'ctrl+w')) {
|
|
this.mode = 'prompt_search'
|
|
this.promptTitle = 'Search: '
|
|
this.promptBuf = this.lastSearch
|
|
return [this, null]
|
|
}
|
|
if (tui.key.matches(msg, 'ctrl+g')) {
|
|
this.mode = 'help'
|
|
return [this, null]
|
|
}
|
|
if (bareEditIsGotoChord(msg)) {
|
|
this.mode = 'prompt_goto'
|
|
this.promptTitle = 'Go to line: '
|
|
this.promptBuf = ''
|
|
return [this, null]
|
|
}
|
|
if (tui.key.matches(msg, 'ctrl+z')) {
|
|
bareEditUndo(this.buf)
|
|
this._ensureScroll()
|
|
return [this, null]
|
|
}
|
|
if (tui.key.matches(msg, 'ctrl+y')) {
|
|
bareEditRedo(this.buf)
|
|
this._ensureScroll()
|
|
return [this, null]
|
|
}
|
|
if (tui.key.matches(msg, 'pageup')) {
|
|
const step = Math.max(1, (this.height || 24) - 4)
|
|
this.buf.row = Math.max(0, this.buf.row - step)
|
|
bareEditClampCursor(this.buf)
|
|
this._ensureScroll()
|
|
return [this, null]
|
|
}
|
|
if (tui.key.matches(msg, 'pagedown')) {
|
|
const step = Math.max(1, (this.height || 24) - 4)
|
|
this.buf.row = Math.min(this.buf.lines.length - 1, this.buf.row + step)
|
|
bareEditClampCursor(this.buf)
|
|
this._ensureScroll()
|
|
return [this, null]
|
|
}
|
|
if (tui.key.matches(msg, 'up', 'down', 'left', 'right', 'home', 'end')) {
|
|
const name = msg && msg.name
|
|
if (
|
|
name === 'up' ||
|
|
name === 'down' ||
|
|
name === 'left' ||
|
|
name === 'right' ||
|
|
name === 'home' ||
|
|
name === 'end'
|
|
) {
|
|
bareEditMoveKey(this.buf, name)
|
|
}
|
|
this._ensureScroll()
|
|
return [this, null]
|
|
}
|
|
if (tui.key.matches(msg, 'backspace')) {
|
|
bareEditBackspace(this.buf)
|
|
this._ensureScroll()
|
|
return [this, null]
|
|
}
|
|
if (tui.key.matches(msg, 'delete')) {
|
|
bareEditDelete(this.buf)
|
|
this._ensureScroll()
|
|
return [this, null]
|
|
}
|
|
if (tui.key.matches(msg, 'enter')) {
|
|
bareEditNewline(this.buf)
|
|
this._ensureScroll()
|
|
return [this, null]
|
|
}
|
|
if (tui.key.matches(msg, 'tab')) {
|
|
bareEditInsertChar(this.buf, ' ')
|
|
this._ensureScroll()
|
|
return [this, null]
|
|
}
|
|
if (tui.key.matches(msg, 'ctrl+c')) {
|
|
return [this, null]
|
|
}
|
|
if (
|
|
msg &&
|
|
msg.type === 'key' &&
|
|
!msg.ctrl &&
|
|
!msg.meta &&
|
|
typeof msg.sequence === 'string' &&
|
|
msg.sequence.length === 1 &&
|
|
msg.sequence >= ' '
|
|
) {
|
|
bareEditInsertChar(this.buf, msg.sequence)
|
|
this._ensureScroll()
|
|
}
|
|
return [this, null]
|
|
},
|
|
view: function () {
|
|
const cols = Math.max(40, this.width || 80)
|
|
const contentH = this._contentH()
|
|
this._ensureScroll()
|
|
const lines = []
|
|
for (let r = 0; r < contentH; r++) {
|
|
const lineIndex = this.scrollRow + r
|
|
const line = this.buf.lines[lineIndex] || ''
|
|
const spans = bareEditHighlightLine(line, this.lang)
|
|
lines.push(
|
|
bareEditPaintLineWindow(line, spans, this.scrollCol, cols, useColor)
|
|
)
|
|
}
|
|
const dirtyMark = this.buf.dirty ? ' [Modified]' : ''
|
|
const statusRaw =
|
|
this.statusMsg ||
|
|
this.display +
|
|
' ' +
|
|
this.savePath +
|
|
dirtyMark +
|
|
' ' +
|
|
(this.buf.row + 1) +
|
|
',' +
|
|
(this.buf.col + 1)
|
|
const helpRaw =
|
|
'^G Help ^O/^S Save ^X Exit ^W Find ^_ Line ^Z Undo ^Y Redo'
|
|
const st = tui.style
|
|
const status = st
|
|
? st()
|
|
.foreground('brightwhite')
|
|
.background('blue')
|
|
.width(cols)
|
|
.render(statusRaw)
|
|
: statusRaw
|
|
const help = st ? st().dim().width(cols).render(helpRaw) : helpRaw
|
|
return lines.join('\n') + '\n' + status + '\n' + help
|
|
},
|
|
overlays: function (size) {
|
|
const cols = (size && size.width) || this.width || 80
|
|
const rows = (size && size.height) || this.height || 24
|
|
const st = tui.style
|
|
/** @type {{ row: number, col: number, text: string }[]} */
|
|
const list = []
|
|
if (this.mode === 'help') {
|
|
const body =
|
|
'Bare OS edit — help\n\n' +
|
|
'^O ^S Save ^X Exit\n' +
|
|
'^W Search ^_ Go to line\n' +
|
|
'^Z Undo ^Y Redo\n' +
|
|
'Arrows move cursor; Home/End; Backspace/Del\n\n' +
|
|
'Press any key to return.'
|
|
const boxed = st
|
|
? st()
|
|
.border(st.borders.rounded)
|
|
.padding(1, 2)
|
|
.background('black')
|
|
.render(body)
|
|
: body
|
|
const h = st ? st.height(boxed) : boxed.split('\n').length
|
|
const w = st ? st.width(boxed) : 40
|
|
list.push({
|
|
row: Math.max(0, Math.floor((rows - h) / 2)),
|
|
col: Math.max(0, Math.floor((cols - w) / 2)),
|
|
text: boxed
|
|
})
|
|
return list
|
|
}
|
|
if (this.mode === 'quit_confirm') {
|
|
const msg = 'Save modified buffer? Y Yes N No ^C Cancel'
|
|
list.push({
|
|
row: Math.max(0, rows - 2),
|
|
col: 0,
|
|
text: st
|
|
? st()
|
|
.foreground('brightwhite')
|
|
.background('blue')
|
|
.width(cols)
|
|
.render(msg)
|
|
: msg
|
|
})
|
|
return list
|
|
}
|
|
if (this._promptKind()) {
|
|
const bar = this.promptTitle + this.promptBuf
|
|
list.push({
|
|
row: Math.max(0, rows - 2),
|
|
col: 0,
|
|
text: st
|
|
? st()
|
|
.foreground('brightwhite')
|
|
.background('blue')
|
|
.width(cols)
|
|
.render(bar)
|
|
: bar
|
|
})
|
|
return list
|
|
}
|
|
this._ensureScroll()
|
|
const cr = this.buf.row - this.scrollRow
|
|
const cc = this.buf.col - this.scrollCol
|
|
if (cr >= 0 && cr < rows - 2 && cc >= 0 && cc < cols) {
|
|
const line = this.buf.lines[this.buf.row] || ''
|
|
const ch = line.charAt(this.buf.col) || ' '
|
|
list.push({
|
|
row: cr,
|
|
col: cc,
|
|
text: st ? st().reverse().render(ch) : ch
|
|
})
|
|
}
|
|
return list
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {{ path: string, initialText: string, argv0?: string }} opts
|
|
*/
|
|
async function bareOsRunEditTui(ctx, opts) {
|
|
if (ctx.tui && typeof ctx.tui.run === 'function') {
|
|
await ctx.tui.run(bareEditCreateTuiApp(ctx, opts), bareEditTuiRunOpts(ctx))
|
|
return
|
|
}
|
|
await bareOsRunEditTuiLegacy(ctx, opts)
|
|
}
|
|
|
|
/**
|
|
* Pre-SDK key loop (BARE_OS_TUI=0).
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {{ path: string, initialText: string, argv0?: string }} opts
|
|
*/
|
|
async function bareOsRunEditTuiLegacy(ctx, opts) {
|
|
const vfs = ctx.vfs
|
|
const b4a = ctx.b4a
|
|
const path = opts.path || 'Untitled'
|
|
const display = opts.argv0 || 'edit'
|
|
const lang = bareEditDetectLang(path)
|
|
const buf = bareEditCreateBuffer(opts.initialText)
|
|
const useColor = bareEditUseColor(ctx)
|
|
const stdin = /** @type {import('stream').Readable | undefined} */ (
|
|
ctx.replStdin
|
|
)
|
|
const stdout = bareEditResolveStdout(ctx)
|
|
if (!stdin || !stdout) {
|
|
ctx.console.error('edit: missing stdin/stdout')
|
|
ctx.exitCode = 1
|
|
return
|
|
}
|
|
|
|
let scrollRow = 0
|
|
let scrollCol = 0
|
|
let savePath = path
|
|
|
|
/** @type {'edit'|'quit_confirm'|'help'|'prompt_search'|'prompt_goto'|'prompt_saveas'} */
|
|
let mode = 'edit'
|
|
let promptBuf = ''
|
|
let promptTitle = ''
|
|
let lastSearch = ''
|
|
|
|
const envEditEarly =
|
|
ctx.env && typeof ctx.env === 'object'
|
|
? /** @type {Record<string, string>} */ (ctx.env)
|
|
: {}
|
|
let maxPromptLen = 4096
|
|
const maxPromptRaw = envEditEarly.BARE_EDIT_MAX_PROMPT
|
|
if (maxPromptRaw != null && String(maxPromptRaw) !== '') {
|
|
const n = parseInt(String(maxPromptRaw), 10)
|
|
if (Number.isFinite(n) && n > 0) maxPromptLen = Math.min(n, 65536)
|
|
}
|
|
|
|
/**
|
|
* @param {string} text
|
|
* @param {'saveas'|'search'|'goto'} kind
|
|
*/
|
|
function promptLineFromPaste(text, kind) {
|
|
let line = String(text).split(/\r?\n/)[0] ?? ''
|
|
line = line.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, '')
|
|
if (line.length > maxPromptLen) line = line.slice(0, maxPromptLen)
|
|
if (kind === 'goto') {
|
|
const m = /^(\s*\d+)/.exec(line)
|
|
return m ? m[1].trim() : ''
|
|
}
|
|
return line
|
|
}
|
|
|
|
function termDims() {
|
|
const env =
|
|
ctx.env && typeof ctx.env === 'object'
|
|
? /** @type {Record<string, string>} */ (ctx.env)
|
|
: {}
|
|
const cols =
|
|
/** @type {{ columns?: number }} */ (stdout).columns ||
|
|
parseInt(env.COLUMNS || '80', 10) ||
|
|
80
|
|
const rows =
|
|
/** @type {{ rows?: number }} */ (stdout).rows ||
|
|
parseInt(env.LINES || '24', 10) ||
|
|
24
|
|
return { cols: Math.max(40, cols), rows: Math.max(8, rows) }
|
|
}
|
|
|
|
function ensureScroll() {
|
|
const { cols, rows } = termDims()
|
|
const contentH = rows - 2
|
|
if (buf.row < scrollRow) scrollRow = buf.row
|
|
if (buf.row >= scrollRow + contentH) scrollRow = buf.row - contentH + 1
|
|
if (scrollRow < 0) scrollRow = 0
|
|
if (scrollCol > buf.col) scrollCol = buf.col
|
|
if (buf.col - scrollCol >= cols) scrollCol = buf.col - cols + 1
|
|
if (scrollCol < 0) scrollCol = 0
|
|
}
|
|
|
|
function draw() {
|
|
const { cols, rows } = termDims()
|
|
const contentH = Math.max(1, rows - 2)
|
|
ensureScroll()
|
|
|
|
let out = ''
|
|
out += '\x1b[?25l\x1b[2J\x1b[H'
|
|
|
|
if (mode === 'help') {
|
|
out +=
|
|
'Bare OS edit — help\r\n\r\n' +
|
|
'^O ^S Save ^X Exit\r\n' +
|
|
'^W Search ^_ Go to line\r\n' +
|
|
'^Z Undo ^Y Redo\r\n' +
|
|
'Arrows move cursor; Home/End; Backspace/Del\r\n\r\n' +
|
|
'Press any key to return.\r\n'
|
|
out += bareEditCup(8, 1) + '\x1b[?25h'
|
|
bareEditWrite(ctx, stdout, out)
|
|
return
|
|
}
|
|
|
|
if (mode === 'quit_confirm') {
|
|
for (let row = 1; row <= contentH; row++) {
|
|
out += bareEditCup(row, 1) + '\x1b[K'
|
|
}
|
|
const msg = 'Save modified buffer? Y Yes N No ^C Cancel'
|
|
const st = bareEditSgr('status', useColor) + msg + EDIT_ANSI_RESET
|
|
out += bareEditCup(contentH + 1, 1) + '\x1b[K' + st
|
|
out += bareEditCup(contentH + 1, 1) + '\x1b[?25h'
|
|
bareEditWrite(ctx, stdout, out)
|
|
return
|
|
}
|
|
|
|
if (
|
|
mode === 'prompt_search' ||
|
|
mode === 'prompt_goto' ||
|
|
mode === 'prompt_saveas'
|
|
) {
|
|
const editorLines = Math.max(0, contentH - 1)
|
|
for (let r = 0; r < editorLines; r++) {
|
|
const lineIndex = scrollRow + r
|
|
const line = buf.lines[lineIndex] || ''
|
|
const spans = bareEditHighlightLine(line, lang)
|
|
const vis = bareEditPaintLineWindow(
|
|
line,
|
|
spans,
|
|
scrollCol,
|
|
cols,
|
|
useColor
|
|
)
|
|
out += bareEditCup(1 + r, 1) + '\x1b[K' + vis
|
|
}
|
|
const barBody =
|
|
bareEditSgr('status', useColor) +
|
|
promptTitle +
|
|
promptBuf +
|
|
(useColor ? '\x1b[0m' : '')
|
|
const promptRow = editorLines + 1
|
|
out += bareEditCup(promptRow, 1) + '\x1b[K' + barBody
|
|
const promptCol = 1 + promptTitle.length + promptBuf.length
|
|
out += bareEditCup(promptRow, promptCol) + '\x1b[?25h'
|
|
bareEditWrite(ctx, stdout, out)
|
|
return
|
|
}
|
|
|
|
for (let r = 0; r < contentH; r++) {
|
|
const lineIndex = scrollRow + r
|
|
const line = buf.lines[lineIndex] || ''
|
|
const spans = bareEditHighlightLine(line, lang)
|
|
const vis = bareEditPaintLineWindow(
|
|
line,
|
|
spans,
|
|
scrollCol,
|
|
cols,
|
|
useColor
|
|
)
|
|
out += bareEditCup(1 + r, 1) + '\x1b[K' + vis
|
|
}
|
|
|
|
const dirtyMark = buf.dirty ? ' [Modified]' : ''
|
|
const statusLine =
|
|
(useColor ? bareEditSgr('status', true) : '') +
|
|
display +
|
|
' ' +
|
|
savePath +
|
|
dirtyMark +
|
|
' ' +
|
|
(buf.row + 1) +
|
|
',' +
|
|
(buf.col + 1) +
|
|
(useColor ? EDIT_ANSI_RESET : '')
|
|
const helpLine =
|
|
(useColor ? bareEditSgr('dim', true) : '') +
|
|
'^G Help ^O/^S Save ^X Exit ^W Find ^_ Line ^Z Undo ^Y Redo' +
|
|
(useColor ? EDIT_ANSI_RESET : '')
|
|
out += bareEditCup(contentH + 1, 1) + '\x1b[K' + statusLine
|
|
out += bareEditCup(contentH + 2, 1) + '\x1b[K' + helpLine
|
|
const curRow = 1 + (buf.row - scrollRow)
|
|
const curCol = 1 + (buf.col - scrollCol)
|
|
const tr = Math.min(Math.max(1, curRow), contentH)
|
|
const tc = Math.min(Math.max(1, curCol), cols)
|
|
out += bareEditCup(tr, tc) + '\x1b[?25h'
|
|
bareEditWrite(ctx, stdout, out)
|
|
}
|
|
|
|
async function doSave(targetPath) {
|
|
const text = bareEditJoinAll(buf)
|
|
try {
|
|
await vfs.writeFile(targetPath, b4a.from(text))
|
|
buf.dirty = false
|
|
savePath = targetPath
|
|
return true
|
|
} catch (e) {
|
|
ctx.console.error('edit: ' + ((e && e.message) || String(e)))
|
|
return false
|
|
}
|
|
}
|
|
|
|
const reader = bareEditCreateStdinReader(stdin)
|
|
let suspended = false
|
|
/** When true, alternate-screen mode was entered (?1049h); {@link bareEditWrite} must send ?1049l on exit. */
|
|
let useAltScreen = false
|
|
/** When true, bracketed paste mode was enabled (?2004h); must send ?2004l on exit. */
|
|
let useBracketPaste = false
|
|
try {
|
|
if (typeof ctx.suspendReplForSubprocess === 'function') {
|
|
ctx.suspendReplForSubprocess()
|
|
suspended = true
|
|
}
|
|
if (typeof stdin.setRawMode === 'function') stdin.setRawMode(true)
|
|
if (typeof stdin.resume === 'function') stdin.resume()
|
|
|
|
const bareEditNoAltScreen =
|
|
envEditEarly.BARE_EDIT_NO_ALTSCREEN != null &&
|
|
String(envEditEarly.BARE_EDIT_NO_ALTSCREEN) !== ''
|
|
if (!bareEditNoAltScreen) {
|
|
bareEditWrite(ctx, stdout, '\x1b[?1049h')
|
|
useAltScreen = true
|
|
}
|
|
|
|
const bareEditNoBracketPaste =
|
|
envEditEarly.BARE_EDIT_NO_BRACKETED_PASTE != null &&
|
|
String(envEditEarly.BARE_EDIT_NO_BRACKETED_PASTE) !== ''
|
|
if (!bareEditNoBracketPaste) {
|
|
bareEditWrite(ctx, stdout, '\x1b[?2004h')
|
|
useBracketPaste = true
|
|
}
|
|
|
|
draw()
|
|
|
|
for (;;) {
|
|
const ev = await bareEditReadKey(reader)
|
|
if (ev.type === 'eof') break
|
|
|
|
if (mode === 'help') {
|
|
mode = 'edit'
|
|
draw()
|
|
continue
|
|
}
|
|
|
|
if (mode === 'quit_confirm') {
|
|
if (ev.type === 'ctrl' && ev.code === 'interrupt') {
|
|
mode = 'edit'
|
|
draw()
|
|
continue
|
|
}
|
|
if (ev.type === 'key' && ev.ch) {
|
|
const u = ev.ch.toUpperCase()
|
|
if (u === 'Y') {
|
|
await doSave(savePath)
|
|
break
|
|
}
|
|
if (u === 'N') break
|
|
}
|
|
continue
|
|
}
|
|
|
|
if (
|
|
mode === 'prompt_search' ||
|
|
mode === 'prompt_goto' ||
|
|
mode === 'prompt_saveas'
|
|
) {
|
|
if (ev.type === 'ctrl' && ev.code === 'interrupt') {
|
|
mode = 'edit'
|
|
draw()
|
|
continue
|
|
}
|
|
if (ev.type === 'paste') {
|
|
if (mode === 'prompt_saveas') {
|
|
promptBuf = promptLineFromPaste(ev.text || '', 'saveas')
|
|
} else if (mode === 'prompt_search') {
|
|
promptBuf = promptLineFromPaste(ev.text || '', 'search')
|
|
} else if (mode === 'prompt_goto') {
|
|
promptBuf = promptLineFromPaste(ev.text || '', 'goto')
|
|
}
|
|
draw()
|
|
continue
|
|
}
|
|
if (ev.type === 'key' && ev.ch === '\n') {
|
|
if (mode === 'prompt_search') {
|
|
lastSearch = promptBuf
|
|
if (lastSearch) {
|
|
const hit = bareEditFindNext(
|
|
buf,
|
|
lastSearch,
|
|
buf.row,
|
|
buf.col + 1
|
|
)
|
|
if (!hit) {
|
|
const hit2 = bareEditFindNext(buf, lastSearch, 0, 0)
|
|
if (hit2) {
|
|
buf.row = hit2.row
|
|
buf.col = hit2.col
|
|
}
|
|
} else {
|
|
buf.row = hit.row
|
|
buf.col = hit.col
|
|
}
|
|
}
|
|
} else if (mode === 'prompt_goto') {
|
|
const n = parseInt(promptBuf.trim(), 10)
|
|
if (Number.isFinite(n)) bareEditGotoLine(buf, n)
|
|
} else if (mode === 'prompt_saveas') {
|
|
const p = promptBuf.trim()
|
|
if (p) {
|
|
savePath = p
|
|
await doSave(savePath)
|
|
}
|
|
}
|
|
mode = 'edit'
|
|
draw()
|
|
continue
|
|
}
|
|
if (ev.type === 'ctrl' && ev.code === 'backspace') {
|
|
promptBuf = promptBuf.slice(0, -1)
|
|
draw()
|
|
continue
|
|
}
|
|
if (ev.type === 'key' && ev.ch && ev.ch !== '\n' && ev.ch !== '\t') {
|
|
if (ev.ch >= ' ' && promptBuf.length < maxPromptLen)
|
|
promptBuf += ev.ch
|
|
draw()
|
|
continue
|
|
}
|
|
continue
|
|
}
|
|
|
|
if (ev.type === 'paste') {
|
|
bareEditInsertPasteText(buf, ev.text || '')
|
|
draw()
|
|
continue
|
|
}
|
|
|
|
if (ev.type === 'nav') {
|
|
if (ev.key === 'pageup') {
|
|
const { rows } = termDims()
|
|
const step = Math.max(1, rows - 4)
|
|
buf.row = Math.max(0, buf.row - step)
|
|
bareEditClampCursor(buf)
|
|
} else if (ev.key === 'pagedown') {
|
|
const { rows } = termDims()
|
|
const step = Math.max(1, rows - 4)
|
|
buf.row = Math.min(buf.lines.length - 1, buf.row + step)
|
|
bareEditClampCursor(buf)
|
|
} else bareEditMoveKey(buf, ev.key)
|
|
draw()
|
|
continue
|
|
}
|
|
|
|
if (ev.type === 'ctrl') {
|
|
if (ev.code === 'backspace') {
|
|
bareEditBackspace(buf)
|
|
draw()
|
|
continue
|
|
}
|
|
if (ev.code === 'delete') {
|
|
bareEditDelete(buf)
|
|
draw()
|
|
continue
|
|
}
|
|
if (ev.code === 'interrupt') {
|
|
/* ignore in edit */
|
|
continue
|
|
}
|
|
const code = typeof ev.code === 'number' ? ev.code : 0
|
|
if (code === 27) {
|
|
/* lone ESC fragment; do not insert */
|
|
continue
|
|
}
|
|
if (code === 24) {
|
|
if (buf.dirty) {
|
|
mode = 'quit_confirm'
|
|
draw()
|
|
} else break
|
|
continue
|
|
}
|
|
if (code === 19) {
|
|
await doSave(savePath)
|
|
draw()
|
|
continue
|
|
}
|
|
if (code === 15) {
|
|
mode = 'prompt_saveas'
|
|
promptTitle = 'File: '
|
|
promptBuf = savePath
|
|
draw()
|
|
continue
|
|
}
|
|
if (code === 23) {
|
|
mode = 'prompt_search'
|
|
promptTitle = 'Search: '
|
|
promptBuf = lastSearch
|
|
draw()
|
|
continue
|
|
}
|
|
if (code === 7) {
|
|
mode = 'help'
|
|
draw()
|
|
continue
|
|
}
|
|
if (code === 31) {
|
|
mode = 'prompt_goto'
|
|
promptTitle = 'Go to line: '
|
|
promptBuf = ''
|
|
draw()
|
|
continue
|
|
}
|
|
if (code === 26) {
|
|
bareEditUndo(buf)
|
|
draw()
|
|
continue
|
|
}
|
|
if (code === 25) {
|
|
bareEditRedo(buf)
|
|
draw()
|
|
continue
|
|
}
|
|
continue
|
|
}
|
|
|
|
if (ev.type === 'key' && ev.ch) {
|
|
if (ev.ch === '\n') bareEditNewline(buf)
|
|
else if (ev.ch === '\t') bareEditInsertChar(buf, ' ')
|
|
else bareEditInsertChar(buf, ev.ch)
|
|
draw()
|
|
}
|
|
}
|
|
} finally {
|
|
try {
|
|
if (useBracketPaste) {
|
|
bareEditWrite(ctx, stdout, '\x1b[?2004l')
|
|
}
|
|
if (useAltScreen) {
|
|
bareEditWrite(ctx, stdout, '\x1b[?1049l')
|
|
} else {
|
|
bareEditWrite(ctx, stdout, '\x1b[2J\x1b[H')
|
|
}
|
|
bareEditWrite(ctx, stdout, '\x1b[?25h\x1b[0m')
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
reader.dispose()
|
|
try {
|
|
if (typeof stdin.setRawMode === 'function') stdin.setRawMode(false)
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
if (suspended && typeof ctx.resumeReplAfterSubprocess === 'function') {
|
|
ctx.resumeReplAfterSubprocess()
|
|
}
|
|
}
|
|
}
|