import { GetCurrentTerminal } from '../app' import { Item } from '../fs/Item' import type { SimpleStream } from '../utils/SimpleStream' import { Program } from './Program' type Viewport = { cols: number rows: number headerRows: number footerRows: number contentHeight: number } type PromptState = | { active: false } | { active: true; label: string; value: string; handler: (value: string) => Promise } export class Edit extends Program { constructor() { super() } async Exec(stdin: SimpleStream, stdout: SimpleStream, workdir: Item, args: string[]): Promise { if (args.length < 2) { stdout.emit('edit: error: missing path argument\n') return 1 } const terminal = GetCurrentTerminal() const resolvePath = (path: string) => Item.NormalizePath(path.startsWith('/') ? path : `${workdir.GetPath()}/${path}`) let filePath = resolvePath(args[1]) let file = await Item.open(filePath, '') let fileExists = await file.Exists() let lines = this.normalizeLines(file.GetData()) let dirty = false let cursorLine = 0 let cursorCol = 0 let preferredCol = 0 let scrollLine = 0 let scrollCol = 0 let statusMessage = '' let exitArmed = false let promptState: PromptState = { active: false } const getViewport = (): Viewport => { const cols = Math.max(1, Math.floor(terminal.GetWidthCells())) const rows = Math.max(1, Math.floor(terminal.GetHeightCells())) const headerRows = rows >= 3 ? 1 : 0 const footerRows = rows >= 2 ? 1 : 0 const contentHeight = Math.max(1, rows - headerRows - footerRows) return { cols, rows, headerRows, footerRows, contentHeight } } const formatLine = (text: string, width: number) => { if (text.length > width) return text.slice(0, width) return text.padEnd(width, ' ') } const clampCursor = () => { cursorLine = Math.max(0, Math.min(cursorLine, lines.length - 1)) const lineLength = lines[cursorLine].length cursorCol = Math.max(0, Math.min(cursorCol, lineLength)) preferredCol = cursorCol } const ensureCursorVisible = (viewport: Viewport) => { if (cursorLine < scrollLine) scrollLine = cursorLine if (cursorLine >= scrollLine + viewport.contentHeight) scrollLine = cursorLine - viewport.contentHeight + 1 if (cursorCol < scrollCol) scrollCol = cursorCol if (cursorCol >= scrollCol + viewport.cols) scrollCol = cursorCol - viewport.cols + 1 const maxScrollLine = Math.max(0, lines.length - viewport.contentHeight) scrollLine = Math.min(scrollLine, maxScrollLine) scrollCol = Math.max(0, scrollCol) } const setStatus = (message: string) => { statusMessage = message } const openFile = async (path: string) => { try { const resolved = resolvePath(path) const nextFile = await Item.open(resolved, '') const exists = await nextFile.Exists() const content = nextFile.GetData() filePath = resolved file = nextFile fileExists = exists lines = this.normalizeLines(content) dirty = false cursorLine = 0 cursorCol = 0 preferredCol = 0 scrollLine = 0 scrollCol = 0 setStatus(exists ? `Opened ${filePath}` : `New file ${filePath}`) } catch (e) { setStatus(`Open failed: ${e instanceof Error ? e.message : String(e)}`) } } const saveFile = async () => { const data = lines.join('\n') try { if (fileExists) { await file.Write(data) } else { const created = await Item.open(filePath, data) await created.Create() file = created fileExists = true } dirty = false exitArmed = false setStatus(`Saved ${filePath}`) } catch (e) { setStatus(`Save failed: ${e instanceof Error ? e.message : String(e)}`) } } const startPrompt = (label: string, handler: (value: string) => Promise) => { promptState = { active: true, label, value: '', handler } } const render = () => { const viewport = getViewport() const footerRow = viewport.headerRows + viewport.contentHeight + (viewport.footerRows ? 1 : 0) ensureCursorVisible(viewport) let output = '\x1b[2J' if (viewport.headerRows) { const headerText = `EDIT - ${filePath}${dirty ? ' *' : ''}` output += `\x1b[1;1H${formatLine(headerText, viewport.cols)}` } const contentStartRow = viewport.headerRows + 1 for (let i = 0; i < viewport.contentHeight; i++) { const lineIndex = scrollLine + i const lineText = lines[lineIndex] ?? '' const visible = lineText.slice(scrollCol, scrollCol + viewport.cols) output += `\x1b[${contentStartRow + i};1H${formatLine(visible, viewport.cols)}` } if (viewport.footerRows) { const positionText = `Ln ${cursorLine + 1}, Col ${cursorCol + 1}` const shortcuts = 'F2 Save F3 Open F10 Exit' const footerText = promptState.active ? `${promptState.label}${promptState.value}` : `${shortcuts} ${positionText}${statusMessage ? ` ${statusMessage}` : ''}` output += `\x1b[${footerRow};1H${formatLine(footerText, viewport.cols)}` } if (promptState.active) { const cursorRow = footerRow const cursorColPos = Math.min(viewport.cols, promptState.label.length + promptState.value.length + 1) output += `\x1b[${cursorRow};${cursorColPos}H` } else { const cursorRow = contentStartRow + (cursorLine - scrollLine) const cursorColPos = 1 + (cursorCol - scrollCol) output += `\x1b[${cursorRow};${cursorColPos}H` } stdout.emit(output) } render() while (true) { const key = await stdin.wait() if (promptState.active) { if (key === 'Escape') { promptState = { active: false } setStatus('Canceled') render() continue } if (key === 'Enter') { const { handler } = promptState const value = promptState.value.trim() promptState = { active: false } if (value.length > 0) { await handler(value) } else { setStatus('Canceled') } render() continue } if (key === 'Backspace') { const nextValue: string = promptState.value.slice(0, -1) promptState = { active: true, label: promptState.label, value: nextValue, handler: promptState.handler, } render() continue } if (key.length === 1) { const nextValue: string = promptState.value + key promptState = { active: true, label: promptState.label, value: nextValue, handler: promptState.handler, } render() } continue } if (exitArmed && key !== 'F10') exitArmed = false switch (key) { case 'F2': await saveFile() render() continue case 'F3': startPrompt('Open: ', openFile) render() continue case 'F10': if (dirty && !exitArmed) { exitArmed = true setStatus('Unsaved changes. Press F10 again to exit.') render() continue } stdout.emit('\x1b[2J\x1b[H') return 0 case 'ArrowLeft': if (cursorCol > 0) { cursorCol -= 1 } else if (cursorLine > 0) { cursorLine -= 1 cursorCol = lines[cursorLine].length } preferredCol = cursorCol render() continue case 'ArrowRight': { const lineLength = lines[cursorLine].length if (cursorCol < lineLength) { cursorCol += 1 } else if (cursorLine < lines.length - 1) { cursorLine += 1 cursorCol = 0 } preferredCol = cursorCol render() continue } case 'ArrowUp': cursorLine = Math.max(0, cursorLine - 1) cursorCol = Math.min(preferredCol, lines[cursorLine].length) render() continue case 'ArrowDown': cursorLine = Math.min(lines.length - 1, cursorLine + 1) cursorCol = Math.min(preferredCol, lines[cursorLine].length) render() continue case 'Home': cursorCol = 0 preferredCol = cursorCol render() continue case 'End': cursorCol = lines[cursorLine].length preferredCol = cursorCol render() continue case 'PageUp': { const viewport = getViewport() cursorLine = Math.max(0, cursorLine - viewport.contentHeight) cursorCol = Math.min(preferredCol, lines[cursorLine].length) render() continue } case 'PageDown': { const viewport = getViewport() cursorLine = Math.min(lines.length - 1, cursorLine + viewport.contentHeight) cursorCol = Math.min(preferredCol, lines[cursorLine].length) render() continue } case 'Backspace': if (cursorCol > 0) { const line = lines[cursorLine] lines[cursorLine] = line.slice(0, cursorCol - 1) + line.slice(cursorCol) cursorCol -= 1 preferredCol = cursorCol dirty = true } else if (cursorLine > 0) { const current = lines[cursorLine] cursorLine -= 1 cursorCol = lines[cursorLine].length lines[cursorLine] += current lines.splice(cursorLine + 1, 1) preferredCol = cursorCol dirty = true } render() continue case 'Delete': { const line = lines[cursorLine] if (cursorCol < line.length) { lines[cursorLine] = line.slice(0, cursorCol) + line.slice(cursorCol + 1) dirty = true } else if (cursorLine < lines.length - 1) { lines[cursorLine] = line + lines[cursorLine + 1] lines.splice(cursorLine + 1, 1) dirty = true } render() continue } case 'Enter': { const line = lines[cursorLine] const before = line.slice(0, cursorCol) const after = line.slice(cursorCol) lines[cursorLine] = before lines.splice(cursorLine + 1, 0, after) cursorLine += 1 cursorCol = 0 preferredCol = 0 dirty = true render() continue } case 'Tab': lines[cursorLine] = this.insertText(lines[cursorLine], cursorCol, ' ') cursorCol += 4 preferredCol = cursorCol dirty = true render() continue } if (key.length === 1) { lines[cursorLine] = this.insertText(lines[cursorLine], cursorCol, key) cursorCol += 1 preferredCol = cursorCol dirty = true render() continue } clampCursor() render() } } private normalizeLines(content: string | null): string[] { const normalized = (content ?? '').replace(/\r\n/g, '\n').replace(/\r/g, '\n') const lines = normalized.split('\n') return lines.length === 0 ? [''] : lines } private insertText(line: string, index: number, text: string): string { return line.slice(0, index) + text + line.slice(index) } }