fix(edit,nano): bracketed paste + bounded prompts so Save-as cannot flood
Enable xterm bracketed paste (?2004h/?2004l), parse ESC[200~…201~ as a single paste event, insert paste in the buffer with one undo step, and replace prompt input with first-line sanitized text capped by BARE_EDIT_MAX_PROMPT. Cap typed prompt length for terminals without bracketed paste. Extend tests and rebuild kernel /bin edit and nano bundles.
This commit is contained in:
@@ -274,6 +274,101 @@ function bareEditSs3ToEvent(b3) {
|
||||
return { type: 'unknown' }
|
||||
}
|
||||
|
||||
/** ESC [ 200 ~ */
|
||||
const BARE_EDIT_BRACKET_PASTE_START = [27, 91, 50, 48, 48, 126]
|
||||
/** ESC [ 201 ~ */
|
||||
const BARE_EDIT_BRACKET_PASTE_END = [27, 91, 50, 48, 49, 126]
|
||||
|
||||
/**
|
||||
* @param {number[]} q
|
||||
* @param {number[]} prefix
|
||||
*/
|
||||
function bareEditStartsWithBytes(q, prefix) {
|
||||
if (q.length < prefix.length) return false
|
||||
for (let i = 0; i < prefix.length; i++) {
|
||||
if (q[i] !== prefix[i]) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* True if q could still become BARE_EDIT_BRACKET_PASTE_START with more bytes.
|
||||
* @param {number[]} q
|
||||
*/
|
||||
function bareEditCouldBeBracketPastePrefix(q) {
|
||||
const pre = BARE_EDIT_BRACKET_PASTE_START
|
||||
const n = Math.min(q.length, pre.length)
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (q[i] !== pre[i]) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number[]} bytes
|
||||
*/
|
||||
function bareEditDecodePasteInner(bytes) {
|
||||
try {
|
||||
if (typeof TextDecoder !== 'undefined') {
|
||||
return new TextDecoder('utf-8', { fatal: false }).decode(
|
||||
new Uint8Array(bytes)
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
let s = ''
|
||||
for (const b of bytes) s += String.fromCharCode(b)
|
||||
return s
|
||||
}
|
||||
|
||||
/**
|
||||
* If queue begins with ESC [ 200 ~ but no closing ESC [ 201 ~ (stdin closed), return inner bytes as text.
|
||||
* @param {number[]} q
|
||||
* @returns {string|undefined}
|
||||
*/
|
||||
function bareEditFinalizeBracketedPasteOnEof(q) {
|
||||
const SL = BARE_EDIT_BRACKET_PASTE_START.length
|
||||
if (q.length >= SL && bareEditStartsWithBytes(q, BARE_EDIT_BRACKET_PASTE_START)) {
|
||||
const inner = q.slice(SL)
|
||||
q.length = 0
|
||||
return bareEditDecodePasteInner(inner)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* xterm bracketed paste: ESC [ 200 ~ … ESC [ 201 ~
|
||||
* @param {number[]} q mutable queue
|
||||
* @returns {string|null|undefined} string if consumed; null if incomplete; undefined if not bracketed paste at front
|
||||
*/
|
||||
function bareEditTryConsumeBracketedPaste(q) {
|
||||
const SL = BARE_EDIT_BRACKET_PASTE_START.length
|
||||
const EL = BARE_EDIT_BRACKET_PASTE_END.length
|
||||
if (!q.length) return undefined
|
||||
if (q.length < SL) {
|
||||
return bareEditCouldBeBracketPastePrefix(q) ? null : undefined
|
||||
}
|
||||
if (!bareEditStartsWithBytes(q, BARE_EDIT_BRACKET_PASTE_START)) {
|
||||
return undefined
|
||||
}
|
||||
for (let i = SL; i <= q.length - EL; i++) {
|
||||
let ok = true
|
||||
for (let j = 0; j < EL; j++) {
|
||||
if (q[i + j] !== BARE_EDIT_BRACKET_PASTE_END[j]) {
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if (ok) {
|
||||
const inner = q.slice(SL, i)
|
||||
q.splice(0, i + EL)
|
||||
return bareEditDecodePasteInner(inner)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number[]} q mutable queue (front = index 0)
|
||||
* @returns {Record<string, unknown> | null} null if more bytes needed
|
||||
|
||||
@@ -274,6 +274,101 @@ function bareEditSs3ToEvent(b3) {
|
||||
return { type: 'unknown' }
|
||||
}
|
||||
|
||||
/** ESC [ 200 ~ */
|
||||
const BARE_EDIT_BRACKET_PASTE_START = [27, 91, 50, 48, 48, 126]
|
||||
/** ESC [ 201 ~ */
|
||||
const BARE_EDIT_BRACKET_PASTE_END = [27, 91, 50, 48, 49, 126]
|
||||
|
||||
/**
|
||||
* @param {number[]} q
|
||||
* @param {number[]} prefix
|
||||
*/
|
||||
function bareEditStartsWithBytes(q, prefix) {
|
||||
if (q.length < prefix.length) return false
|
||||
for (let i = 0; i < prefix.length; i++) {
|
||||
if (q[i] !== prefix[i]) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* True if q could still become BARE_EDIT_BRACKET_PASTE_START with more bytes.
|
||||
* @param {number[]} q
|
||||
*/
|
||||
function bareEditCouldBeBracketPastePrefix(q) {
|
||||
const pre = BARE_EDIT_BRACKET_PASTE_START
|
||||
const n = Math.min(q.length, pre.length)
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (q[i] !== pre[i]) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number[]} bytes
|
||||
*/
|
||||
function bareEditDecodePasteInner(bytes) {
|
||||
try {
|
||||
if (typeof TextDecoder !== 'undefined') {
|
||||
return new TextDecoder('utf-8', { fatal: false }).decode(
|
||||
new Uint8Array(bytes)
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
let s = ''
|
||||
for (const b of bytes) s += String.fromCharCode(b)
|
||||
return s
|
||||
}
|
||||
|
||||
/**
|
||||
* If queue begins with ESC [ 200 ~ but no closing ESC [ 201 ~ (stdin closed), return inner bytes as text.
|
||||
* @param {number[]} q
|
||||
* @returns {string|undefined}
|
||||
*/
|
||||
function bareEditFinalizeBracketedPasteOnEof(q) {
|
||||
const SL = BARE_EDIT_BRACKET_PASTE_START.length
|
||||
if (q.length >= SL && bareEditStartsWithBytes(q, BARE_EDIT_BRACKET_PASTE_START)) {
|
||||
const inner = q.slice(SL)
|
||||
q.length = 0
|
||||
return bareEditDecodePasteInner(inner)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* xterm bracketed paste: ESC [ 200 ~ … ESC [ 201 ~
|
||||
* @param {number[]} q mutable queue
|
||||
* @returns {string|null|undefined} string if consumed; null if incomplete; undefined if not bracketed paste at front
|
||||
*/
|
||||
function bareEditTryConsumeBracketedPaste(q) {
|
||||
const SL = BARE_EDIT_BRACKET_PASTE_START.length
|
||||
const EL = BARE_EDIT_BRACKET_PASTE_END.length
|
||||
if (!q.length) return undefined
|
||||
if (q.length < SL) {
|
||||
return bareEditCouldBeBracketPastePrefix(q) ? null : undefined
|
||||
}
|
||||
if (!bareEditStartsWithBytes(q, BARE_EDIT_BRACKET_PASTE_START)) {
|
||||
return undefined
|
||||
}
|
||||
for (let i = SL; i <= q.length - EL; i++) {
|
||||
let ok = true
|
||||
for (let j = 0; j < EL; j++) {
|
||||
if (q[i + j] !== BARE_EDIT_BRACKET_PASTE_END[j]) {
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if (ok) {
|
||||
const inner = q.slice(SL, i)
|
||||
q.splice(0, i + EL)
|
||||
return bareEditDecodePasteInner(inner)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number[]} q mutable queue (front = index 0)
|
||||
* @returns {Record<string, unknown> | null} null if more bytes needed
|
||||
|
||||
+217
-7
@@ -580,6 +580,37 @@ function bareEditNewline(buf) {
|
||||
buf.dirty = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert pasted text as one undo step (normalized newlines; tab → two spaces).
|
||||
* @param {{ lines: string[], row: number, col: number, dirty: boolean, undo: unknown[], redo: unknown[] }} buf
|
||||
* @param {string} text
|
||||
*/
|
||||
function bareEditInsertPasteText(buf, text) {
|
||||
const normalized = String(text).replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
||||
if (!normalized) return
|
||||
bareEditPushUndo(buf)
|
||||
for (const ch of normalized) {
|
||||
if (ch === '\n') {
|
||||
const line = buf.lines[buf.row]
|
||||
const rest = line.slice(buf.col)
|
||||
buf.lines[buf.row] = line.slice(0, buf.col)
|
||||
buf.lines.splice(buf.row + 1, 0, rest)
|
||||
buf.row++
|
||||
buf.col = 0
|
||||
} else if (ch === '\t') {
|
||||
const line = buf.lines[buf.row]
|
||||
buf.lines[buf.row] = line.slice(0, buf.col) + ' ' + line.slice(buf.col)
|
||||
buf.col += 2
|
||||
} else {
|
||||
const line = buf.lines[buf.row]
|
||||
buf.lines[buf.row] = line.slice(0, buf.col) + ch + line.slice(buf.col)
|
||||
buf.col += ch.length
|
||||
}
|
||||
}
|
||||
buf.dirty = true
|
||||
bareEditClampCursor(buf)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ lines: string[], row: number, col: number, dirty: boolean, undo: unknown[], redo: unknown[] }} buf
|
||||
*/
|
||||
@@ -797,6 +828,101 @@ function bareEditSs3ToEvent(b3) {
|
||||
return { type: 'unknown' }
|
||||
}
|
||||
|
||||
/** ESC [ 200 ~ */
|
||||
const BARE_EDIT_BRACKET_PASTE_START = [27, 91, 50, 48, 48, 126]
|
||||
/** ESC [ 201 ~ */
|
||||
const BARE_EDIT_BRACKET_PASTE_END = [27, 91, 50, 48, 49, 126]
|
||||
|
||||
/**
|
||||
* @param {number[]} q
|
||||
* @param {number[]} prefix
|
||||
*/
|
||||
function bareEditStartsWithBytes(q, prefix) {
|
||||
if (q.length < prefix.length) return false
|
||||
for (let i = 0; i < prefix.length; i++) {
|
||||
if (q[i] !== prefix[i]) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* True if q could still become BARE_EDIT_BRACKET_PASTE_START with more bytes.
|
||||
* @param {number[]} q
|
||||
*/
|
||||
function bareEditCouldBeBracketPastePrefix(q) {
|
||||
const pre = BARE_EDIT_BRACKET_PASTE_START
|
||||
const n = Math.min(q.length, pre.length)
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (q[i] !== pre[i]) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number[]} bytes
|
||||
*/
|
||||
function bareEditDecodePasteInner(bytes) {
|
||||
try {
|
||||
if (typeof TextDecoder !== 'undefined') {
|
||||
return new TextDecoder('utf-8', { fatal: false }).decode(
|
||||
new Uint8Array(bytes)
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
let s = ''
|
||||
for (const b of bytes) s += String.fromCharCode(b)
|
||||
return s
|
||||
}
|
||||
|
||||
/**
|
||||
* If queue begins with ESC [ 200 ~ but no closing ESC [ 201 ~ (stdin closed), return inner bytes as text.
|
||||
* @param {number[]} q
|
||||
* @returns {string|undefined}
|
||||
*/
|
||||
function bareEditFinalizeBracketedPasteOnEof(q) {
|
||||
const SL = BARE_EDIT_BRACKET_PASTE_START.length
|
||||
if (q.length >= SL && bareEditStartsWithBytes(q, BARE_EDIT_BRACKET_PASTE_START)) {
|
||||
const inner = q.slice(SL)
|
||||
q.length = 0
|
||||
return bareEditDecodePasteInner(inner)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* xterm bracketed paste: ESC [ 200 ~ … ESC [ 201 ~
|
||||
* @param {number[]} q mutable queue
|
||||
* @returns {string|null|undefined} string if consumed; null if incomplete; undefined if not bracketed paste at front
|
||||
*/
|
||||
function bareEditTryConsumeBracketedPaste(q) {
|
||||
const SL = BARE_EDIT_BRACKET_PASTE_START.length
|
||||
const EL = BARE_EDIT_BRACKET_PASTE_END.length
|
||||
if (!q.length) return undefined
|
||||
if (q.length < SL) {
|
||||
return bareEditCouldBeBracketPastePrefix(q) ? null : undefined
|
||||
}
|
||||
if (!bareEditStartsWithBytes(q, BARE_EDIT_BRACKET_PASTE_START)) {
|
||||
return undefined
|
||||
}
|
||||
for (let i = SL; i <= q.length - EL; i++) {
|
||||
let ok = true
|
||||
for (let j = 0; j < EL; j++) {
|
||||
if (q[i + j] !== BARE_EDIT_BRACKET_PASTE_END[j]) {
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if (ok) {
|
||||
const inner = q.slice(SL, i)
|
||||
q.splice(0, i + EL)
|
||||
return bareEditDecodePasteInner(inner)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number[]} q mutable queue (front = index 0)
|
||||
* @returns {Record<string, unknown> | null} null if more bytes needed
|
||||
@@ -881,10 +1007,42 @@ async function bareEditReadKey(reader) {
|
||||
reader._keyq = reader._keyq || []
|
||||
const q = reader._keyq
|
||||
for (;;) {
|
||||
const pasteText = bareEditTryConsumeBracketedPaste(q)
|
||||
if (pasteText !== undefined && pasteText !== null) {
|
||||
return { type: 'paste', text: pasteText }
|
||||
}
|
||||
if (pasteText === null) {
|
||||
const b = await reader.nextByte()
|
||||
if (b === undefined) {
|
||||
const eofPaste = bareEditFinalizeBracketedPasteOnEof(q)
|
||||
if (eofPaste !== undefined) {
|
||||
return { type: 'paste', text: eofPaste }
|
||||
}
|
||||
if (!q.length) return { type: 'eof' }
|
||||
if (q.length === 1 && q[0] === 27) {
|
||||
q.length = 0
|
||||
return { type: 'key', ch: '\x1b' }
|
||||
}
|
||||
const lone = q.shift()
|
||||
if (lone !== undefined && lone < 0x20) {
|
||||
return { type: 'ctrl', code: lone }
|
||||
}
|
||||
if (lone !== undefined) {
|
||||
return { type: 'key', ch: String.fromCharCode(lone) }
|
||||
}
|
||||
return { type: 'eof' }
|
||||
}
|
||||
q.push(b)
|
||||
continue
|
||||
}
|
||||
const ev = bareEditTryConsumeKey(q)
|
||||
if (ev) return ev
|
||||
const b = await reader.nextByte()
|
||||
if (b === undefined) {
|
||||
const eofPaste = bareEditFinalizeBracketedPasteOnEof(q)
|
||||
if (eofPaste !== undefined) {
|
||||
return { type: 'paste', text: eofPaste }
|
||||
}
|
||||
if (!q.length) return { type: 'eof' }
|
||||
if (q.length === 1 && q[0] === 27) {
|
||||
q.length = 0
|
||||
@@ -986,6 +1144,32 @@ async function bareOsRunEditTui(ctx, opts) {
|
||||
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'
|
||||
@@ -1117,6 +1301,8 @@ async function bareOsRunEditTui(ctx, opts) {
|
||||
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()
|
||||
@@ -1125,18 +1311,22 @@ async function bareOsRunEditTui(ctx, opts) {
|
||||
if (typeof stdin.setRawMode === 'function') stdin.setRawMode(true)
|
||||
if (typeof stdin.resume === 'function') stdin.resume()
|
||||
|
||||
const envEdit =
|
||||
ctx.env && typeof ctx.env === 'object'
|
||||
? /** @type {Record<string, string>} */ (ctx.env)
|
||||
: {}
|
||||
const bareEditNoAltScreen =
|
||||
envEdit.BARE_EDIT_NO_ALTSCREEN != null &&
|
||||
String(envEdit.BARE_EDIT_NO_ALTSCREEN) !== ''
|
||||
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 (;;) {
|
||||
@@ -1176,6 +1366,17 @@ async function bareOsRunEditTui(ctx, opts) {
|
||||
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
|
||||
@@ -1217,13 +1418,19 @@ async function bareOsRunEditTui(ctx, opts) {
|
||||
continue
|
||||
}
|
||||
if (ev.type === 'key' && ev.ch && ev.ch !== '\n' && ev.ch !== '\t') {
|
||||
if (ev.ch >= ' ') promptBuf += ev.ch
|
||||
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()
|
||||
@@ -1320,6 +1527,9 @@ async function bareOsRunEditTui(ctx, opts) {
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
if (useBracketPaste) {
|
||||
bareEditWrite(ctx, stdout, '\x1b[?2004l')
|
||||
}
|
||||
if (useAltScreen) {
|
||||
bareEditWrite(ctx, stdout, '\x1b[?1049l')
|
||||
} else {
|
||||
|
||||
+217
-7
@@ -580,6 +580,37 @@ function bareEditNewline(buf) {
|
||||
buf.dirty = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert pasted text as one undo step (normalized newlines; tab → two spaces).
|
||||
* @param {{ lines: string[], row: number, col: number, dirty: boolean, undo: unknown[], redo: unknown[] }} buf
|
||||
* @param {string} text
|
||||
*/
|
||||
function bareEditInsertPasteText(buf, text) {
|
||||
const normalized = String(text).replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
||||
if (!normalized) return
|
||||
bareEditPushUndo(buf)
|
||||
for (const ch of normalized) {
|
||||
if (ch === '\n') {
|
||||
const line = buf.lines[buf.row]
|
||||
const rest = line.slice(buf.col)
|
||||
buf.lines[buf.row] = line.slice(0, buf.col)
|
||||
buf.lines.splice(buf.row + 1, 0, rest)
|
||||
buf.row++
|
||||
buf.col = 0
|
||||
} else if (ch === '\t') {
|
||||
const line = buf.lines[buf.row]
|
||||
buf.lines[buf.row] = line.slice(0, buf.col) + ' ' + line.slice(buf.col)
|
||||
buf.col += 2
|
||||
} else {
|
||||
const line = buf.lines[buf.row]
|
||||
buf.lines[buf.row] = line.slice(0, buf.col) + ch + line.slice(buf.col)
|
||||
buf.col += ch.length
|
||||
}
|
||||
}
|
||||
buf.dirty = true
|
||||
bareEditClampCursor(buf)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ lines: string[], row: number, col: number, dirty: boolean, undo: unknown[], redo: unknown[] }} buf
|
||||
*/
|
||||
@@ -797,6 +828,101 @@ function bareEditSs3ToEvent(b3) {
|
||||
return { type: 'unknown' }
|
||||
}
|
||||
|
||||
/** ESC [ 200 ~ */
|
||||
const BARE_EDIT_BRACKET_PASTE_START = [27, 91, 50, 48, 48, 126]
|
||||
/** ESC [ 201 ~ */
|
||||
const BARE_EDIT_BRACKET_PASTE_END = [27, 91, 50, 48, 49, 126]
|
||||
|
||||
/**
|
||||
* @param {number[]} q
|
||||
* @param {number[]} prefix
|
||||
*/
|
||||
function bareEditStartsWithBytes(q, prefix) {
|
||||
if (q.length < prefix.length) return false
|
||||
for (let i = 0; i < prefix.length; i++) {
|
||||
if (q[i] !== prefix[i]) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* True if q could still become BARE_EDIT_BRACKET_PASTE_START with more bytes.
|
||||
* @param {number[]} q
|
||||
*/
|
||||
function bareEditCouldBeBracketPastePrefix(q) {
|
||||
const pre = BARE_EDIT_BRACKET_PASTE_START
|
||||
const n = Math.min(q.length, pre.length)
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (q[i] !== pre[i]) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number[]} bytes
|
||||
*/
|
||||
function bareEditDecodePasteInner(bytes) {
|
||||
try {
|
||||
if (typeof TextDecoder !== 'undefined') {
|
||||
return new TextDecoder('utf-8', { fatal: false }).decode(
|
||||
new Uint8Array(bytes)
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
let s = ''
|
||||
for (const b of bytes) s += String.fromCharCode(b)
|
||||
return s
|
||||
}
|
||||
|
||||
/**
|
||||
* If queue begins with ESC [ 200 ~ but no closing ESC [ 201 ~ (stdin closed), return inner bytes as text.
|
||||
* @param {number[]} q
|
||||
* @returns {string|undefined}
|
||||
*/
|
||||
function bareEditFinalizeBracketedPasteOnEof(q) {
|
||||
const SL = BARE_EDIT_BRACKET_PASTE_START.length
|
||||
if (q.length >= SL && bareEditStartsWithBytes(q, BARE_EDIT_BRACKET_PASTE_START)) {
|
||||
const inner = q.slice(SL)
|
||||
q.length = 0
|
||||
return bareEditDecodePasteInner(inner)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* xterm bracketed paste: ESC [ 200 ~ … ESC [ 201 ~
|
||||
* @param {number[]} q mutable queue
|
||||
* @returns {string|null|undefined} string if consumed; null if incomplete; undefined if not bracketed paste at front
|
||||
*/
|
||||
function bareEditTryConsumeBracketedPaste(q) {
|
||||
const SL = BARE_EDIT_BRACKET_PASTE_START.length
|
||||
const EL = BARE_EDIT_BRACKET_PASTE_END.length
|
||||
if (!q.length) return undefined
|
||||
if (q.length < SL) {
|
||||
return bareEditCouldBeBracketPastePrefix(q) ? null : undefined
|
||||
}
|
||||
if (!bareEditStartsWithBytes(q, BARE_EDIT_BRACKET_PASTE_START)) {
|
||||
return undefined
|
||||
}
|
||||
for (let i = SL; i <= q.length - EL; i++) {
|
||||
let ok = true
|
||||
for (let j = 0; j < EL; j++) {
|
||||
if (q[i + j] !== BARE_EDIT_BRACKET_PASTE_END[j]) {
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if (ok) {
|
||||
const inner = q.slice(SL, i)
|
||||
q.splice(0, i + EL)
|
||||
return bareEditDecodePasteInner(inner)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number[]} q mutable queue (front = index 0)
|
||||
* @returns {Record<string, unknown> | null} null if more bytes needed
|
||||
@@ -881,10 +1007,42 @@ async function bareEditReadKey(reader) {
|
||||
reader._keyq = reader._keyq || []
|
||||
const q = reader._keyq
|
||||
for (;;) {
|
||||
const pasteText = bareEditTryConsumeBracketedPaste(q)
|
||||
if (pasteText !== undefined && pasteText !== null) {
|
||||
return { type: 'paste', text: pasteText }
|
||||
}
|
||||
if (pasteText === null) {
|
||||
const b = await reader.nextByte()
|
||||
if (b === undefined) {
|
||||
const eofPaste = bareEditFinalizeBracketedPasteOnEof(q)
|
||||
if (eofPaste !== undefined) {
|
||||
return { type: 'paste', text: eofPaste }
|
||||
}
|
||||
if (!q.length) return { type: 'eof' }
|
||||
if (q.length === 1 && q[0] === 27) {
|
||||
q.length = 0
|
||||
return { type: 'key', ch: '\x1b' }
|
||||
}
|
||||
const lone = q.shift()
|
||||
if (lone !== undefined && lone < 0x20) {
|
||||
return { type: 'ctrl', code: lone }
|
||||
}
|
||||
if (lone !== undefined) {
|
||||
return { type: 'key', ch: String.fromCharCode(lone) }
|
||||
}
|
||||
return { type: 'eof' }
|
||||
}
|
||||
q.push(b)
|
||||
continue
|
||||
}
|
||||
const ev = bareEditTryConsumeKey(q)
|
||||
if (ev) return ev
|
||||
const b = await reader.nextByte()
|
||||
if (b === undefined) {
|
||||
const eofPaste = bareEditFinalizeBracketedPasteOnEof(q)
|
||||
if (eofPaste !== undefined) {
|
||||
return { type: 'paste', text: eofPaste }
|
||||
}
|
||||
if (!q.length) return { type: 'eof' }
|
||||
if (q.length === 1 && q[0] === 27) {
|
||||
q.length = 0
|
||||
@@ -986,6 +1144,32 @@ async function bareOsRunEditTui(ctx, opts) {
|
||||
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'
|
||||
@@ -1117,6 +1301,8 @@ async function bareOsRunEditTui(ctx, opts) {
|
||||
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()
|
||||
@@ -1125,18 +1311,22 @@ async function bareOsRunEditTui(ctx, opts) {
|
||||
if (typeof stdin.setRawMode === 'function') stdin.setRawMode(true)
|
||||
if (typeof stdin.resume === 'function') stdin.resume()
|
||||
|
||||
const envEdit =
|
||||
ctx.env && typeof ctx.env === 'object'
|
||||
? /** @type {Record<string, string>} */ (ctx.env)
|
||||
: {}
|
||||
const bareEditNoAltScreen =
|
||||
envEdit.BARE_EDIT_NO_ALTSCREEN != null &&
|
||||
String(envEdit.BARE_EDIT_NO_ALTSCREEN) !== ''
|
||||
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 (;;) {
|
||||
@@ -1176,6 +1366,17 @@ async function bareOsRunEditTui(ctx, opts) {
|
||||
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
|
||||
@@ -1217,13 +1418,19 @@ async function bareOsRunEditTui(ctx, opts) {
|
||||
continue
|
||||
}
|
||||
if (ev.type === 'key' && ev.ch && ev.ch !== '\n' && ev.ch !== '\t') {
|
||||
if (ev.ch >= ' ') promptBuf += ev.ch
|
||||
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()
|
||||
@@ -1320,6 +1527,9 @@ async function bareOsRunEditTui(ctx, opts) {
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
if (useBracketPaste) {
|
||||
bareEditWrite(ctx, stdout, '\x1b[?2004l')
|
||||
}
|
||||
if (useAltScreen) {
|
||||
bareEditWrite(ctx, stdout, '\x1b[?1049l')
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user