/** 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 } /** * 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 */ 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) }