diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a53cf4c..6efdc7ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **`/view`, a native read-only viewer** for files and completed output ([#397](https://github.com/raiseCatError/notMyShell/issues/397)). `/view README.md` opens a file relative to the shell's directory (`~` for home): Markdown renders (`m` shows the source), source files get syntax colors and line numbers, `.diff`/`.patch` files and `git diff`/`git show` output read as diffs. `/view N` opens completed output N (numbered as `/copy` numbers it) and `/view` the latest; Actions → View output does the same for a block. Find with `/` and `n`/`N`, go to a line with `:`, `w` toggles wrapping (Left/Right scroll sideways), `l` line numbers, `c` copies the whole text, Esc closes. Viewing never changes the file or the transcript. - Limits: regular files only (directories, devices and pipes are refused), at most 8 MiB and 200,000 lines (the rest is named, with `less` or an editor as the way to read it), binary and UTF-16 files are refused, invalid UTF-8 is shown with `�` and a note. Control characters, escape sequences and invisible format characters are shown as visible marks, never interpreted. Open in pager, `less`, `bat` and Glow are unchanged. +- **`/edit`, a small native editor** for one text file ([#402](https://github.com/raiseCatError/notMyShell/issues/402)). `/edit notes.md` (or `e` in `/view`) opens the file in a panel: type, select with Shift, Ctrl+Z / Ctrl+Y undo and redo, Ctrl+F find, Ctrl+C / Ctrl+X copy and cut, Ctrl+S save, Esc close (it asks about unsaved changes). A path that does not exist yet is created on the first save. It is for quick edits, not an IDE: `vim`, `nano`, `micro` and your `$EDITOR` still run as usual. + - Safe saving: the new text is written beside the file and renamed over it, keeping its mode, group, line endings, BOM and final newline; a symlink stays a symlink. If the file changed, was replaced or was deleted since you opened it, nothing is written until you choose Keep editing, Overwrite or Reload. A new file never replaces one that appeared meanwhile. + - Limits: your own regular UTF-8 text files up to 1 MiB with one hard link, writable, with one kind of line ending. Others are refused with the reason and stay readable in `/view`. Extended attributes and ACLs are not carried over by a save. Unsaved text is lost if the NMSh window itself is closed. ### Fixed diff --git a/docs/development/v021-ledger.md b/docs/development/v021-ledger.md index 4644df2d..e1358a5e 100644 --- a/docs/development/v021-ledger.md +++ b/docs/development/v021-ledger.md @@ -17,7 +17,7 @@ Automated results here are not physical-terminal validation. | 0 | Design and ledger | #410 | `docs/v021-design` / #411 | | PR open, CI green; awaiting merge | | 1 | Reliability gates | #403, #408, #388 | `fix/v021-reliability` (stacked on #411) | | PR open | | 2 | `/view` | #397, #402 | `feature/v021-view` (stacked on slice 1) | | PR open | -| 3 | `/edit` | #402 | | | | +| 3 | `/edit` | #402 | `feature/v021-edit` (stacked on slice 2) | | PR open | | 4 | Semantic Output | #386 | | | | | 5 | `/track` | #393 (#394) | | | | | 6 | Modules and diagnostics | #364, #365 | | | | @@ -35,7 +35,7 @@ Automated results here are not physical-terminal validation. Checklist: [v0.21 physical QA](../testing/v021-physical-qa.md). Slice 1: section 1 (multi-row progress in Ghostty and Terminal.app, queued highlight colors). Slice 2: section 2 (`/view` keys, rendering, refusals, hostile text, -NO_COLOR). +NO_COLOR). Slice 3: section 3 (`/edit` editing keys, saving, conflicts, refusals). ## Verification notes diff --git a/docs/testing/v021-physical-qa.md b/docs/testing/v021-physical-qa.md index 3cee9cac..8d03ff04 100644 --- a/docs/testing/v021-physical-qa.md +++ b/docs/testing/v021-physical-qa.md @@ -18,6 +18,8 @@ | 1 Multi-row progress (#388) | Terminal.app | macOS | Bash or Fish | | Not run | | | 2 `/view` | Ghostty | macOS | zsh | | Not run | | | 2 `/view` | Terminal.app | macOS | zsh | | Not run | | +| 3 `/edit` | Ghostty | macOS | zsh | | Not run | | +| 3 `/edit` | Terminal.app | macOS | Bash or Fish | | Not run | | ## 1. Multi-row progress redraws (#388) @@ -50,3 +52,20 @@ - [ ] 2.8 Esc closes, the composer draft and cursor are where they were, and a running command's output kept arriving underneath. At 32×10 the viewer still fits and Esc is shown. - [ ] 2.9 `NO_COLOR=1` and Safe glyphs: matches and the current match are still visible (reverse video). + +## 3. Native editor (`/edit`) + +- [ ] 3.1 `/edit notes.txt` in an empty folder: it says *new file*; type two lines with Enter and a Tab, Ctrl+S says + *Saved*, Esc returns to the composer, `cat -A notes.txt` shows the tab and a final newline. +- [ ] 3.2 Shift+arrows, Shift+Alt+arrows and Cmd+Shift+arrows select; typing replaces the selection; Ctrl+C copies + and Ctrl+X cuts it (paste it back with the terminal's paste); Ctrl+Z undoes a typed word in one step, Ctrl+Y redoes. +- [ ] 3.3 Ctrl+F finds (lowercase ignores case), Enter again finds the next match. +- [ ] 3.4 Edit, then Esc: the question offers Save, Discard changes and Keep editing; each does what it says. +- [ ] 3.5 Conflict: `/edit notes.txt`, change it from another terminal (`echo x >> notes.txt`), then Ctrl+S: nothing is + written until you choose; Overwrite writes yours, Reload shows theirs. +- [ ] 3.6 A CRLF file with a BOM keeps both after saving (`xxd file | head -1`); `chmod 640` survives a save; editing + through a symlink keeps the symlink. +- [ ] 3.7 Refusals: a binary (`/edit /bin/ls`), a root-owned file (`/edit /etc/hosts`), a 2 MB file, a read-only file + (`chmod 444`) each explain why, and `/view` still opens them. +- [ ] 3.8 `e` in `/view README.md` opens the editor on the same file; `vim notes.txt` and `nano notes.txt` still run as + usual from the composer. diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index c04748b5..a3915804 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -70,6 +70,8 @@ import {configuredEditor} from '../host/HostActions.js'; import {comparePanelKey, createComparePanel, renderComparePanel, type ComparePanelState} from '../ui/ComparePanel.js'; import {loadFileDocument, outputDocument} from '../viewer/document.js'; import {createViewer, renderViewer, viewerKey, type ViewerLook, type ViewerState} from '../viewer/ViewerPanel.js'; +import {createEditor, editorKey, renderEditor, type EditorAction, type EditorState} from '../viewer/EditorPanel.js'; +import {openForEdit, saveFile} from '../viewer/fileEdit.js'; import {buildComparisonReport, copyableDiff, previousRun} from '../output/compare.js'; import {createStripStudio, renderStripStudio, stripStudioKey, type StripStudioState} from '../status/StripStudio.js'; import {configureListKey, renderConfigureList, type ConfigureListState} from '../tools/config/ConfigureList.js'; @@ -1622,11 +1624,20 @@ export class TerminalApp { this.render(); return; } + if (this.textEditor) { + const editor = this.textEditor; + const {columns, rows} = this.dimensions(); + const action = editorKey(editor, key, columns, rows - 4); + if (action) this.runEditorAction(editor, action); + this.render(); + return; + } if (this.viewer) { const viewer = this.viewer; const {columns, rows} = this.dimensions(); const action = viewerKey(viewer, key, columns, rows - 4, this.viewerLook()); if (action?.kind === 'close') { this.viewer = undefined; this.returnFromPanel(); } + else if (action?.kind === 'edit') { this.viewer = undefined; this.openTextEditor(action.path, '/edit', viewer); } else if (action?.kind === 'copy') { void this.copyText(action.text, 'Copied the document').then(note => { if (note && this.viewer === viewer) { viewer.note = note; this.render(); } }); } @@ -2887,6 +2898,10 @@ export class TerminalApp { } else if (slash.kind === 'pins') { this.panelOrigin = undefined; this.openPins(); } else if (slash.kind === 'view') this.openViewer(slash.target, command.trim()); + else if (slash.kind === 'editFile') { + if (!slash.target) this.output.addFrontendInteraction(command.trim(), 'Name a file: /edit notes.md. Your own editor and vim, nano or micro still work as usual.', INFO); + else this.openTextEditor(slash.target, command.trim()); + } else if (slash.kind === 'compare') { if (slash.error) this.output.addFrontendInteraction(command.trim(), slash.error, ERROR); else { @@ -3603,6 +3618,61 @@ export class TerminalApp { }); } + /** + * /edit: open one small text file in NMSh's editor, or say in the transcript why it cannot be edited (refused files + * stay readable in /view). `returnTo` is the viewer it was opened from; it is reopened if the file cannot be edited. + */ + private openTextEditor(target: string, command: string, returnTo?: ViewerState): void { + void openForEdit(target, this.shellCwd).then(result => { + if (this.stopped) return; + if (!result.ok) { + if (returnTo && !this.hasModalInput()) { returnTo.note = result.reason; this.viewer = returnTo; } + else this.output.addFrontendInteraction(command, result.reason, ERROR); + this.render(); + return; + } + if (this.hasModalInput()) return; + this.panelOrigin = undefined; + this.textEditor = createEditor(result.file); + this.render(); + }); + } + + /** The editor's requests that touch the disk or the clipboard; the panel shows the outcome. */ + private runEditorAction(editor: EditorState, action: EditorAction): void { + if (action.kind === 'close') { this.textEditor = undefined; this.returnFromPanel(); return; } + if (action.kind === 'copy') { + void this.copyText(action.text, 'Copied').then(note => { if (note && this.textEditor === editor) { editor.note = {text: note, tone: 'success'}; this.render(); } }); + return; + } + editor.busy = true; + if (action.kind === 'reload') { + void openForEdit(editor.file.path, this.shellCwd).then(result => { + editor.busy = false; + if (this.textEditor !== editor) return; + if (!result.ok) { editor.note = {text: result.reason, tone: 'error'}; this.render(); return; } + this.textEditor = {...createEditor(result.file), note: {text: 'Reloaded from disk; your changes were discarded.', tone: 'info'}}; + this.render(); + }); + return; + } + const lines = [...editor.buffer.lines]; + void saveFile(editor.file, lines, {...(action.force ? {force: true} : {})}).then(result => { + editor.busy = false; + if (this.textEditor !== editor) return; + if (result.ok) { + editor.file = result.file; + // Only what was written is clean: typing during the save stays unsaved. + if (editor.buffer.lines.length === lines.length && editor.buffer.lines.every((line, index) => line === lines[index])) editor.buffer.markSaved(); + if (action.thenClose) { this.textEditor = undefined; this.returnFromPanel(); } + else editor.note = {text: `Saved ${editor.file.title}.`, tone: 'success'}; + } else if (result.conflict) { + editor.dialog = {kind: 'conflict', focus: 0, conflict: result.conflict, reason: result.reason, ...(action.thenClose ? {thenClose: true} : {})}; + } else editor.note = {text: `${result.reason} Your text is still here.`, tone: 'error'}; + this.render(); + }); + } + /** View output: the record's stored output, numbered as /copy numbers it. */ private openOutputViewer(record: CompletedCommand): void { const number = this.output.recentShellCommands().indexOf(record) + 1; @@ -4964,7 +5034,7 @@ export class TerminalApp { private get settingsPanelActive(): boolean { return this.hasModalInput(); } private hasModalInput(ignorePalette = false): boolean { - return Boolean(this.stopsEditor || this.chromeEditor || this.screensaverPanel || this.themeStudio || this.themeBridgePanel || this.rowPanel || this.copyPicker || this.summaryReview || this.reportReview || this.comparePanel || this.viewer || this.background.panel || this.batchReview || this.pinsPanel || this.stripStudio || this.configureList || this.tmuxPanel || this.dotfiles || this.setupState || this.installPrompt || this.presetPanel || this.toolsPanel || this.toolConfigurationLoading || this.toolConfiguration || this.promptPanelState || this.transcriptPanelState || this.providerPanelState || (!ignorePalette && this.paletteState) || this.syntaxPanelState || this.layoutPanelState || this.settingsPanelState + return Boolean(this.stopsEditor || this.chromeEditor || this.screensaverPanel || this.themeStudio || this.themeBridgePanel || this.rowPanel || this.copyPicker || this.summaryReview || this.reportReview || this.comparePanel || this.viewer || this.textEditor || this.background.panel || this.batchReview || this.pinsPanel || this.stripStudio || this.configureList || this.tmuxPanel || this.dotfiles || this.setupState || this.installPrompt || this.presetPanel || this.toolsPanel || this.toolConfigurationLoading || this.toolConfiguration || this.promptPanelState || this.transcriptPanelState || this.providerPanelState || (!ignorePalette && this.paletteState) || this.syntaxPanelState || this.layoutPanelState || this.settingsPanelState || this.resumeBrowser || this.appearanceHub || this.keyboardState || this.startupPanel || this.aboutPanel || this.shellPanel || this.openPanel || this.askState || this.agentView || this.agentPanel || this.launcher || this.modsPanel || this.worktreePanel || this.githubPanel || this.providersOverview || this.understandingPanel || this.cursorPanel || this.doctorPanel || this.watchPanel || this.queuePanel || this.queueHistoryPanel || this.pasteReview || this.misePanel || this.keepAwakePanel); } @@ -5007,6 +5077,7 @@ export class TerminalApp { if (this.reportReview) return renderReportReview(this.reportReview, columns, this.dimensions().rows - 4); if (this.comparePanel) return renderComparePanel(this.comparePanel, columns, this.dimensions().rows - 4); if (this.viewer) return renderViewer(this.viewer, columns, this.dimensions().rows - 4, this.viewerLook()); + if (this.textEditor) return renderEditor(this.textEditor, columns, this.dimensions().rows - 4); if (this.background.panel) return framePanel(this.background.panelRows(Math.max(1, columns - 4), this.dimensions().rows - 4, {now: Date.now(), color: colorLevel() !== 'none', safe: getCurrentGlyphMode() === 'safe'}), columns); if (this.rowPanel) return renderRowPanel(this.rowPanel, this.promptConfiguration, columns, this.dimensions().rows, this.rowPanelPreview(columns)); @@ -5939,6 +6010,8 @@ export class TerminalApp { private viewer?: ViewerState; /** A file /view is still reading; a newer request or Esc cancels it. */ private viewerLoad?: AbortController; + /** NMSh's small text editor (/edit, e in /view); owns keys while open. */ + private textEditor?: EditorState; private clipboardNote?: {text: string; tone: 'success' | 'info' | 'error'; until: number}; private clipboardNoteTimer?: ReturnType; /** The block whose Copy control just succeeded (by startId), shown as "Copied" until `until`. */ diff --git a/src/ask/concepts.ts b/src/ask/concepts.ts index 0428b879..c5bdbda4 100644 --- a/src/ask/concepts.ts +++ b/src/ask/concepts.ts @@ -256,6 +256,9 @@ export const CONCEPTS: readonly Concept[] = [ {id: 'view', label: 'View a file or output', support: 'actionable', open: '/view', covers: ['/view'], aliases: ['view file', 'view a file', 'read a file', 'open a file read only', 'view output', 'read markdown', 'preview markdown', 'view log', 'pager'], description: '/view opens a read-only native viewer: /view README.md for a file (Markdown renders; m shows the source), /view N for completed output N (numbered as /copy), /view alone for the latest output. Also Actions → View output. Find with /, n/N, go to a line with :, w wraps, l numbers, c copies the whole text. less, bat and Glow still work as usual.'}, + {id: 'edit-file', label: 'Edit a small text file', support: 'actionable', open: '/edit', covers: ['/edit'], + aliases: ['edit a file', 'edit file', 'edit text file', 'change a config file', 'quick edit', 'small editor', 'native editor', 'edit notes'], + description: '/edit notes.md opens a small text file (up to 1 MiB, your own, UTF-8) in NMSh\'s editor: type, select with Shift, Ctrl+Z undo, Ctrl+Y redo, Ctrl+F find, Ctrl+C/Ctrl+X copy and cut, Ctrl+S saves (atomically; if the file changed on disk you choose Overwrite, Reload or Keep editing), Esc closes and asks about unsaved changes. e in /view edits the file shown. vim, nano, micro and your $EDITOR still work as usual.'}, {id: 'paste-batch', label: 'Paste and queue a batch', support: 'actionable', open: '/ps', covers: ['/ps', '/clips'], aliases: ['paste commands', 'paste batch', 'paste from clipboard', 'queue pasted commands', 'review pasted commands', 'ps command', 'run as block'], description: '/ps reads the clipboard once and shows Paste Review. B reviews a safe batch with choices to run as one block, queue separately, edit or cancel; quotes, loops and heredocs stay whole. C deliberately keeps a clip in memory; /clips reviews it before insertion. Clips disappear when this frontend exits. Nothing executes automatically.'}, @@ -367,7 +370,7 @@ export const GUIDE_SECTIONS: readonly GuideSection[] = [ {id: 'chroma', title: 'Chroma', why: 'Optional gradients and motion for NMSh-owned chrome only; your command output is never recolored.', concepts: ['chroma', 'effects', 'activity']}, {id: 'shells', title: 'Shells', why: 'One NMSh window can run zsh, Fish or Bash, and you can leave for a plain shell and come back.', concepts: ['shell', 'leave', 'otherShells']}, {id: 'completion', title: 'Completion & suggestions', why: 'Tab completion lists real candidates; ghost suggestions predict the rest of the line.', concepts: ['completion', 'suggestions']}, - {id: 'history', title: 'History & transcripts', why: 'Find what you ran and what it printed, now or in earlier sessions.', concepts: ['history', 'picker', 'transcript', 'find', 'copy', 'output-actions', 'view', 'compare', 'transcripts', 'pins']}, + {id: 'history', title: 'History & transcripts', why: 'Find what you ran and what it printed, now or in earlier sessions.', concepts: ['history', 'picker', 'transcript', 'find', 'copy', 'output-actions', 'view', 'edit-file', 'compare', 'transcripts', 'pins']}, {id: 'sessions', title: 'Sessions', why: 'Sessions keep running when a window closes; reattach, switch or get notified.', concepts: ['sessions', 'queue', 'agentSessions', 'presets', 'notices', 'commandNotifications', 'panes', 'keepAwake']}, {id: 'ask', title: 'Ask', why: 'Plain-English help that knows NMSh, your commands and this repository, and never runs anything you did not confirm.', concepts: ['ask']}, {id: 'intelligence', title: 'Local intelligence', why: 'An optional local model helps Ask map vague requests to known actions. It runs on this machine, never writes commands, and Ask works fully without it.', concepts: ['understanding'], diff --git a/src/commands/slashCommands.ts b/src/commands/slashCommands.ts index b719d050..c05244ea 100644 --- a/src/commands/slashCommands.ts +++ b/src/commands/slashCommands.ts @@ -42,6 +42,7 @@ const RAW_COMMANDS: readonly SlashCommand[] = [ {name: '/copy N', insertion: '/copy ', description: 'Copy Nth previous output; -N the latest N, A-B or 1,3,5 several; --status adds completion status'}, {name: '/copy settings', insertion: '/copy settings', description: 'Output navigation, Quick Copy or Interactive Picker; completion status and auto-expand preferences'}, {name: '/view', insertion: '/view ', description: 'Read a file or a completed output in a native viewer: /view README.md · /view 2 (output 2, numbered as /copy) · /view (latest output)'}, + {name: '/edit', insertion: '/edit ', description: 'Edit a small text file in NMSh: /edit notes.md (Ctrl+S saves safely, Esc closes; vim, nano and your editor still work as usual)'}, {name: '/compare', insertion: '/compare', description: 'Compare two outputs: /compare (latest with its previous run), /compare N, /compare N M (numbers as /copy)'}, {name: '/pins', insertion: '/pins', description: 'Commands and recipes you kept: stage a pin, or review and queue a recipe (nothing runs by itself)'}, {name: '/ps', insertion: '/ps', description: 'Paste from the clipboard, reviewed first: read when you run it, never in the background'}, @@ -171,6 +172,8 @@ export type ParsedSlashCommand = | {kind: 'compare'; first?: number; second?: number; error?: string} /** /view [path | N]: a file (relative to the shell's directory, ~ for home), output N as /copy numbers it, or the latest output. */ | {kind: 'view'; target: string} + /** /edit : NMSh's small native editor for one text file (relative to the shell's directory, ~ for home). */ + | {kind: 'editFile'; target: string} | {kind: 'appearance'} | {kind: 'motion'} | {kind: 'prompt'} @@ -268,6 +271,8 @@ export function parseSlashCommand(input: string): ParsedSlashCommand | undefined if (/^\/pins\s*$/u.test(input)) return {kind: 'pins'}; const view = /^\/view(?:\s+([\s\S]*))?$/u.exec(input); if (view) return {kind: 'view', target: (view[1] ?? '').trim()}; + const editFile = /^\/edit(?:\s+([\s\S]*))?$/u.exec(input); + if (editFile) return {kind: 'editFile', target: (editFile[1] ?? '').trim()}; // /cp is /copy: one parser, one handler, every form. if (/^\/(?:copy|cp)\s+settings\s*$/u.test(input)) return {kind: 'copySettings'}; const copy = /^\/(?:copy|cp)(?:\s+(.*))?$/su.exec(input); diff --git a/src/terminal/keys.ts b/src/terminal/keys.ts index c10baa2f..a1a73f08 100644 --- a/src/terminal/keys.ts +++ b/src/terminal/keys.ts @@ -8,7 +8,9 @@ export type Key = | {kind: 'bufferHome' | 'bufferEnd' | 'selectBufferHome' | 'selectBufferEnd'} | {kind: 'historySearch' | 'historyDelete' | 'find' | 'previousFailure' | 'nextFailure'} | {kind: 'suggestNext' | 'suggestPrevious' | 'palette'} | {kind: 'pageUp' | 'pageDown' | 'latest' | 'interrupt' | 'suspend' | 'eof' | 'wheelUp' | 'wheelDown' | 'mouseMove' | 'mouseClick' | 'mouseDrag' | 'mouseRelease' | 'focusPrevious' | 'focusNext' | 'toggleDetails' /** Ctrl+Q: add the composer's command to the queue; Ctrl+S: send it to the running program's input. */ - | 'queueCommand' | 'sendToProgram'} & {x?: number; y?: number}; + | 'queueCommand' | 'sendToProgram' + /** Ctrl+Y: redo in NMSh's text editor (the composer ignores it, as it ignored the unmapped byte before). */ + | 'redo'} & {x?: number; y?: number}; const SEQUENCES: Array<[string, Key['kind']]> = [ @@ -106,6 +108,7 @@ const SEQUENCES: Array<[string, Key['kind']]> = [ ['\u001B[112;5u', 'suggestPrevious'], // Kitty Ctrl+P ['\u001B[113;5u', 'queueCommand'], // Kitty Ctrl+Q ['\u001B[115;5u', 'sendToProgram'], // Kitty Ctrl+S + ['\u001B[121;5u', 'redo'], // Kitty Ctrl+Y ['\u001B[111;5u', 'toggleDetails'], // Kitty Ctrl+O (lowercase o) ['\u001B[79;5u', 'toggleDetails'], // Kitty Ctrl+O (uppercase O) ]; @@ -180,6 +183,7 @@ export function decodeKeys(input: string): Key[] { else if (value === '\u000F') keys.push({kind: 'toggleDetails'} as Key); // Ctrl+O else if (value === '\u0011') keys.push({kind: 'queueCommand'} as Key); // Ctrl+Q: queue (the TTY is raw; no flow control) else if (value === '\u0013') keys.push({kind: 'sendToProgram'} as Key); // Ctrl+S: send to the running program + else if (value === '\u0019') keys.push({kind: 'redo'} as Key); // Ctrl+Y: redo in the NMSh editor else if (value === '\u000E') keys.push({kind: 'suggestNext'} as Key); // Ctrl+N else if (value === '\u0010') keys.push({kind: 'suggestPrevious'} as Key); // Ctrl+P else if (codePoint >= 0x20 && codePoint !== 0x7f) keys.push({kind: 'text', value} as Key); diff --git a/src/viewer/EditorPanel.ts b/src/viewer/EditorPanel.ts new file mode 100644 index 00000000..40b51920 --- /dev/null +++ b/src/viewer/EditorPanel.ts @@ -0,0 +1,288 @@ +import type {Key} from '../terminal/keys.js'; +import {graphemes} from '../input/inputLayout.js'; +import {colorLevel} from '../presentation/capabilities.js'; +import {renderActionRow, type ActionControl} from '../ui/actionControls.js'; +import {renderControlRows} from '../ui/controls.js'; +import {editText} from '../ui/formControls.js'; +import {framePanel} from '../ui/PanelShell.js'; +import {foreground, UI_COLORS} from '../ui/palette.js'; +import {displayWidth, truncateAnsi, truncateText} from '../util/text.js'; +import {inert} from './document.js'; +import type {EditableFile, SaveConflict} from './fileEdit.js'; +import {displayColumn, TAB_WIDTH, TextBuffer, type Position} from './textBuffer.js'; + +/** + * The native editor (#402): one small text file in a full-height panel. It owns keys only while open; the composer, + * its draft and the shell are untouched. Saving and reloading are the app's work (they touch the disk); the panel says + * what the person asked for and shows the result. Unsaved changes are never dropped without a choice. + */ + +const RESET = '\u001b[0m'; +const REVERSE = '\u001b[7m'; +const WHEEL_ROWS = 3; + +export interface EditorDialog { + kind: 'unsaved' | 'conflict'; + focus: number; + conflict?: SaveConflict; + reason?: string; + /** Close once the save this dialog leads to succeeds. */ + thenClose?: boolean; +} + +export interface EditorState { + file: EditableFile; + buffer: TextBuffer; + top: number; + left: number; + input?: {kind: 'find'; value: string}; + query?: string; + dialog?: EditorDialog; + note?: {text: string; tone: 'info' | 'error' | 'success'}; + /** A save or reload is in progress; keys wait. */ + busy?: boolean; +} + +export type EditorAction = + | {kind: 'close'} + | {kind: 'save'; force?: boolean; thenClose?: boolean} + | {kind: 'reload'} + | {kind: 'copy'; text: string}; + +export function createEditor(file: EditableFile): EditorState { + return {file, buffer: new TextBuffer(file.lines), top: 0, left: 0, ...(file.identity ? {} : {note: {text: 'New file: it is created when you save.', tone: 'info' as const}})}; +} + +function dialogControls(dialog: EditorDialog): ActionControl[] { + const focused = (index: number, destructive = false) => dialog.focus === index ? (destructive ? 'destructiveFocused' : 'focused') : destructive ? 'destructive' : 'default'; + if (dialog.kind === 'unsaved') return [ + {id: 'Action.Save', label: 'Save', state: focused(0), key: 's', essential: true}, + {id: 'Action.Discard', label: 'Discard changes', state: focused(1, true), key: 'd', essential: true}, + {id: 'Action.Keep', label: 'Keep editing', state: focused(2), key: 'Esc', essential: true}, + ]; + return [ + {id: 'Action.Keep', label: 'Keep editing', state: focused(0), key: 'Esc', essential: true}, + {id: 'Action.Overwrite', label: dialog.conflict === 'deleted' ? 'Save anyway' : 'Overwrite', state: focused(1, true), key: 'o', essential: true}, + ...(dialog.conflict === 'deleted' || dialog.conflict === 'exists' ? [] : [{id: 'Action.Reload', label: 'Reload, discard mine', state: focused(2, true), key: 'r', essential: true} as ActionControl]), + ]; +} + +function dialogKey(state: EditorState, dialog: EditorDialog, key: Key): EditorAction | undefined { + const controls = dialogControls(dialog); + const text = key.kind === 'text' ? key.value.toLowerCase() : undefined; + if (key.kind === 'left' || key.kind === 'focusPrevious') { dialog.focus = (dialog.focus + controls.length - 1) % controls.length; return undefined; } + if (key.kind === 'right' || key.kind === 'complete') { dialog.focus = (dialog.focus + 1) % controls.length; return undefined; } + const chosen = key.kind === 'enter' ? controls[dialog.focus]!.id + : key.kind === 'escape' || key.kind === 'interrupt' ? 'Action.Keep' + : controls.find(control => control.key && control.key.length === 1 && control.key === text)?.id; + if (!chosen) return undefined; + state.dialog = undefined; + if (chosen === 'Action.Keep') return undefined; + if (chosen === 'Action.Save') return {kind: 'save', thenClose: true}; + if (chosen === 'Action.Discard') return {kind: 'close'}; + if (chosen === 'Action.Overwrite') return {kind: 'save', force: true, ...(dialog.thenClose ? {thenClose: true} : {})}; + return {kind: 'reload'}; +} + +/** The rows the text area has (the frame, header, status and footer take the rest). */ +function textRows(state: EditorState, columns: number, height: number): number { + return Math.max(1, height - 2 - 1 - 1 - footerRows(state, columns).length); +} + +function gutter(state: EditorState): number { + return Math.max(3, String(state.buffer.lines.length).length) + 1; +} + +function textWidth(state: EditorState, columns: number): number { + return Math.max(4, columns - 4 - gutter(state)); +} + +/** Keep the caret on screen, with a little room around it. */ +function reveal(state: EditorState, columns: number, height: number): void { + const rows = textRows(state, columns, height); + const width = textWidth(state, columns); + const caret = state.buffer.caret; + if (caret.line < state.top) state.top = caret.line; + else if (caret.line >= state.top + rows) state.top = caret.line - rows + 1; + const column = displayColumn(state.buffer.lines[caret.line]!, caret.column); + const margin = Math.min(8, Math.floor(width / 4)); + if (column < state.left) state.left = Math.max(0, column - margin); + else if (column >= state.left + width) state.left = column - width + 1 + margin; +} + +export function editorKey(state: EditorState, key: Key, columns: number, height: number): EditorAction | undefined { + if (state.busy) return undefined; + if (state.dialog) return dialogKey(state, state.dialog, key); + const buffer = state.buffer; + if (state.input) { + const input = state.input; + if (key.kind === 'escape' || key.kind === 'interrupt') { state.input = undefined; return undefined; } + if (key.kind === 'enter') { + state.input = undefined; + state.query = input.value; + const found = buffer.find(input.value); + state.note = !input.value ? undefined : found ? (found.wrapped ? {text: 'Search wrapped to the top.', tone: 'info'} : undefined) : {text: `No match for “${input.value}”.`, tone: 'info'}; + reveal(state, columns, height); + return undefined; + } + const next = editText(input.value, key); + if (next !== undefined) input.value = next.slice(0, 200); + return undefined; + } + state.note = undefined; + const rows = textRows(state, columns, height); + switch (key.kind) { + case 'escape': + if (buffer.dirty) { state.dialog = {kind: 'unsaved', focus: 0}; return undefined; } + return {kind: 'close'}; + case 'sendToProgram': return {kind: 'save'}; + case 'suspend': if (!buffer.undo()) state.note = {text: 'Nothing to undo.', tone: 'info'}; break; + case 'redo': if (!buffer.redo()) state.note = {text: 'Nothing to redo.', tone: 'info'}; break; + case 'find': state.input = {kind: 'find', value: state.query ?? ''}; return undefined; + case 'interrupt': { + const selected = buffer.selectedText(); + if (selected) return {kind: 'copy', text: selected}; + state.note = {text: 'Esc closes · Ctrl+S saves · select text to copy it.', tone: 'info'}; + return undefined; + } + case 'historyDelete': { + const selected = buffer.selectedText(); + if (!selected) { state.note = {text: 'Select text to cut it.', tone: 'info'}; return undefined; } + buffer.insert(''); + reveal(state, columns, height); + return {kind: 'copy', text: selected}; + } + case 'text': buffer.insert(key.value); break; + case 'paste': buffer.insert(key.value); break; + case 'enter': case 'newline': buffer.insert('\n'); break; + case 'complete': buffer.insert('\t'); break; + case 'backspace': buffer.backspace(); break; + case 'delete': buffer.delete(); break; + case 'deleteWord': buffer.deleteWord(); break; + case 'deleteLineBefore': buffer.deleteLineBefore(); break; + case 'deleteLineAfter': buffer.deleteLineAfter(); break; + case 'left': buffer.left(); break; + case 'right': buffer.right(); break; + case 'up': buffer.vertical(-1); break; + case 'down': buffer.vertical(1); break; + case 'selectLeft': buffer.left(true); break; + case 'selectRight': buffer.right(true); break; + case 'selectUp': buffer.vertical(-1, true); break; + case 'selectDown': buffer.vertical(1, true); break; + case 'wordLeft': buffer.wordLeft(); break; + case 'wordRight': buffer.wordRight(); break; + case 'selectWordLeft': buffer.wordLeft(true); break; + case 'selectWordRight': buffer.wordRight(true); break; + case 'lineHome': buffer.home(); break; + case 'lineEnd': buffer.end(); break; + case 'selectLineHome': buffer.home(true); break; + case 'selectLineEnd': buffer.end(true); break; + case 'bufferHome': buffer.bufferHome(); break; + case 'bufferEnd': case 'latest': buffer.bufferEnd(); break; + case 'selectBufferHome': buffer.bufferHome(true); break; + case 'selectBufferEnd': buffer.bufferEnd(true); break; + case 'selectAll': buffer.selectAll(); break; + case 'pageUp': buffer.vertical(-Math.max(1, rows - 1)); break; + case 'pageDown': buffer.vertical(Math.max(1, rows - 1)); break; + case 'wheelUp': state.top = Math.max(0, state.top - WHEEL_ROWS); return undefined; + case 'wheelDown': state.top = Math.max(0, Math.min(buffer.lines.length - rows, state.top + WHEEL_ROWS)); return undefined; + default: return undefined; + } + reveal(state, columns, height); + return undefined; +} + +// ---- Drawing ------------------------------------------------------------------------------------------------------ + +interface Cell {text: string; width: number; offset: number} + +/** A line's drawn cells from display column 0: tabs as spaces, unsafe characters as marks, each with its offset. */ +function lineCells(line: string, limit: number): Cell[] { + const cells: Cell[] = []; + let column = 0; + let offset = 0; + for (const character of graphemes(line)) { + if (column >= limit) break; + if (character === '\t') { + const spaces = TAB_WIDTH - (column % TAB_WIDTH); + for (let index = 0; index < spaces; index += 1) cells.push({text: ' ', width: 1, offset}); + column += spaces; + } else { + const shown = inert(character); + const width = displayWidth(shown); + cells.push({text: shown, width, offset}); + column += width; + } + offset += character.length; + } + return cells; +} + +const inside = (position: Position, start: Position, end: Position) => + (position.line > start.line || (position.line === start.line && position.column >= start.column)) && (position.line < end.line || (position.line === end.line && position.column < end.column)); + +function drawLine(state: EditorState, index: number, width: number): string { + const line = state.buffer.lines[index]!; + const selection = state.buffer.selection; + const caret = state.buffer.caret; + const cells = lineCells(line, state.left + width); + let out = ''; + let used = 0; + let skipped = 0; + let caretDrawn = caret.line !== index; + for (const cell of cells) { + if (skipped < state.left) { skipped += cell.width; continue; } + if (used + cell.width > width) break; + const position = {line: index, column: cell.offset}; + const atCaret = !caretDrawn && cell.offset === caret.column; + const selected = selection && inside(position, selection.start, selection.end); + out += atCaret || selected ? `${REVERSE}${cell.text}${RESET}` : cell.text; + if (atCaret) caretDrawn = true; + used += cell.width; + } + // The caret at the end of the line (or of the visible part) is a reverse-video space. + if (!caretDrawn && used < width) out += `${REVERSE} ${RESET}`; + return out; +} + +function footerRows(state: EditorState, columns: number): string[] { + const width = Math.max(1, columns - 4); + if (state.dialog) { + const dialog = state.dialog; + const message = dialog.kind === 'unsaved' ? `${state.file.title} has unsaved changes.` : dialog.reason ?? `${state.file.title} changed on disk.`; + const accent = foreground(UI_COLORS.warning); + const actions = renderActionRow(dialogControls(dialog), {columns: width, keys: true, color: colorLevel() !== 'none'}).rows; + return [`${accent}${truncateText(message, width)}${RESET}`, ...actions]; + } + if (state.input) return renderControlRows([['Enter', 'find'], ['Esc', 'cancel']], width); + return renderControlRows([['Ctrl+S', 'save'], ['Esc', 'close'], ['Ctrl+Z', 'undo'], ['Ctrl+Y', 'redo'], ['Ctrl+F', 'find'], ['Ctrl+C', 'copy'], ['Ctrl+X', 'cut']], width); +} + +export function renderEditor(state: EditorState, columns: number, height: number): string[] { + const width = textWidth(state, columns); + const rows = textRows(state, columns, height); + state.top = Math.max(0, Math.min(state.top, Math.max(0, state.buffer.lines.length - 1))); + const primary = foreground(UI_COLORS.primary); + const subtle = foreground(UI_COLORS.subtle); + const warning = foreground(UI_COLORS.warning); + const dirty = state.buffer.dirty; + const detail = [`${state.buffer.lines.length.toLocaleString('en-US')} lines`, state.file.lineEnding === 'crlf' ? 'CRLF' : '', state.file.bom ? 'BOM' : '', state.file.identity ? '' : 'new file'].filter(Boolean).join(' · '); + const title = truncateText(state.file.title, Math.max(8, Math.floor((columns - 4) * 0.6))); + const out = [` ${primary}${title}${RESET}${dirty ? `${warning} ● edited${RESET}` : ''} ${subtle}${truncateText(detail, Math.max(1, columns - 8 - displayWidth(title) - (dirty ? 9 : 0)))}${RESET}`]; + const number = gutter(state); + for (let offset = 0; offset < rows; offset += 1) { + const index = state.top + offset; + if (index >= state.buffer.lines.length) { out.push(` ${subtle}${'~'.padStart(number - 1)}${RESET}`); continue; } + out.push(` ${subtle}${String(index + 1).padStart(number - 1)}${RESET} ${drawLine(state, index, width)}`); + } + const caret = state.buffer.caret; + const position = `Ln ${caret.line + 1}, Col ${displayColumn(state.buffer.lines[caret.line]!, caret.column) + 1}`; + const selected = state.buffer.selection ? ` · ${state.buffer.selectedText().length.toLocaleString('en-US')} selected` : ''; + const status = state.input + ? `${foreground(UI_COLORS.accent)}Find: ${RESET}${truncateText(inert(state.input.value), Math.max(1, columns - 12))}${foreground(UI_COLORS.accent)}▏${RESET}` + : `${subtle}${truncateText(`${position}${selected}${state.busy ? ' · saving…' : ''}`, Math.max(1, columns - 4))}${RESET}${state.note ? ` ${state.note.tone === 'error' ? foreground(UI_COLORS.failure) : state.note.tone === 'success' ? foreground(UI_COLORS.success) : subtle}${truncateText(inert(state.note.text), Math.max(1, columns - 8 - displayWidth(position + selected)))}${RESET}` : ''}`; + out.push(` ${status}`, ...footerRows(state, columns).map(row => ` ${row}`)); + const rule = framePanel([], columns)[0]!; + if (height <= 1) return [rule].slice(0, Math.max(0, height)); + return [...framePanel(out.slice(0, Math.max(0, height - 2)).map(row => truncateAnsi(row, columns)), columns), rule]; +} diff --git a/src/viewer/ViewerPanel.ts b/src/viewer/ViewerPanel.ts index 01d3c5de..5493f428 100644 --- a/src/viewer/ViewerPanel.ts +++ b/src/viewer/ViewerPanel.ts @@ -56,7 +56,7 @@ export interface ViewerState { markdown?: {width: number; rows: MarkdownRow[]}; } -export type ViewerAction = {kind: 'close'} | {kind: 'copy'; text: string; lines: number}; +export type ViewerAction = {kind: 'close'} | {kind: 'copy'; text: string; lines: number} | {kind: 'edit'; path: string}; export interface ViewerLook {syntax?: SyntaxSgr; hyperlinks?: boolean} @@ -400,6 +400,7 @@ export function viewerKey(state: ViewerState, key: Key, columns: number, height: else if (text === 'n' && state.query) step(state, 1, width, body, look); else if (text === 'N' && state.query) step(state, -1, width, body, look); else if (text === 'c') return {kind: 'copy', text: state.document.text, lines: state.document.lines.length}; + else if (text === 'e' && state.document.origin.kind === 'file') return {kind: 'edit', path: state.document.origin.path}; return undefined; } @@ -437,7 +438,7 @@ function controls(state: ViewerState, columns: number): string[] { ['↑↓', 'scroll'], ['/', 'find'], ...(state.query ? [['n/N', 'next/previous'] as const] : []), [':', 'line'], ...(state.document.format === 'markdown' ? [['m', state.rendered ? 'source' : 'rendered'] as const] : []), ...(!state.rendered ? [['w', state.wrap ? 'no wrap' : 'wrap'] as const, ['l', 'numbers'] as const] : []), - ['c', 'copy all'], ['Esc', 'close'], + ['c', 'copy all'], ...(state.document.origin.kind === 'file' ? [['e', 'edit'] as const] : []), ['Esc', 'close'], ], width); } diff --git a/src/viewer/fileEdit.ts b/src/viewer/fileEdit.ts new file mode 100644 index 00000000..0ad39a90 --- /dev/null +++ b/src/viewer/fileEdit.ts @@ -0,0 +1,201 @@ +import {createHash, randomBytes} from 'node:crypto'; +import {constants} from 'node:fs'; +import {access, link, lstat, open, readFile, realpath, rename, stat, unlink} from 'node:fs/promises'; +import {homedir} from 'node:os'; +import {basename, dirname, join} from 'node:path'; +import {displayPath, resolveViewPath} from './document.js'; + +/** + * Opening and saving a small text file for the native editor (#402). Only files that can be saved back exactly and + * safely are opened for editing; anything else is refused with the reason (and stays readable in /view): + * + * - a regular UTF-8 text file of at most MAX_EDIT_BYTES, owned by this user, with one hard link, whose file and + * directory are writable, with one kind of line ending (LF or CRLF); a BOM and the final newline are kept; + * - a path that does not exist yet, in a writable directory, opens empty and is created on save (never over a file + * that appeared meanwhile). + * + * Saving writes a new file beside the original (exclusive create, the original's mode, group when permitted), syncs it, + * checks that the original is still the file that was opened (device, inode, size, time and a content hash) and + * renames it over the original's real path. A changed, replaced or deleted original is a conflict for the person to + * decide; nothing is overwritten silently. Extended attributes and ACLs are not carried over by the rename. + */ + +export const MAX_EDIT_BYTES = 1024 * 1024; +const BOM = Buffer.from([0xef, 0xbb, 0xbf]); + +export interface FileIdentity {dev: number; ino: number; size: number; mtimeMs: number; hash: string} + +export interface EditableFile { + /** The path as given, resolved (it may be a symlink). */ + path: string; + /** The file written: the symlink's target. */ + realPath: string; + title: string; + /** The original's permission bits; undefined for a new file (it gets the default mode under the umask). */ + mode?: number; + gid?: number; + /** Undefined for a file that does not exist yet. */ + identity?: FileIdentity; + lineEnding: 'lf' | 'crlf'; + bom: boolean; + finalNewline: boolean; + lines: string[]; +} + +export type OpenResult = {ok: true; file: EditableFile} | {ok: false; reason: string}; +export type SaveConflict = 'changed' | 'replaced' | 'deleted' | 'exists'; +export type SaveResult = {ok: true; file: EditableFile} | {ok: false; reason: string; conflict?: SaveConflict}; + +const hash = (bytes: Buffer) => createHash('sha256').update(bytes).digest('hex'); +const code = (error: unknown) => (error as NodeJS.ErrnoException | undefined)?.code; + +function failure(error: unknown, action: string, shown: string): string { + switch (code(error)) { + case 'EACCES': case 'EPERM': return `Could not ${action} ${shown}: permission denied.`; + case 'EROFS': return `Could not ${action} ${shown}: the file system is read-only.`; + case 'ENOSPC': return `Could not ${action} ${shown}: the disk is full.`; + case 'EDQUOT': return `Could not ${action} ${shown}: over the disk quota.`; + case 'ENOENT': return `Could not ${action} ${shown}: it no longer exists.`; + case 'ELOOP': return `Could not ${action} ${shown}: a symlink loop.`; + case 'EISDIR': return `Could not ${action} ${shown}: it is a directory.`; + default: return `Could not ${action} ${shown}${code(error) ? ` (${code(error)})` : ''}.`; + } +} + +async function writable(path: string): Promise { + try { await access(path, constants.W_OK); return true; } catch { return false; } +} + +/** Open `input` (relative to `cwd`, `~` for home) for editing, or say why it cannot be edited. */ +export async function openForEdit(input: string, cwd: string, options: {home?: string; maxBytes?: number; uid?: number} = {}): Promise { + const home = options.home ?? homedir(); + const path = resolveViewPath(input, cwd, home); + const shown = displayPath(path, home); + const uid = options.uid ?? process.getuid?.(); + let realPath: string; + try { realPath = await realpath(path); } + catch (error) { + if (code(error) !== 'ENOENT') return {ok: false, reason: failure(error, 'open', shown)}; + try { await lstat(path); return {ok: false, reason: `${shown} is a symlink to a missing file; edit its target instead.`}; } catch { /* nothing there: a new file */ } + const directory = dirname(path); + let info; + try { info = await stat(directory); } catch { return {ok: false, reason: `${displayPath(directory, home)} does not exist; create the directory first.`}; } + if (!info.isDirectory()) return {ok: false, reason: `${displayPath(directory, home)} is not a directory.`}; + if (!(await writable(directory))) return {ok: false, reason: `${displayPath(directory, home)} is not writable, so ${shown} cannot be created.`}; + return {ok: true, file: {path, realPath: path, title: shown, lineEnding: 'lf', bom: false, finalNewline: true, lines: ['']}}; + } + let handle; + try { handle = await open(realPath, constants.O_RDONLY | constants.O_NONBLOCK | (constants.O_NOFOLLOW ?? 0) | (constants.O_NOCTTY ?? 0)); } + catch (error) { return {ok: false, reason: failure(error, 'open', shown)}; } + try { + const info = await handle.stat(); + if (info.isDirectory()) return {ok: false, reason: `${shown} is a directory.`}; + if (!info.isFile()) return {ok: false, reason: `${shown} is not a regular file.`}; + const maxBytes = options.maxBytes ?? MAX_EDIT_BYTES; + if (info.size > maxBytes) return {ok: false, reason: `${shown} is larger than ${Math.round(maxBytes / 1024)} KiB; the NMSh editor is for small files. Use your editor, or /view to read it.`}; + if (uid !== undefined && info.uid !== uid) return {ok: false, reason: `${shown} belongs to another user; NMSh edits only your own files. /view reads it.`}; + if (info.nlink > 1) return {ok: false, reason: `${shown} has other hard links, which saving by replacement would separate. Use your editor, or /view to read it.`}; + const bytes = await handle.readFile(); + if ((bytes[0] === 0xff && bytes[1] === 0xfe) || (bytes[0] === 0xfe && bytes[1] === 0xff)) return {ok: false, reason: `${shown} is UTF-16 text; the NMSh editor edits UTF-8 only.`}; + if (bytes.includes(0)) return {ok: false, reason: `${shown} looks binary (it contains NUL bytes).`}; + const bom = bytes.subarray(0, 3).equals(BOM); + let text: string; + try { text = new TextDecoder('utf-8', {fatal: true}).decode(bom ? bytes.subarray(3) : bytes); } + catch { return {ok: false, reason: `${shown} is not valid UTF-8; saving could change bytes it holds. /view reads it.`}; } + const finalNewline = text.endsWith('\n'); + const raw = text.split('\n'); + if (finalNewline) raw.pop(); + const terminated = finalNewline ? raw.length : raw.length - 1; + const crlf = raw.slice(0, terminated).filter(line => line.endsWith('\r')).length; + if (crlf && crlf !== terminated) return {ok: false, reason: `${shown} mixes LF and CRLF line endings; saving would change them. Use your editor, or /view to read it.`}; + const lines = crlf ? raw.map((line, index) => index < terminated ? line.slice(0, -1) : line) : raw; + if (!(await writable(realPath))) return {ok: false, reason: `${shown} is read-only. /view reads it.`}; + if (!(await writable(dirname(realPath)))) return {ok: false, reason: `The folder holding ${shown} is not writable, so it cannot be saved safely. /view reads it.`}; + return {ok: true, file: {path, realPath, title: shown, mode: info.mode & 0o7777, gid: info.gid, + identity: {dev: info.dev, ino: info.ino, size: info.size, mtimeMs: info.mtimeMs, hash: hash(bytes)}, + lineEnding: crlf ? 'crlf' : 'lf', bom, finalNewline: finalNewline || text === '', lines: lines.length ? lines : ['']}}; + } catch (error) { + return {ok: false, reason: failure(error, 'open', shown)}; + } finally { await handle.close().catch(() => undefined); } +} + +/** The bytes a buffer's lines save as: the file's line ending, BOM and final newline kept. */ +export function encodeLines(file: Pick, lines: readonly string[]): Buffer { + const eol = file.lineEnding === 'crlf' ? '\r\n' : '\n'; + const body = lines.join(eol) + (file.finalNewline && !(lines.length === 1 && lines[0] === '') ? eol : ''); + const text = Buffer.from(body, 'utf8'); + return file.bom ? Buffer.concat([BOM, text]) : text; +} + +/** Whether the original is still the file that was opened; undefined when it is. */ +async function changedOnDisk(file: EditableFile): Promise { + if (!file.identity) { + try { await lstat(file.realPath); return 'exists'; } catch (error) { return code(error) === 'ENOENT' ? undefined : 'exists'; } + } + try { if (await realpath(file.path) !== file.realPath) return 'replaced'; } + catch (error) { return code(error) === 'ENOENT' ? 'deleted' : 'replaced'; } + let info; + try { info = await lstat(file.realPath); } catch (error) { return code(error) === 'ENOENT' ? 'deleted' : 'replaced'; } + if (!info.isFile() || info.dev !== file.identity.dev || info.ino !== file.identity.ino) return 'replaced'; + if (info.size === file.identity.size && info.mtimeMs === file.identity.mtimeMs) return undefined; + try { return hash(await readFile(file.realPath)) === file.identity.hash ? undefined : 'changed'; } catch { return 'changed'; } +} + +const CONFLICTS: Record = { + changed: 'changed on disk since you opened it', + replaced: 'was replaced on disk since you opened it', + deleted: 'was deleted since you opened it', + exists: 'was created by something else since you opened it', +}; + +/** + * Save `lines` over the file. Without `force`, a changed original is a conflict and nothing is written. With `force` + * (the person chose Overwrite), the content check is skipped, but only a regular file (or nothing) is ever replaced. + */ +export async function saveFile(file: EditableFile, lines: readonly string[], options: {force?: boolean} = {}): Promise { + if (!options.force) { + const conflict = await changedOnDisk(file); + if (conflict) return {ok: false, conflict, reason: `${file.title} ${CONFLICTS[conflict]}.`}; + } else { + try { if (!(await lstat(file.realPath)).isFile()) return {ok: false, reason: `${file.title} is no longer a regular file; nothing was written.`}; } + catch (error) { if (code(error) !== 'ENOENT') return {ok: false, reason: failure(error, 'save', file.title)}; } + } + const bytes = encodeLines(file, lines); + const directory = dirname(file.realPath); + const temp = join(directory, `.${basename(file.realPath)}.nmsh-${randomBytes(6).toString('hex')}.tmp`); + let handle; + try { + handle = await open(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), file.mode === undefined ? 0o666 : 0o600); + await handle.writeFile(bytes); + if (file.mode !== undefined) await handle.chmod(file.mode); + if (file.gid !== undefined) await handle.chown(-1, file.gid).catch(() => undefined); + await handle.sync(); + await handle.close(); + handle = undefined; + // The narrowest window: the original is checked again just before it is replaced. + if (!options.force) { + const conflict = await changedOnDisk(file); + if (conflict) { await unlink(temp).catch(() => undefined); return {ok: false, conflict, reason: `${file.title} ${CONFLICTS[conflict]}.`}; } + } + if (!file.identity && !options.force) { + // A new file is created only if nothing appeared at its path meanwhile: link fails rather than replace. + try { await link(temp, file.realPath); } + catch (error) { + await unlink(temp).catch(() => undefined); + if (code(error) === 'EEXIST') return {ok: false, conflict: 'exists', reason: `${file.title} ${CONFLICTS.exists}.`}; + throw error; + } + await unlink(temp).catch(() => undefined); + } else await rename(temp, file.realPath); + } catch (error) { + await handle?.close().catch(() => undefined); + await unlink(temp).catch(() => undefined); + return {ok: false, reason: failure(error, 'save', file.title)}; + } + // The directory entry itself is durable too, where the platform allows syncing a directory. + try { const dir = await open(directory, constants.O_RDONLY); try { await dir.sync(); } finally { await dir.close(); } } catch { /* best effort */ } + try { + const info = await lstat(file.realPath); + return {ok: true, file: {...file, lines: [...lines], identity: {dev: info.dev, ino: info.ino, size: info.size, mtimeMs: info.mtimeMs, hash: hash(bytes)}}}; + } catch (error) { return {ok: false, reason: failure(error, 'check the saved', file.title)}; } +} diff --git a/src/viewer/textBuffer.ts b/src/viewer/textBuffer.ts new file mode 100644 index 00000000..6f81b389 --- /dev/null +++ b/src/viewer/textBuffer.ts @@ -0,0 +1,311 @@ +import {graphemes} from '../input/inputLayout.js'; +import {displayWidth} from '../util/text.js'; + +/** + * The text of a small file being edited (#402): lines, a caret, an optional selection anchor and bounded undo/redo. + * Positions are (line, UTF-16 offset) and always sit on grapheme boundaries. Every change goes through one + * replaceRange, which records its inverse; typing and deleting coalesce into one undo step per burst. Movement mirrors + * the composer's rules (words are separated by spaces and tabs) over lines instead of one buffer. + */ + +export interface Position {line: number; column: number} + +interface Edit { + from: Position; + removed: string; + inserted: string; + caretBefore: Position; + anchorBefore?: Position; + caretAfter: Position; + kind: 'type' | 'delete' | 'other'; + at: number; +} + +const MAX_UNDO = 500; +const UNREACHABLE: unique symbol = Symbol('unreachable saved state'); +const COALESCE_MS = 1000; +export const TAB_WIDTH = 4; + +const same = (a: Position, b: Position) => a.line === b.line && a.column === b.column; +const before = (a: Position, b: Position) => a.line < b.line || (a.line === b.line && a.column < b.column); +const isSpace = (character: string | undefined) => character === ' ' || character === '\t'; + +/** The display column of `column` in `line`, with tabs to the next stop and wide characters counted. */ +export function displayColumn(line: string, column: number): number { + let cells = 0; + let offset = 0; + for (const character of graphemes(line)) { + if (offset >= column) break; + cells += character === '\t' ? TAB_WIDTH - (cells % TAB_WIDTH) : displayWidth(character); + offset += character.length; + } + return cells; +} + +/** The offset in `line` whose display column is nearest to `target` without passing it. */ +function columnAt(line: string, target: number): number { + let cells = 0; + let offset = 0; + for (const character of graphemes(line)) { + const width = character === '\t' ? TAB_WIDTH - (cells % TAB_WIDTH) : displayWidth(character); + if (cells + width > target) break; + cells += width; + offset += character.length; + } + return offset; +} + +export class TextBuffer { + lines: string[]; + caret: Position = {line: 0, column: 0}; + anchor?: Position; + private preferred?: number; + private readonly undoStack: Edit[] = []; + private readonly redoStack: Edit[] = []; + /** + * The undo step the saved text ends with (undefined: the text as opened). The buffer is clean exactly when the + * history's top is that step, so undoing back to the saved text reads as clean again. A saved state pushed out of + * the bounded history can never be reached again and is marked unreachable. + */ + private savedTop: Edit | undefined | typeof UNREACHABLE = undefined; + + constructor(lines: readonly string[], private readonly now: () => number = Date.now) { + this.lines = lines.length ? [...lines] : ['']; + } + + get text(): string { return this.lines.join('\n'); } + get dirty(): boolean { return this.undoStack.at(-1) !== this.savedTop; } + get canUndo(): boolean { return this.undoStack.length > 0; } + get canRedo(): boolean { return this.redoStack.length > 0; } + + markSaved(): void { this.savedTop = this.undoStack.at(-1); } + + get selection(): {start: Position; end: Position} | undefined { + if (!this.anchor || same(this.anchor, this.caret)) return undefined; + return before(this.anchor, this.caret) ? {start: this.anchor, end: this.caret} : {start: this.caret, end: this.anchor}; + } + + selectedText(): string { + const selection = this.selection; + return selection ? this.slice(selection.start, selection.end) : ''; + } + + private slice(from: Position, to: Position): string { + if (from.line === to.line) return this.lines[from.line]!.slice(from.column, to.column); + return [this.lines[from.line]!.slice(from.column), ...this.lines.slice(from.line + 1, to.line), this.lines[to.line]!.slice(0, to.column)].join('\n'); + } + + /** Replace [from, to) with text; records the inverse for undo. */ + private replaceRange(from: Position, to: Position, text: string, kind: Edit['kind'], record = true): void { + const removed = this.slice(from, to); + const head = this.lines[from.line]!.slice(0, from.column); + const tail = this.lines[to.line]!.slice(to.column); + const parts = text.split('\n'); + const caretAfter = {line: from.line + parts.length - 1, column: (parts.length === 1 ? head.length : 0) + parts.at(-1)!.length}; + parts[0] = head + parts[0]!; + parts[parts.length - 1] = parts.at(-1)! + tail; + const caretBefore = this.caret; + const anchorBefore = this.anchor; + this.lines.splice(from.line, to.line - from.line + 1, ...parts); + this.caret = caretAfter; + this.anchor = undefined; + this.preferred = undefined; + if (!record) return; + this.redoStack.length = 0; + const previous = this.undoStack.at(-1); + const at = this.now(); + // A burst of typing (or of deleting) is one undo step: same kind, continuing where the last one ended, no line break. + // The saved step is never extended: what the file holds must stay one exact step of the history. + if (previous && previous !== this.savedTop && kind !== 'other' && previous.kind === kind && at - previous.at < COALESCE_MS && !text.includes('\n') && !removed.includes('\n')) { + if (kind === 'type' && !removed && same(previous.caretAfter, from)) { + previous.inserted += text; previous.caretAfter = caretAfter; previous.at = at; return; + } + if (kind === 'delete' && !text && same(previous.from, to) && !previous.inserted) { + previous.from = from; previous.removed = removed + previous.removed; previous.caretAfter = caretAfter; previous.at = at; return; + } + } + this.undoStack.push({from, removed, inserted: text, caretBefore, ...(anchorBefore ? {anchorBefore} : {}), caretAfter, kind, at}); + if (this.undoStack.length > MAX_UNDO) { + const dropped = this.undoStack.shift(); + // The saved text was the state before (or at) the dropped step: undo can no longer return to it. + if (this.savedTop === undefined || this.savedTop === dropped) this.savedTop = UNREACHABLE; + } + } + + private endOf(from: Position, text: string): Position { + const parts = text.split('\n'); + return {line: from.line + parts.length - 1, column: (parts.length === 1 ? from.column : 0) + parts.at(-1)!.length}; + } + + undo(): boolean { + const edit = this.undoStack.pop(); + if (!edit) return false; + this.replaceRange(edit.from, this.endOf(edit.from, edit.inserted), edit.removed, 'other', false); + this.caret = edit.caretBefore; + this.anchor = edit.anchorBefore; + this.redoStack.push(edit); + return true; + } + + redo(): boolean { + const edit = this.redoStack.pop(); + if (!edit) return false; + this.replaceRange(edit.from, this.endOf(edit.from, edit.removed), edit.inserted, 'other', false); + this.caret = edit.caretAfter; + this.undoStack.push(edit); + return true; + } + + // ---- Editing ----------------------------------------------------------------------------------------------------- + + /** Typed or pasted text: CR LF and CR become line breaks, tabs stay, other control characters are dropped. */ + insert(value: string): void { + const text = value.replace(/\r\n?/gu, '\n').replace(/[\u0000-\u0008\u000b-\u001f\u007f]/gu, ''); + if (!text && !this.selection) return; + const selection = this.selection; + this.replaceRange(selection?.start ?? this.caret, selection?.end ?? this.caret, text, selection ? 'other' : 'type'); + } + + private deleteSelection(): boolean { + const selection = this.selection; + if (!selection) { this.anchor = undefined; return false; } + this.replaceRange(selection.start, selection.end, '', 'other'); + return true; + } + + backspace(): void { + if (this.deleteSelection()) return; + const from = this.stepLeft(this.caret); + if (!same(from, this.caret)) this.replaceRange(from, this.caret, '', 'delete'); + } + + delete(): void { + if (this.deleteSelection()) return; + const to = this.stepRight(this.caret); + if (!same(to, this.caret)) this.replaceRange(this.caret, to, '', 'other'); + } + + deleteWord(): void { + if (this.deleteSelection()) return; + const from = this.wordStart(this.caret); + if (!same(from, this.caret)) this.replaceRange(from, this.caret, '', 'other'); + } + + deleteLineBefore(): void { + if (this.deleteSelection()) return; + if (this.caret.column > 0) this.replaceRange({line: this.caret.line, column: 0}, this.caret, '', 'other'); + } + + deleteLineAfter(): void { + if (this.deleteSelection()) return; + const length = this.lines[this.caret.line]!.length; + if (this.caret.column < length) this.replaceRange(this.caret, {line: this.caret.line, column: length}, '', 'other'); + } + + // ---- Movement ---------------------------------------------------------------------------------------------------- + + private stepLeft(position: Position): Position { + if (position.column === 0) return position.line === 0 ? position : {line: position.line - 1, column: this.lines[position.line - 1]!.length}; + const characters = graphemes(this.lines[position.line]!.slice(0, position.column)); + return {line: position.line, column: position.column - characters.at(-1)!.length}; + } + + private stepRight(position: Position): Position { + const line = this.lines[position.line]!; + if (position.column >= line.length) return position.line + 1 >= this.lines.length ? position : {line: position.line + 1, column: 0}; + return {line: position.line, column: position.column + graphemes(line.slice(position.column))[0]!.length}; + } + + private wordStart(position: Position): Position { + if (position.column === 0) return this.stepLeft(position); + const line = this.lines[position.line]!; + let column = position.column; + while (column > 0 && isSpace(line[column - 1])) column -= 1; + while (column > 0 && !isSpace(line[column - 1])) column -= 1; + return {line: position.line, column}; + } + + private wordEnd(position: Position): Position { + const line = this.lines[position.line]!; + if (position.column >= line.length) return this.stepRight(position); + let column = position.column; + while (column < line.length && !isSpace(line[column])) column += 1; + while (column < line.length && isSpace(line[column])) column += 1; + return {line: position.line, column}; + } + + private move(to: Position, select: boolean, keepPreferred = false): void { + if (select) this.anchor ??= this.caret; + else this.anchor = undefined; + this.caret = to; + if (!keepPreferred) this.preferred = undefined; + } + + /** Without Shift, Left/Right first collapse a selection to its edge, as the composer does. */ + left(select = false): void { + const selection = this.selection; + if (!select && selection) { this.move(selection.start, false); return; } + this.move(this.stepLeft(this.caret), select); + } + + right(select = false): void { + const selection = this.selection; + if (!select && selection) { this.move(selection.end, false); return; } + this.move(this.stepRight(this.caret), select); + } + + wordLeft(select = false): void { this.move(this.wordStart(this.caret), select); } + wordRight(select = false): void { this.move(this.wordEnd(this.caret), select); } + home(select = false): void { this.move({line: this.caret.line, column: 0}, select); } + end(select = false): void { this.move({line: this.caret.line, column: this.lines[this.caret.line]!.length}, select); } + bufferHome(select = false): void { this.move({line: 0, column: 0}, select); } + bufferEnd(select = false): void { this.move({line: this.lines.length - 1, column: this.lines.at(-1)!.length}, select); } + + selectAll(): void { + this.anchor = {line: 0, column: 0}; + this.caret = {line: this.lines.length - 1, column: this.lines.at(-1)!.length}; + } + + /** Up/Down keep the display column the caret started from across shorter lines. */ + vertical(delta: number, select = false): void { + const target = Math.max(0, Math.min(this.lines.length - 1, this.caret.line + delta)); + if (target === this.caret.line) { this.move(delta < 0 ? {line: target, column: 0} : {line: target, column: this.lines[target]!.length}, select); return; } + this.preferred ??= displayColumn(this.lines[this.caret.line]!, this.caret.column); + this.move({line: target, column: columnAt(this.lines[target]!, this.preferred)}, select, true); + } + + setCaret(position: Position): void { + const line = Math.max(0, Math.min(this.lines.length - 1, position.line)); + this.move({line, column: Math.max(0, Math.min(this.lines[line]!.length, position.column))}, false); + } + + // ---- Search ------------------------------------------------------------------------------------------------------ + + /** The next match after the caret (or the previous one before it), wrapping; smart case. Selects it. */ + find(query: string, direction: 1 | -1 = 1): {wrapped: boolean} | undefined { + if (!query) return undefined; + const insensitive = query === query.toLowerCase(); + const fold = (text: string) => insensitive ? text.toLowerCase() : text; + const needle = fold(query); + const start = direction === 1 ? (this.selection?.end ?? this.caret) : (this.selection?.start ?? this.caret); + const count = this.lines.length; + for (let step = 0; step <= count; step += 1) { + const raw = start.line + direction * step; + const index = ((raw % count) + count) % count; + const line = fold(this.lines[index]!); + let found = -1; + if (direction === 1) found = line.indexOf(needle, step === 0 ? start.column : 0); + else { + const limit = step === 0 ? start.column - needle.length : line.length; + if (limit >= 0) found = line.lastIndexOf(needle, limit); + } + if (found >= 0 && (step > 0 || direction === -1 || found >= start.column)) { + this.anchor = {line: index, column: found}; + this.caret = {line: index, column: found + query.length}; + this.preferred = undefined; + return {wrapped: direction === 1 ? raw >= count : raw < 0}; + } + } + return undefined; + } +} diff --git a/tests/textEditor.test.ts b/tests/textEditor.test.ts new file mode 100644 index 00000000..84ab2216 --- /dev/null +++ b/tests/textEditor.test.ts @@ -0,0 +1,316 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {chmodSync, linkSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {TextBuffer} from '../src/viewer/textBuffer.js'; +import {encodeLines, openForEdit, saveFile, type EditableFile} from '../src/viewer/fileEdit.js'; +import {createEditor, editorKey, renderEditor, type EditorState} from '../src/viewer/EditorPanel.js'; +import {decodeKeys} from '../src/terminal/keys.js'; +import {displayWidth, stripAnsi} from '../src/util/text.js'; +import type {Key} from '../src/terminal/keys.js'; + +const scratch = () => realpathSync(mkdtempSync(join(tmpdir(), 'nmsh-edit-'))); +async function opened(path: string): Promise { + const result = await openForEdit(path, '/'); + if (!result.ok) throw new Error(result.reason); + return result.file; +} +const refusal = async (path: string) => { + const result = await openForEdit(path, '/'); + assert.equal(result.ok, false); + return (result as {reason: string}).reason; +}; + +// ---- Buffer ------------------------------------------------------------------------------------------------------- + +test('buffer: typing, line breaks, tabs and pasted CR LF; control characters never enter the text', () => { + let clock = 0; + const buffer = new TextBuffer(['ab'], () => clock); + buffer.end(); + buffer.insert('c\u001b[2J\u0007'); + buffer.insert('\n\tline two\r\nthree\rfour'); + assert.deepEqual(buffer.lines, ['abc[2J', '\tline two', 'three', 'four']); + assert.deepEqual(buffer.caret, {line: 3, column: 4}); + buffer.backspace(); + buffer.home(); + buffer.backspace(); + assert.deepEqual(buffer.lines, ['abc[2J', '\tline two', 'threefou'], 'backspace at a line start joins the lines'); + clock += 5000; + assert.ok(buffer.dirty); +}); + +test('buffer: grapheme, word and vertical movement keep a display column across tabs and wide characters', () => { + const buffer = new TextBuffer(['hello world next', '\tx', '中文字符', 'éé']); + buffer.wordRight(); + assert.deepEqual(buffer.caret, {line: 0, column: 6}); + buffer.wordRight(); + assert.deepEqual(buffer.caret, {line: 0, column: 13}); + buffer.wordLeft(); + assert.deepEqual(buffer.caret, {line: 0, column: 6}); + buffer.setCaret({line: 0, column: 5}); + buffer.vertical(1); + assert.deepEqual(buffer.caret, {line: 1, column: 2}, 'column 5 lands after the tab (4 cells) and x'); + buffer.vertical(1); + assert.deepEqual(buffer.caret, {line: 2, column: 2}, 'wide characters: column 5 falls inside the third, so the caret stops before it'); + buffer.vertical(1); + assert.deepEqual(buffer.caret, {line: 3, column: 4}, 'the preferred column survives a shorter line'); + buffer.left(); + assert.deepEqual(buffer.caret, {line: 3, column: 2}, 'a combining sequence is one step'); +}); + +test('buffer: a selection is replaced by typing, cut by delete, and Left/Right collapse it like the composer', () => { + const buffer = new TextBuffer(['one two', 'three']); + buffer.wordRight(true); + assert.equal(buffer.selectedText(), 'one '); + buffer.insert('1 '); + assert.deepEqual(buffer.lines, ['1 two', 'three']); + buffer.selectAll(); + assert.equal(buffer.selectedText(), '1 two\nthree'); + buffer.left(); + assert.deepEqual(buffer.caret, {line: 0, column: 0}); + buffer.vertical(1, true); + buffer.delete(); + assert.deepEqual(buffer.lines, ['three']); +}); + +test('buffer: undo and redo coalesce bursts, restore the caret, and the saved text reads clean again', () => { + let clock = 0; + const buffer = new TextBuffer(['x'], () => clock); + buffer.end(); + for (const character of 'abc') { buffer.insert(character); clock += 100; } + clock += 5000; + buffer.insert('!'); + buffer.markSaved(); + assert.equal(buffer.dirty, false); + buffer.backspace(); + assert.equal(buffer.dirty, true); + assert.ok(buffer.undo()); + assert.equal(buffer.dirty, false, 'back to the saved text'); + assert.ok(buffer.undo()); + assert.deepEqual(buffer.lines, ['xabc']); + assert.ok(buffer.undo(), 'typing abc was one step'); + assert.deepEqual(buffer.lines, ['x']); + assert.deepEqual(buffer.caret, {line: 0, column: 1}); + assert.equal(buffer.dirty, true); + assert.ok(buffer.redo() && buffer.redo()); + assert.deepEqual(buffer.lines, ['xabc!']); + assert.equal(buffer.dirty, false); + // A saved state pushed out of the bounded history can never read as clean again. + const long = new TextBuffer(['']); + long.markSaved(); + for (let step = 0; step < 520; step += 1) { long.insert('\n'); } + while (long.undo()) { /* as far back as the history goes */ } + assert.equal(long.dirty, true); +}); + +test('buffer: find selects the next match, smart case, wrapping', () => { + const buffer = new TextBuffer(['Alpha beta', 'gamma ALPHA', 'alpha']); + assert.deepEqual(buffer.find('alpha'), {wrapped: false}); + assert.equal(buffer.selectedText(), 'Alpha'); + buffer.find('alpha'); + assert.deepEqual(buffer.caret, {line: 1, column: 11}); + buffer.find('alpha'); + assert.deepEqual(buffer.find('alpha'), {wrapped: true}); + assert.deepEqual(buffer.anchor, {line: 0, column: 0}); + assert.equal(buffer.find('Zeta'), undefined); +}); + +// ---- Opening and saving --------------------------------------------------------------------------------------------- + +test('open: only files that can be saved back exactly and safely; every refusal says why', async () => { + const root = scratch(); + try { + writeFileSync(join(root, 'mixed.txt'), 'a\r\nb\nc\n'); + assert.match(await refusal(join(root, 'mixed.txt')), /mixes LF and CRLF/u); + writeFileSync(join(root, 'bad.txt'), Buffer.from([0x61, 0xff, 0x0a])); + assert.match(await refusal(join(root, 'bad.txt')), /not valid UTF-8/u); + writeFileSync(join(root, 'bin'), Buffer.from([1, 0, 2])); + assert.match(await refusal(join(root, 'bin')), /binary/u); + writeFileSync(join(root, 'big.txt'), 'x'.repeat(2 * 1024 * 1024)); + assert.match(await refusal(join(root, 'big.txt')), /larger than 1024 KiB/u); + writeFileSync(join(root, 'linked.txt'), 'a\n'); + linkSync(join(root, 'linked.txt'), join(root, 'second-link.txt')); + assert.match(await refusal(join(root, 'linked.txt')), /other hard links/u); + mkdirSync(join(root, 'dir')); + assert.match(await refusal(join(root, 'dir')), /is a directory/u); + assert.match(await refusal(join(root, 'nowhere', 'x.txt')), /does not exist; create the directory first/u); + symlinkSync(join(root, 'absent.txt'), join(root, 'dangling.txt')); + assert.match(await refusal(join(root, 'dangling.txt')), /symlink to a missing file/u); + if (process.getuid?.() !== 0) { + writeFileSync(join(root, 'ro.txt'), 'r\n'); + chmodSync(join(root, 'ro.txt'), 0o444); + assert.match(await refusal(join(root, 'ro.txt')), /read-only/u); + mkdirSync(join(root, 'locked')); + writeFileSync(join(root, 'locked', 'in.txt'), 'i\n'); + chmodSync(join(root, 'locked'), 0o555); + assert.match(await refusal(join(root, 'locked', 'in.txt')), /folder holding .* is not writable/u); + chmodSync(join(root, 'locked'), 0o755); + } + const fresh = await opened(join(root, 'new.txt')); + assert.equal(fresh.identity, undefined); + assert.deepEqual(fresh.lines, ['']); + } finally { rmSync(root, {recursive: true, force: true}); } +}); + +test('save: atomic replacement keeps mode, BOM, CRLF and a missing final newline; through a symlink the link stays', async () => { + const root = scratch(); + try { + const path = join(root, 'notes.txt'); + writeFileSync(path, Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from('one\r\ntwo', 'utf8')])); + chmodSync(path, 0o640); + const file = await opened(path); + assert.deepEqual(file.lines, ['one', 'two']); + assert.equal(file.lineEnding, 'crlf'); + assert.equal(file.bom, true); + assert.equal(file.finalNewline, false); + const saved = await saveFile(file, ['one', 'two', 'three']); + assert.ok(saved.ok); + assert.deepEqual(readFileSync(path), Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from('one\r\ntwo\r\nthree', 'utf8')])); + assert.equal(statSync(path).mode & 0o777, 0o640); + assert.deepEqual(readdirSync(root), ['notes.txt'], 'no temporary file is left behind'); + + writeFileSync(join(root, 'target.md'), '# t\n'); + symlinkSync(join(root, 'target.md'), join(root, 'link.md')); + const viaLink = await opened(join(root, 'link.md')); + assert.equal(viaLink.realPath, join(root, 'target.md')); + assert.ok((await saveFile(viaLink, ['# changed'])).ok); + assert.ok(lstatSync(join(root, 'link.md')).isSymbolicLink(), 'the symlink is kept; its target is written'); + assert.equal(readFileSync(join(root, 'target.md'), 'utf8'), '# changed\n'); + assert.deepEqual(encodeLines({lineEnding: 'lf', bom: false, finalNewline: true}, ['']), Buffer.alloc(0), 'an emptied file is empty'); + } finally { rmSync(root, {recursive: true, force: true}); } +}); + +test('save: a changed, replaced or deleted original is a conflict; Overwrite is explicit; a new file never replaces one that appeared', async () => { + const root = scratch(); + try { + const path = join(root, 'conf.txt'); + writeFileSync(path, 'mine\n'); + const file = await opened(path); + writeFileSync(path, 'theirs, longer\n'); + const changed = await saveFile(file, ['edited']); + assert.ok(!changed.ok && changed.conflict === 'changed'); + assert.equal(readFileSync(path, 'utf8'), 'theirs, longer\n', 'nothing was written'); + assert.ok((await saveFile(file, ['edited'], {force: true})).ok); + assert.equal(readFileSync(path, 'utf8'), 'edited\n'); + + const again = await opened(path); + writeFileSync(join(root, 'other.txt'), 'other\n'); + renameSync(join(root, 'other.txt'), path); + const replaced = await saveFile(again, ['x']); + assert.ok(!replaced.ok && replaced.conflict === 'replaced'); + + const gone = await opened(path); + unlinkSync(path); + const deleted = await saveFile(gone, ['x']); + assert.ok(!deleted.ok && deleted.conflict === 'deleted'); + + const fresh = await opened(join(root, 'fresh.txt')); + writeFileSync(join(root, 'fresh.txt'), 'someone else\n'); + const exists = await saveFile(fresh, ['mine']); + assert.ok(!exists.ok && exists.conflict === 'exists'); + assert.equal(readFileSync(join(root, 'fresh.txt'), 'utf8'), 'someone else\n'); + unlinkSync(join(root, 'fresh.txt')); + const created = await saveFile(fresh, ['mine']); + assert.ok(created.ok && created.file.identity); + assert.equal(readFileSync(join(root, 'fresh.txt'), 'utf8'), 'mine\n'); + assert.deepEqual(readdirSync(root).filter(name => name.includes('.nmsh-')), [], 'no temporary files'); + } finally { rmSync(root, {recursive: true, force: true}); } +}); + +test('save: a failure keeps the text and leaves nothing behind', {skip: process.getuid?.() === 0 && 'root ignores permissions'}, async () => { + const root = scratch(); + try { + mkdirSync(join(root, 'd')); + const path = join(root, 'd', 'f.txt'); + writeFileSync(path, 'a\n'); + const file = await opened(path); + chmodSync(join(root, 'd'), 0o555); + const result = await saveFile(file, ['b']); + assert.ok(!result.ok && !result.conflict); + assert.match(result.reason, /permission denied/u); + chmodSync(join(root, 'd'), 0o755); + assert.equal(readFileSync(path, 'utf8'), 'a\n'); + assert.deepEqual(readdirSync(join(root, 'd')), ['f.txt']); + } finally { chmodSync(join(root, 'd'), 0o755); rmSync(root, {recursive: true, force: true}); } +}); + +// ---- Panel -------------------------------------------------------------------------------------------------------- + +const keysFor = (bytes: string): Key[] => decodeKeys(bytes); +function press(state: EditorState, input: string | Key, columns = 80, height = 24) { + let action; + for (const key of typeof input === 'string' ? keysFor(input) : [input]) action = editorKey(state, key, columns, height) ?? action; + return action; +} + +test('panel: the editor keys work from real terminal bytes (Ctrl+S save, Ctrl+Z/Ctrl+Y, Ctrl+F, Ctrl+C/Ctrl+X)', async () => { + const root = scratch(); + try { + writeFileSync(join(root, 'a.txt'), 'hello\n'); + const state = createEditor(await opened(join(root, 'a.txt'))); + press(state, '\u001b[F'); + press(state, ' world'); + assert.deepEqual(state.buffer.lines, ['hello world']); + assert.deepEqual(press(state, '\u0013'), {kind: 'save'}); + press(state, '\u001a'); + assert.deepEqual(state.buffer.lines, ['hello']); + press(state, '\u0019'); + assert.deepEqual(state.buffer.lines, ['hello world']); + press(state, '\u0006'); + press(state, 'wor'); + press(state, '\r'); + assert.equal(state.buffer.selectedText(), 'wor'); + assert.deepEqual(press(state, '\u0003'), {kind: 'copy', text: 'wor'}); + assert.deepEqual(press(state, '\u0018'), {kind: 'copy', text: 'wor'}); + assert.deepEqual(state.buffer.lines, ['hello ld']); + } finally { rmSync(root, {recursive: true, force: true}); } +}); + +test('panel: Esc with unsaved changes asks; Discard, Save and Keep editing do what they say; conflicts offer Overwrite or Reload', async () => { + const root = scratch(); + try { + writeFileSync(join(root, 'b.txt'), 'b\n'); + const file = await opened(join(root, 'b.txt')); + const clean = createEditor(file); + assert.deepEqual(press(clean, {kind: 'escape'}), {kind: 'close'}, 'nothing to lose: Esc closes'); + const state = createEditor(file); + press(state, 'x'); + assert.equal(press(state, {kind: 'escape'}), undefined); + assert.equal(state.dialog?.kind, 'unsaved'); + assert.match(stripAnsi(renderEditor(state, 80, 24).join('\n')), /unsaved changes[\s\S]*Save[\s\S]*Discard changes[\s\S]*Keep editing/u); + assert.equal(press(state, {kind: 'escape'}), undefined, 'Esc in the question keeps editing'); + assert.equal(state.dialog, undefined); + press(state, {kind: 'escape'}); + assert.deepEqual(press(state, 's'), {kind: 'save', thenClose: true}); + press(state, {kind: 'escape'}); + assert.deepEqual(press(state, {kind: 'right'}), undefined); + assert.deepEqual(press(state, {kind: 'enter'}), {kind: 'close'}, 'Discard was focused'); + state.dialog = {kind: 'conflict', focus: 0, conflict: 'changed', reason: 'b.txt changed on disk since you opened it.'}; + assert.match(stripAnsi(renderEditor(state, 80, 24).join('\n')), /changed on disk[\s\S]*Keep editing[\s\S]*Overwrite[\s\S]*Reload, discard mine/u); + assert.deepEqual(press(state, 'o'), {kind: 'save', force: true}); + state.dialog = {kind: 'conflict', focus: 0, conflict: 'changed'}; + assert.deepEqual(press(state, 'r'), {kind: 'reload'}); + } finally { rmSync(root, {recursive: true, force: true}); } +}); + +test('panel: hostile file content is drawn inert, the caret and selection are visible without color, and it fits narrow panels', async () => { + const root = scratch(); + try { + writeFileSync(join(root, 'h.txt'), 'evil \u001b]8;;https://x\u0007link\u001b[2J and ‮right\n\tindented 中文\n'); + const state = createEditor(await opened(join(root, 'h.txt'))); + press(state, '\u001b[1;2C'); + for (const [columns, height] of [[80, 24], [32, 10], [20, 6]] as const) { + const rows = renderEditor(state, columns, height); + assert.ok(rows.length <= height); + for (const row of rows) { + assert.doesNotMatch(row.replace(/\u001b\[[0-9;]*m/gu, ''), /[\u001b‮]/u); + assert.ok(displayWidth(stripAnsi(row)) <= columns); + } + } + const wide = renderEditor(state, 100, 24).join('\n'); + assert.match(stripAnsi(wide), /evil ␛\]8;;https:\/\/x·link␛\[2J and ·right/u); + assert.match(wide, /\u001b\[7me\u001b\[0m/u, 'the selected first character is reverse video'); + assert.match(stripAnsi(wide), /Ln 1, Col 2 · 1 selected/u); + } finally { rmSync(root, {recursive: true, force: true}); } +}); diff --git a/tests/viewerApp.test.ts b/tests/viewerApp.test.ts index c03d0c87..c755f7aa 100644 --- a/tests/viewerApp.test.ts +++ b/tests/viewerApp.test.ts @@ -1,6 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import {mkdtempSync, realpathSync, rmSync, writeFileSync} from 'node:fs'; +import {once} from 'node:events'; +import {mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync} from 'node:fs'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; import {isolateConfig} from './support/isolatedConfig.js'; @@ -63,6 +64,8 @@ test('/view reads relative to the shell directory; a missing file is repo const app = new TerminalApp(); app['render'] = () => {}; try { + // The real shell's startup prompt sets the directory; set the test's own after it. + await once(app['session'], 'prompt'); app['shellCwd'] = root; writeFileSync(join(root, 'notes.md'), '# Notes\n\nhello\n'); app['openViewer']('notes.md', '/view notes.md'); @@ -75,3 +78,40 @@ test('/view reads relative to the shell directory; a missing file is repo assert.equal(app['viewer'], undefined); } finally { app['stop'](0); app['session'].kill(); isolation.restore(); rmSync(root, {recursive: true, force: true}); } }); + +test('/edit opens a small file, Ctrl+S saves it, Esc closes; e in /view edits the file shown; refusals are reported', async () => { + const isolation = isolateConfig(); + const root = realpathSync(mkdtempSync(join(tmpdir(), 'nmsh-editapp-'))); + const app = new TerminalApp(); + app['render'] = () => {}; + try { + // The real shell's startup prompt sets the directory; set the test's own after it. + await once(app['session'], 'prompt'); + app['shellCwd'] = root; + writeFileSync(join(root, 'todo.txt'), 'buy milk\n'); + app['openTextEditor']('todo.txt', '/edit todo.txt'); + await until(() => app['textEditor'] !== undefined, 'the editor'); + assert.equal(app['hasModalInput'](), true); + app['handleKey']({kind: 'bufferEnd'}); + app['handleKey']({kind: 'text', value: ' and eggs'}); + assert.equal(app['editor'].text, '', 'keys belong to the editor, not the composer'); + app['handleKey']({kind: 'sendToProgram'}); + await until(() => readFileSync(join(root, 'todo.txt'), 'utf8') === 'buy milk and eggs\n', 'the save'); + await until(() => app['textEditor']?.busy === false, 'the save to settle'); + assert.equal(app['textEditor']!.buffer.dirty, false); + app['handleKey']({kind: 'escape'}); + assert.equal(app['textEditor'], undefined); + + app['openViewer']('todo.txt', '/view todo.txt'); + await until(() => app['viewer'] !== undefined, 'the viewer'); + app['handleKey']({kind: 'text', value: 'e'}); + await until(() => app['textEditor'] !== undefined, 'the editor from the viewer'); + assert.equal(app['viewer'], undefined); + app['handleKey']({kind: 'escape'}); + + writeFileSync(join(root, 'blob'), Buffer.from([0, 1, 2])); + app['openTextEditor']('blob', '/edit blob'); + await until(() => /looks binary/u.test(transcriptText(app)), 'the refusal'); + assert.equal(app['textEditor'], undefined); + } finally { app['stop'](0); app['session'].kill(); isolation.restore(); rmSync(root, {recursive: true, force: true}); } +}); diff --git a/tests/viewerLive.test.ts b/tests/viewerLive.test.ts index 79f3e0e4..ec0910e5 100644 --- a/tests/viewerLive.test.ts +++ b/tests/viewerLive.test.ts @@ -56,3 +56,24 @@ test('live: /view reads a file and the latest output; find, go to line, copy all await frontend.run('echo AFTER-VIEW', /AFTER-VIEW/u); } finally { await sandbox.dispose(); } }); + +test('live: /edit creates a file, typed text and Ctrl+S from real terminal bytes save it, Esc returns to the composer', {skip: supported ? false : 'unsupported platform', timeout: 120_000}, async () => { + const sandbox = new LiveSandbox({provider: 'none', shellBackend: 'zsh'}); + const frontend = sandbox.launch([], {cols: 100, rows: 30}); + try { + await frontend.waitFor(/Vespyr|notMyShell|zsh/u); + await frontend.run('echo READY', /READY/u); + let mark = frontend.mark; + frontend.pty.write('/edit notes.txt\r'); + await frontend.waitFor(/notes\.txt[\s\S]*new file[\s\S]*Ctrl\+S save/u, mark); + frontend.pty.write('first line\rsecond\tline'); + mark = frontend.mark; + frontend.pty.write('\u0013'); + await frontend.waitFor(/Saved/u, mark); + assert.equal(readFileSync(join(sandbox.home, 'notes.txt'), 'utf8'), 'first line\nsecond\tline\n'); + mark = frontend.mark; + frontend.pty.write('\u001b'); + await frontend.waitFor(/❯/u, mark); + await frontend.run('cat notes.txt', /second\s+line/u); + } finally { await sandbox.dispose(); } +});