diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fc3b600..5a53cf4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added + +- **`/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. + ### Fixed - Multi-line progress displays that redraw their own rows (cursor up and erase line, as concurrent downloads and container pulls do) now update in place instead of leaving a copy of every frame in the transcript ([#388](https://github.com/raiseCatError/notMyShell/issues/388)). A program can redraw only its own recent rows, at most a screen height up, never NMSh's lines or earlier commands; `/copy` copies the final rows once. Absolute cursor positioning keeps the previous behavior. diff --git a/docs/development/v021-ledger.md b/docs/development/v021-ledger.md index 9b179e5c..4644df2d 100644 --- a/docs/development/v021-ledger.md +++ b/docs/development/v021-ledger.md @@ -16,7 +16,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 | | | | +| 2 | `/view` | #397, #402 | `feature/v021-view` (stacked on slice 1) | | PR open | | 3 | `/edit` | #402 | | | | | 4 | Semantic Output | #386 | | | | | 5 | `/track` | #393 (#394) | | | | @@ -34,7 +34,8 @@ Automated results here are not physical-terminal validation. ## Physical QA still required 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). +and Terminal.app, queued highlight colors). Slice 2: section 2 (`/view` keys, rendering, refusals, hostile text, +NO_COLOR). ## Verification notes diff --git a/docs/testing/v021-physical-qa.md b/docs/testing/v021-physical-qa.md index 133614c2..3cee9cac 100644 --- a/docs/testing/v021-physical-qa.md +++ b/docs/testing/v021-physical-qa.md @@ -16,6 +16,8 @@ |---|---|---|---|---|---|---| | 1 Multi-row progress (#388) | Ghostty | macOS | zsh | | Not run | | | 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 | | ## 1. Multi-row progress redraws (#388) @@ -29,3 +31,22 @@ - [ ] 1.6 `vim`, `less` and `htop` still take the full screen and return to the composer. - [ ] 1.7 Queue two quick commands (`ls` then `pwd`) with Ctrl+Q while a short command runs: both stored command lines end with the known-command color (#408). + +## 2. Native viewer (`/view`, View output) + +- [ ] 2.1 `/view README.md` in this repository: Markdown is rendered (headings, lists, code, tables); `m` shows the + source with line numbers; `m` again returns. +- [ ] 2.2 `/view src/viewer/ViewerPanel.ts`: syntax colors, line numbers, `w` turns wrapping off and Left/Right scroll + sideways with `‹`/`›` marks, `l` hides numbers. +- [ ] 2.3 `/` finds text (lowercase ignores case), `n`/`N` step and wrap with a note, `:` + number goes to a line, + `G`/`g` end and start, PageUp/PageDown, mouse wheel scrolls. +- [ ] 2.4 Run `seq 1 5000`, then `/view` (latest output) and Actions → View output on that block; `/view 2` opens the + previous output. The transcript is unchanged after closing. +- [ ] 2.5 `c` copies the whole document; the clipboard has the exact text (tabs kept), and nothing else. +- [ ] 2.6 Hostile content: `printf '\033]8;;https://example.com\a\033[31mred\033[2J\n' > /tmp/h.txt && /view /tmp/h.txt` + shows `␛` marks, no color change, no link, no screen clear. +- [ ] 2.7 Refusals say why: a directory, a missing file, `/dev/zero`, a FIFO (`mkfifo /tmp/f && /view /tmp/f` returns + at once), a binary (`/view /bin/ls`), a 20 MB log (shows the first 8 MiB with a note). +- [ ] 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). diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 07f2d810..c04748b5 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -68,6 +68,8 @@ import {DEFAULT_REPORT_OPTIONS, describeFindings, redactText, scanSensitive, typ import {applyReportEdit, createReportReview, createTextReview, renderReportReview, reportReviewKey, type ReportReviewState} from '../ui/ReportReview.js'; 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 {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'; @@ -1620,6 +1622,17 @@ export class TerminalApp { 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 === 'copy') { + void this.copyText(action.text, 'Copied the document').then(note => { if (note && this.viewer === viewer) { viewer.note = note; this.render(); } }); + } + this.render(); + return; + } if (this.comparePanel) { const {columns, rows} = this.dimensions(); const action = comparePanelKey(this.comparePanel, key, columns, rows - 4); @@ -2873,6 +2886,7 @@ export class TerminalApp { else this.configureClipboardChanges(slash.suggestions); } 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 === 'compare') { if (slash.error) this.output.addFrontendInteraction(command.trim(), slash.error, ERROR); else { @@ -3230,6 +3244,7 @@ export class TerminalApp { try { await writeClipboard(record.command); this.noteClipboard(`Copied command · ${copyStats(record.command).characters.toLocaleString()} characters`, 'success'); } catch (error) { this.noteClipboard(clipboardFailure(error), 'error'); } } else if (action === 'fold') this.output.toggleExpanded(index); + else if (action === 'view') this.openOutputViewer(record); else if (action === 'pager') await this.openBlockInPager(record); else if (action === 'explain') { this.explainBlock = record.startId; this.openAsk('why did this fail'); } else if (action === 'edit' || action === 'rerun') { @@ -3553,6 +3568,50 @@ export class TerminalApp { finally { rmSync(directory, {recursive: true, force: true}); this.render(); } } + private viewerLook(): ViewerLook { + return {syntax: this.syntaxSgr, hyperlinks: this.host.capabilities.hyperlinks}; + } + + /** + * /view: a completed output by its /copy number (the latest when no target is given), or a file relative to the + * shell's directory. The file is read asynchronously and read-only; a failure is reported in the transcript. + */ + private openViewer(target: string, command: string): void { + this.viewerLoad?.abort(); + this.viewerLoad = undefined; + if (!target || /^[1-9]\d{0,2}$/u.test(target)) { + const number = target ? Number(target) : 1; + const record = this.output.recentShellCommands()[number - 1]; + if (!record) { + this.output.addFrontendInteraction(command, target ? `No completed command output at ${number}.` : 'No completed output yet. /view opens a file.', target ? ERROR : INFO); + return; + } + this.openOutputViewer(record); + return; + } + const load = new AbortController(); + this.viewerLoad = load; + void loadFileDocument(target, this.shellCwd, {signal: load.signal}).then(result => { + if (this.viewerLoad !== load || this.stopped) return; + this.viewerLoad = undefined; + if (!result.ok) { this.output.addFrontendInteraction(command, result.reason, ERROR); this.render(); return; } + // Another panel opened while the file was read: it keeps the keys; the file is not opened over it. + if (this.hasModalInput()) return; + this.panelOrigin = undefined; + this.viewer = createViewer(result.document); + 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; + this.panelOrigin = undefined; + // The text /copy copies (tabs as the program wrote them), without the optional completion status. + this.viewer = createViewer(outputDocument({command: record.command, output: recordCopyText(record, false), exitCode: record.exitCode}, Math.max(1, number))); + this.render(); + } + /** Compare output…: straight to the difference when the other side is known, otherwise choose it (previous run first). */ private openCompare(base: CompletedCommand, partner?: CompletedCommand): void { this.panelOrigin = undefined; @@ -4905,7 +4964,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.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.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); } @@ -4947,6 +5006,7 @@ export class TerminalApp { if (this.summaryReview) return renderReportReview(this.summaryReview.document, columns, this.dimensions().rows - 4); 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.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)); @@ -5875,6 +5935,10 @@ export class TerminalApp { private reportReview?: ReportReviewState; /** Compare output…: choosing the other block, then the difference. */ private comparePanel?: ComparePanelState; + /** The native read-only viewer (/view, View output); owns keys while open. */ + private viewer?: ViewerState; + /** A file /view is still reading; a newer request or Esc cancels it. */ + private viewerLoad?: AbortController; 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 9eda8b8a..0428b879 100644 --- a/src/ask/concepts.ts +++ b/src/ask/concepts.ts @@ -253,6 +253,9 @@ export const CONCEPTS: readonly Concept[] = [ {id: 'compare', label: 'Compare output', support: 'actionable', open: '/compare', covers: ['/compare'], aliases: ['compare output', 'compare outputs', 'diff output', 'compare runs', 'what changed between runs', 'compare with previous run'], description: '/compare shows what changed between two command outputs (by default the latest and the previous run of the same command; /compare N M picks them by /copy number). Also Actions → Compare output…. Added, removed and changed lines; c copies the diff, r a comparison report.'}, + {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: '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.'}, @@ -364,7 +367,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', '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', '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 331dc1bf..b719d050 100644 --- a/src/commands/slashCommands.ts +++ b/src/commands/slashCommands.ts @@ -41,6 +41,7 @@ const RAW_COMMANDS: readonly SlashCommand[] = [ {name: '/copy', insertion: '/copy', description: 'Select completed output: Up/Down, Enter copies, Esc returns (saved Quick Copy preferences still apply)'}, {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: '/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'}, @@ -168,6 +169,8 @@ export type ParsedSlashCommand = | {kind: 'pins'} /** /compare [A [B]]: outputs by /copy number; A defaults to the latest, B to A's previous run (or a choice). */ | {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} | {kind: 'appearance'} | {kind: 'motion'} | {kind: 'prompt'} @@ -263,6 +266,8 @@ export function parseSlashCommand(input: string): ParsedSlashCommand | undefined const clips = /^\/clips(?:\s+suggestions\s+(on|off))?\s*$/u.exec(input); if (clips) return {kind: 'clips', ...(clips[1] ? {suggestions: clips[1] === 'on'} : {})}; 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()}; // /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/markdown/highlight.ts b/src/markdown/highlight.ts index a7cfd176..434a3963 100644 --- a/src/markdown/highlight.ts +++ b/src/markdown/highlight.ts @@ -230,12 +230,12 @@ function tokenizeLine(line: string, grammar: Grammar, id: string, state: LineSta } /** Tokens for each line of a code block; a line's tokens always concatenate back to exactly that line. */ -export function highlightCode(lines: readonly string[], language: string | undefined): CodeToken[][] { +export function highlightCode(lines: readonly string[], language: string | undefined, maxLines = MAX_LINES): CodeToken[][] { const id = languageId(language); if (!id) return lines.map(line => [{text: line, kind: 'plain'}]); const state: LineState = {}; return lines.map((line, number) => { - if (number >= MAX_LINES || line.length > MAX_LINE_LENGTH) return [{text: line, kind: 'plain'}]; + if (number >= maxLines || line.length > MAX_LINE_LENGTH) return [{text: line, kind: 'plain'}]; if (id === 'diff') return diffLine(line); if (id === 'markdown') return markdownLine(line, state); return tokenizeLine(line, GRAMMARS[id]!, id, state); diff --git a/src/markdown/render.ts b/src/markdown/render.ts index 4f3bcdcb..272a91e9 100644 --- a/src/markdown/render.ts +++ b/src/markdown/render.ts @@ -209,6 +209,13 @@ function tokenSgr(kind: CodeTokenKind, context: Context): string { } } +/** The style a code token gets in rendered Markdown, for other NMSh surfaces that show code the same way (the viewer). */ +export function codeTokenStyle(kind: CodeTokenKind, options: {syntax?: SyntaxSgr; level?: ColorLevel} = {}): string { + const level = options.level ?? colorLevel(); + return tokenSgr(kind, {level, safe: false, hyperlinks: false, ...(options.syntax ? {syntax: options.syntax} : {}), + fg: color => colorEscape(38, color, level), tone: UI_COLORS.primary, codeIndex: 0}); +} + function codeRows(block: Extract, width: number, context: Context): MarkdownRow[] { const index = context.codeIndex++; const gutter = `${context.fg(UI_COLORS.separator)}${glyph(context, '│', '|')}${RESET} `; diff --git a/src/ui/BlockActions.ts b/src/ui/BlockActions.ts index ab7ec107..8ec19081 100644 --- a/src/ui/BlockActions.ts +++ b/src/ui/BlockActions.ts @@ -5,7 +5,7 @@ import {displayWidth} from '../util/text.js'; import {recordCopyText} from '../clipboard/copySelection.js'; import {findOutputReferences} from '../output/references.js'; -export type BlockActionId = 'copyCommand' | 'copyOutput' | 'copyBoth' | 'copyReport' | 'compare' | 'pager' | 'rerun' | 'edit' | 'fold' | 'explain' | 'references' | 'pin' | 'clipCommand' | 'clipOutput'; +export type BlockActionId = 'copyCommand' | 'copyOutput' | 'copyBoth' | 'copyReport' | 'compare' | 'view' | 'pager' | 'rerun' | 'edit' | 'fold' | 'explain' | 'references' | 'pin' | 'clipCommand' | 'clipOutput'; /** One registry for both pointer-opened and keyboard-opened block palettes. */ export const BLOCK_ACTIONS: readonly {id: BlockActionId; label: string; detail: string}[] = [ {id: 'copyCommand', label: 'Copy command', detail: 'Stored plain command'}, @@ -13,6 +13,7 @@ export const BLOCK_ACTIONS: readonly {id: BlockActionId; label: string; detail: {id: 'copyBoth', label: 'Copy command + output', detail: 'Stored plain command and full output'}, {id: 'copyReport', label: 'Copy as report…', detail: 'Command, status, exit code, duration and output as Markdown or plain text; reviewed first'}, {id: 'compare', label: 'Compare output…', detail: 'With the previous run of this command, or another block you choose'}, + {id: 'view', label: 'View output', detail: "Full stored output in NMSh's viewer: find, line numbers, wrap; read-only"}, {id: 'pager', label: 'Open in pager', detail: 'Command and full stored output in your pager (less), read-only'}, {id: 'rerun', label: 'Rerun command', detail: 'Explicitly submit a visible command in the current shell directory'}, {id: 'edit', label: 'Edit & rerun', detail: 'Put command in composer; Enter executes it'}, diff --git a/src/viewer/ViewerPanel.ts b/src/viewer/ViewerPanel.ts new file mode 100644 index 00000000..01d3c5de --- /dev/null +++ b/src/viewer/ViewerPanel.ts @@ -0,0 +1,502 @@ +import type {Key} from '../terminal/keys.js'; +import type {SyntaxSgr} from '../input/syntaxTheme.js'; +import {graphemes} from '../input/inputLayout.js'; +import {codeTokenStyle, renderMarkdown, type MarkdownRow} from '../markdown/render.js'; +import {highlightCode, type CodeToken} from '../markdown/highlight.js'; +import {colorLevel} from '../presentation/capabilities.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 {formatBytes, inert, type ViewDocument} from './document.js'; + +/** + * The native read-only viewer (#397): one document in a full-height panel, never the transcript. Drawing is bounded + * to what is visible: only the lines on screen are laid out, one wrapped line takes at most MAX_WRAP_ROWS rows, syntax + * roles cover the first HIGHLIGHT_LINES lines and Markdown renders up to RENDER_MARKDOWN_BYTES. Every character is + * drawn inert: control and format characters (bidi overrides, zero-width marks) are shown as visible marks and tabs + * as spaces, and Markdown links are links only for the renderer's allowed targets. + */ + +const HIGHLIGHT_LINES = 20_000; +const RENDER_MARKDOWN_BYTES = 1024 * 1024; +const MAX_WRAP_ROWS = 200; +const TAB = 4; +const HORIZONTAL_STEP = 8; +const WHEEL_ROWS = 3; +const RESET = '\u001b[0m'; +const REVERSE = '\u001b[7m'; +const BOLD = '\u001b[1m'; + +interface Segment {text: string; style: string} +/** One logical line of what is shown: a source line (segments), or a rendered Markdown row (prestyled). */ +interface Unit {text: string; segments?: Segment[]; prestyled?: string; number?: number; rest?: number} + +export interface ViewerState { + document: ViewDocument; + /** First visible unit, and the wrapped row inside it. */ + top: number; + sub: number; + /** Horizontal offset in cells while not wrapping. */ + left: number; + wrap: boolean; + numbers: boolean; + /** Markdown drawn rendered (true) or as source. */ + rendered: boolean; + input?: {kind: 'search' | 'goto'; value: string}; + query?: string; + match?: {unit: number; start: number; length: number}; + matchCount?: number; + /** One line of feedback (copy result, no match, wrapped search). */ + note?: string; + /** Units the last drawing showed, for the position line. */ + shown?: {first: number; last: number}; + tokens?: CodeToken[][]; + markdown?: {width: number; rows: MarkdownRow[]}; +} + +export type ViewerAction = {kind: 'close'} | {kind: 'copy'; text: string; lines: number}; + +export interface ViewerLook {syntax?: SyntaxSgr; hyperlinks?: boolean} + +export function createViewer(document: ViewDocument): ViewerState { + const markdown = document.format === 'markdown' && Buffer.byteLength(document.text) <= RENDER_MARKDOWN_BYTES; + const note = document.format === 'markdown' && !markdown ? 'Too large to render: showing the Markdown source.' : undefined; + return {document, top: 0, sub: 0, left: 0, wrap: true, numbers: !markdown, rendered: markdown, ...(note ? {note} : {})}; +} + +// ---- Text --------------------------------------------------------------------------------------------------------- + +/** Characters drawn as visible marks: controls (not tab) and invisible format, line and paragraph separators. */ +const UNSAFE = /[\u0000-\u0008\u000a-\u001f\u007f-\u009f\p{Cf}\p{Zl}\p{Zp}]/gu; +const PRINTABLE_ASCII = /^[\t\x20-\x7e]*$/u; +const markOf = (character: string) => character === '\u001b' ? '␛' : '·'; +const cellWidth = (character: string) => character.length === 1 && character >= ' ' && character <= '~' ? 1 : displayWidth(character); + +/** + * A piece of a line as the viewer draws it, from `column`: tabs expanded to the next stop and every unsafe character + * a visible mark. Printable ASCII takes a fast path; other text is measured by grapheme. + */ +function safePiece(text: string, column: number): {text: string; column: number} { + if (PRINTABLE_ASCII.test(text)) { + if (!text.includes('\t')) return {text, column: column + text.length}; + let out = ''; + for (const character of text) { + if (character === '\t') { const spaces = TAB - (column % TAB); out += ' '.repeat(spaces); column += spaces; } + else { out += character; column += 1; } + } + return {text: out, column}; + } + const safe = text.replace(UNSAFE, markOf); + if (!safe.includes('\t')) return {text: safe, column: column + displayWidth(safe)}; + let out = ''; + for (const character of graphemes(safe)) { + if (character === '\t') { const spaces = TAB - (column % TAB); out += ' '.repeat(spaces); column += spaces; } + else { out += character; column += cellWidth(character); } + } + return {text: out, column}; +} + +/** The whole line as drawn: what search matches against, so match offsets are drawing offsets. */ +function safeLine(line: string): string { + return safePiece(line, 0).text; +} + +/** + * Tokens → drawable segments, stopping once `budget` cells are produced: a line is never laid out further than it + * can be shown. `rest` counts the source characters left undrawn. + */ +function safeSegments(tokens: readonly CodeToken[], styleOf: (token: CodeToken) => string, budget: number): {segments: Segment[]; rest: number} { + const segments: Segment[] = []; + let column = 0; + let rest = 0; + for (const token of tokens) { + if (column >= budget) { rest += token.text.length; continue; } + // Enough source for the remaining cells (a cell is at most a few code units; a longer cluster just shows less). + const room = (budget - column) * 4 + 8; + const source = token.text.length > room ? token.text.slice(0, room) : token.text; + rest += token.text.length - source.length; + const piece = safePiece(source, column); + column = piece.column; + if (piece.text) segments.push({text: piece.text, style: styleOf(token)}); + } + return {segments, rest}; +} + +function smartMatch(text: string, query: string): (from: number) => number { + const insensitive = query === query.toLowerCase(); + const haystack = insensitive ? text.toLowerCase() : text; + return from => haystack.indexOf(query, from); +} + +// ---- Units -------------------------------------------------------------------------------------------------------- + +function contentWidth(state: ViewerState, columns: number): number { + return Math.max(4, columns - 4 - gutterWidth(state)); +} + +function gutterWidth(state: ViewerState): number { + return state.numbers && !state.rendered ? Math.max(3, String(state.document.lines.length).length) + 1 : 0; +} + +function markdownRows(state: ViewerState, width: number, look: ViewerLook): MarkdownRow[] { + if (state.markdown?.width !== width) { + state.markdown = {width, rows: renderMarkdown(state.document.text, width, {...(look.syntax ? {syntax: look.syntax} : {}), hyperlinks: Boolean(look.hyperlinks)})}; + } + return state.markdown.rows; +} + +function unitCount(state: ViewerState, width: number, look: ViewerLook): number { + return state.rendered ? markdownRows(state, width, look).length : state.document.lines.length; +} + +function lineTokens(state: ViewerState, index: number): CodeToken[] { + const line = state.document.lines[index] ?? ''; + if (!state.document.language) return [{text: line, kind: 'plain'}]; + state.tokens ??= highlightCode(state.document.lines.slice(0, HIGHLIGHT_LINES), state.document.language, HIGHLIGHT_LINES); + return state.tokens[index] ?? [{text: line, kind: 'plain'}]; +} + +/** The cells a unit may need to draw: a bounded wrap, or the visible window when scrolled sideways. */ +function cellBudget(state: ViewerState, width: number): number { + return state.wrap ? MAX_WRAP_ROWS * width : state.left + width + 1; +} + +function unitAt(state: ViewerState, index: number, width: number, look: ViewerLook): Unit { + if (state.rendered) { + const row = markdownRows(state, width, look)[index]; + return {text: row?.plain ?? '', prestyled: row?.ansi ?? ''}; + } + const level = colorLevel(); + const styleOf = (token: CodeToken) => level === 'none' ? '' : codeTokenStyle(token.kind, {...(look.syntax ? {syntax: look.syntax} : {}), level}); + const {segments, rest} = safeSegments(lineTokens(state, index), styleOf, cellBudget(state, width)); + return {text: segments.map(segment => segment.text).join(''), segments, number: index + 1, rest}; +} + +/** The searchable text of a unit (the same characters it is drawn with), without styling work. */ +function unitText(state: ViewerState, index: number, width: number, look: ViewerLook): string { + if (state.rendered) return markdownRows(state, width, look)[index]?.plain ?? ''; + return safeLine(state.document.lines[index] ?? ''); +} + +interface Cell {text: string; width: number; style: string} +type Highlight = {start: number; end: number; current: boolean}; + +function cellsOf(unit: Unit, highlights: readonly Highlight[]): Cell[] { + const cells: Cell[] = []; + let offset = 0; + let range = 0; + for (const segment of unit.segments ?? []) { + for (const character of PRINTABLE_ASCII.test(segment.text) ? segment.text : graphemes(segment.text)) { + while (range < highlights.length && highlights[range]!.end <= offset) range += 1; + const mark = range < highlights.length && offset >= highlights[range]!.start ? highlights[range] : undefined; + cells.push({text: character, width: cellWidth(character), style: mark ? `${segment.style}${REVERSE}${mark.current ? BOLD : ''}` : segment.style}); + offset += character.length; + } + } + return cells; +} + +function paint(cells: readonly Cell[]): string { + let out = ''; + let style: string | undefined; + for (const cell of cells) { + if (cell.style !== style) { out += `${RESET}${cell.style}`; style = cell.style; } + out += cell.text; + } + return `${out}${RESET}`; +} + +/** The rows a unit takes: wrapped (bounded), or one row scrolled by `left`. */ +function unitRows(state: ViewerState, unit: Unit, width: number, highlights: readonly Highlight[]): string[] { + if (unit.prestyled !== undefined) { + return [highlights.length ? `${REVERSE}${truncateAnsi(unit.text, width)}${RESET}` : unit.prestyled]; + } + const cells = cellsOf(unit, highlights); + const subtle = foreground(UI_COLORS.subtle); + const more = Boolean(unit.rest); + if (!state.wrap) { + let skipped = 0; + let index = 0; + while (index < cells.length && skipped + cells[index]!.width <= state.left) { skipped += cells[index]!.width; index += 1; } + const row: Cell[] = []; + let used = 0; + while (index < cells.length && used + cells[index]!.width <= width) { row.push(cells[index]!); used += cells[index]!.width; index += 1; } + if ((index < cells.length || more) && row.length) row[row.length - 1] = {text: '›', width: 1, style: subtle}; + if (state.left > 0 && row.length) row[0] = {text: '‹', width: 1, style: subtle}; + return [paint(row)]; + } + const rows: string[] = []; + let row: Cell[] = []; + let used = 0; + for (let index = 0; index < cells.length; index += 1) { + const cell = cells[index]!; + if (used + cell.width > width && row.length) { + rows.push(paint(row)); + row = []; + used = 0; + if (rows.length === MAX_WRAP_ROWS - 1) { + const rest = cells.length - index + (unit.rest ?? 0); + rows.push(`${subtle}${truncateText(`… ${rest.toLocaleString('en-US')} more characters on this line · w turns wrapping off`, width)}${RESET}`); + return rows; + } + } + row.push(cell); + used += cell.width; + } + rows.push(paint(row)); + return rows; +} + +function rowCount(state: ViewerState, index: number, width: number, look: ViewerLook): number { + if (!state.wrap || state.rendered) return 1; + const line = state.document.lines[index] ?? ''; + if (!line) return 1; + // Printable ASCII wraps by simple division; anything else is laid out, bounded (wide characters move whole to the next row). + if (PRINTABLE_ASCII.test(line)) return Math.min(MAX_WRAP_ROWS, Math.max(1, Math.ceil(safeLine(line).length / width))); + return unitRows(state, unitAt(state, index, width, look), width, []).length; +} + +// ---- Scrolling ---------------------------------------------------------------------------------------------------- + +/** The top position at which the last unit's last row is at the bottom: no scrolling past the end. */ +function lastTop(state: ViewerState, width: number, height: number, look: ViewerLook): {top: number; sub: number} { + let remaining = height; + for (let index = unitCount(state, width, look) - 1; index >= 0; index -= 1) { + const rows = rowCount(state, index, width, look); + if (rows >= remaining) return {top: index, sub: rows - remaining}; + remaining -= rows; + } + return {top: 0, sub: 0}; +} + +function clampTop(state: ViewerState, width: number, height: number, look: ViewerLook): void { + const last = lastTop(state, width, height, look); + if (state.top > last.top || (state.top === last.top && state.sub > last.sub)) { state.top = last.top; state.sub = last.sub; } + state.top = Math.max(0, state.top); + state.sub = Math.max(0, Math.min(state.sub, rowCount(state, state.top, width, look) - 1)); +} + +function scroll(state: ViewerState, delta: number, width: number, height: number, look: ViewerLook): void { + let remaining = delta; + while (remaining > 0) { + const rows = rowCount(state, state.top, width, look); + if (state.sub + remaining < rows) { state.sub += remaining; break; } + remaining -= rows - state.sub; + if (state.top + 1 >= unitCount(state, width, look)) { state.sub = rows - 1; break; } + state.top += 1; + state.sub = 0; + } + while (remaining < 0) { + if (state.sub + remaining >= 0) { state.sub += remaining; break; } + remaining += state.sub + 1; + if (state.top === 0) { state.sub = 0; break; } + state.top -= 1; + state.sub = rowCount(state, state.top, width, look) - 1; + } + clampTop(state, width, height, look); +} + +function reveal(state: ViewerState, unit: number, width: number, height: number, look: ViewerLook): void { + const visible = state.shown && unit >= state.shown.first && unit <= state.shown.last && unit !== state.shown.last; + if (!visible) { state.top = Math.max(0, unit - Math.floor(height / 3)); state.sub = 0; } + clampTop(state, width, height, look); +} + +// ---- Search ------------------------------------------------------------------------------------------------------- + +function findFrom(state: ViewerState, unit: number, offset: number, direction: 1 | -1, width: number, look: ViewerLook): {unit: number; start: number; wrapped: boolean} | undefined { + const query = state.query; + if (!query) return undefined; + const count = unitCount(state, width, look); + for (let step = 0; step <= count; step += 1) { + const raw = unit + direction * step; + const index = ((raw % count) + count) % count; + const text = unitText(state, index, width, look); + const find = smartMatch(text, query); + let found = -1; + if (direction === 1) found = find(step === 0 ? offset : 0); + else { + const limit = step === 0 ? offset - 1 : text.length; + for (let at = find(0); at >= 0 && at <= limit; at = find(at + 1)) found = at; + } + if (found >= 0) return {unit: index, start: found, wrapped: direction === 1 ? raw >= count : raw < 0}; + } + return undefined; +} + +function countMatches(state: ViewerState, width: number, look: ViewerLook): number { + const query = state.query; + if (!query) return 0; + let total = 0; + for (let index = 0; index < unitCount(state, width, look); index += 1) { + const find = smartMatch(unitText(state, index, width, look), query); + for (let at = find(0); at >= 0; at = find(at + query.length)) total += 1; + } + return total; +} + +function step(state: ViewerState, direction: 1 | -1, width: number, height: number, look: ViewerLook): void { + const from = state.match ? {unit: state.match.unit, offset: state.match.start + (direction === 1 ? 1 : 0)} : {unit: state.top, offset: 0}; + const found = findFrom(state, from.unit, from.offset, direction, width, look); + if (!found) { state.match = undefined; state.note = `No match for “${state.query}”.`; return; } + state.match = {unit: found.unit, start: found.start, length: state.query!.length}; + state.note = found.wrapped ? (direction === 1 ? 'Search wrapped to the top.' : 'Search wrapped to the bottom.') : undefined; + reveal(state, found.unit, width, height, look); +} + +// ---- Keys --------------------------------------------------------------------------------------------------------- + +export function viewerKey(state: ViewerState, key: Key, columns: number, height: number, look: ViewerLook = {}): ViewerAction | undefined { + const width = contentWidth(state, columns); + const body = bodyHeight(state, columns, height); + 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; + if (input.kind === 'goto') { + const line = Number(input.value.trim()); + if (!Number.isInteger(line) || line < 1) { state.note = 'Type a line number.'; return undefined; } + state.top = Math.min(line, unitCount(state, width, look)) - 1; + state.sub = 0; + clampTop(state, width, body, look); + state.note = undefined; + return undefined; + } + state.query = input.value; + state.match = undefined; + if (!state.query) { state.matchCount = undefined; return undefined; } + state.matchCount = countMatches(state, width, look); + step(state, 1, width, body, look); + return undefined; + } + const next = editText(input.value, key); + if (next !== undefined) input.value = next.slice(0, 200); + return undefined; + } + state.note = undefined; + const text = key.kind === 'text' ? key.value : undefined; + if (key.kind === 'escape' && state.query) { state.query = undefined; state.match = undefined; state.matchCount = undefined; return undefined; } + if (key.kind === 'escape' || key.kind === 'interrupt' || text === 'q') return {kind: 'close'}; + if (key.kind === 'up' || text === 'k') scroll(state, -1, width, body, look); + else if (key.kind === 'down' || text === 'j') scroll(state, 1, width, body, look); + else if (key.kind === 'wheelUp') scroll(state, -WHEEL_ROWS, width, body, look); + else if (key.kind === 'wheelDown') scroll(state, WHEEL_ROWS, width, body, look); + else if (key.kind === 'pageUp' || text === 'b') scroll(state, -Math.max(1, body - 1), width, body, look); + else if (key.kind === 'pageDown' || text === ' ' || text === 'f') scroll(state, Math.max(1, body - 1), width, body, look); + else if (key.kind === 'lineHome' || key.kind === 'bufferHome' || text === 'g') { state.top = 0; state.sub = 0; } + else if (key.kind === 'lineEnd' || key.kind === 'bufferEnd' || key.kind === 'latest' || text === 'G') Object.assign(state, lastTop(state, width, body, look)); + else if ((key.kind === 'left' || key.kind === 'right') && !state.wrap && !state.rendered) state.left = Math.max(0, state.left + (key.kind === 'left' ? -HORIZONTAL_STEP : HORIZONTAL_STEP)); + else if (text === 'w' && !state.rendered) { state.wrap = !state.wrap; state.left = 0; state.sub = 0; clampTop(state, width, body, look); } + else if (text === 'l' && !state.rendered) { state.numbers = !state.numbers; state.sub = 0; } + else if (text === 'm' && state.document.format === 'markdown') { + if (!state.rendered && Buffer.byteLength(state.document.text) > RENDER_MARKDOWN_BYTES) state.note = 'Too large to render: showing the Markdown source.'; + else { state.rendered = !state.rendered; state.numbers = !state.rendered; state.top = 0; state.sub = 0; state.match = undefined; state.matchCount = undefined; } + } + else if (text === '/' || key.kind === 'find') state.input = {kind: 'search', value: state.query ?? ''}; + else if (text === ':') state.input = {kind: 'goto', value: ''}; + 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}; + return undefined; +} + +// ---- Drawing ------------------------------------------------------------------------------------------------------ + +function detail(document: ViewDocument): string { + const lines = `${document.lines.length.toLocaleString('en-US')} line${document.lines.length === 1 ? '' : 's'}`; + if (document.origin.kind === 'output') { + const status = document.origin.exitCode === undefined ? '' : ` · exit ${document.origin.exitCode}`; + return `output ${document.origin.number}${status} · ${lines}${document.format === 'diff' ? ' · diff' : ''}`; + } + const kind = document.format === 'markdown' ? 'Markdown' : document.format === 'diff' ? 'diff' : document.language ?? 'text'; + const ending = document.lineEnding === 'crlf' ? ' · CRLF' : document.lineEnding === 'mixed' ? ' · mixed line endings' : ''; + const link = document.origin.symlink ? ` · → ${inert(document.origin.realPath)}` : ''; + return `${kind} · ${lines} · ${formatBytes(document.origin.bytes)}${ending}${link}`; +} + +function headerRows(state: ViewerState, columns: number, height: number): string[] { + const width = Math.max(10, columns - 4); + const primary = foreground(UI_COLORS.primary); + const subtle = foreground(UI_COLORS.subtle); + const warning = foreground(UI_COLORS.warning); + const title = state.document.origin.kind === 'output' ? `$ ${state.document.title}` : state.document.title; + const shownTitle = truncateText(title.replace(/[\u0000-\u001f\u007f-\u009f\p{Cf}]/gu, '·'), Math.max(8, Math.floor(width * 0.6))); + const rows = [` ${primary}${shownTitle}${RESET} ${subtle}${truncateText(detail(state.document), Math.max(1, width - displayWidth(shownTitle) - 2))}${RESET}`]; + const notes = height >= 12 ? state.document.notes.slice(0, 2) : []; + for (const note of notes) rows.push(` ${warning}${truncateText(note, width)}${RESET}`); + return rows; +} + +function controls(state: ViewerState, columns: number): string[] { + const width = Math.max(1, columns - 4); + if (state.input) return renderControlRows([['Enter', state.input.kind === 'search' ? 'find' : 'go'], ['Esc', 'cancel']], width); + return renderControlRows([ + ['↑↓', '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'], + ], width); +} + +function bodyHeight(state: ViewerState, columns: number, height: number): number { + return Math.max(1, height - 2 - headerRows(state, columns, height).length - 1 - controls(state, columns).length); +} + +function statusRow(state: ViewerState, width: number, total: number): string { + const subtle = foreground(UI_COLORS.subtle); + const accent = foreground(UI_COLORS.accent); + if (state.input) { + const label = state.input.kind === 'search' ? 'Find: ' : 'Go to line: '; + return ` ${accent}${label}${RESET}${truncateText(state.input.value.replace(/[\u0000-\u001f\u007f-\u009f\p{Cf}]/gu, '·'), Math.max(1, width - label.length - 1))}${accent}▏${RESET}`; + } + const unit = state.rendered ? 'Row' : 'Line'; + const first = state.shown ? state.shown.first + 1 : 0; + const last = state.shown ? state.shown.last + 1 : 0; + const position = total === 0 ? 'Empty' : `${unit}${last > first ? 's' : ''} ${first.toLocaleString('en-US')}${last > first ? `–${last.toLocaleString('en-US')}` : ''} of ${total.toLocaleString('en-US')}`; + const search = state.query ? ` · ${state.matchCount === undefined ? '' : `${state.matchCount.toLocaleString('en-US')} match${state.matchCount === 1 ? '' : 'es'} for `}“${state.query}”` : ''; + const note = state.note ? ` · ${state.note}` : ''; + return ` ${subtle}${truncateText(`${position}${search}${note}`.replace(/[\u0000-\u001f\u007f-\u009f\p{Cf}]/gu, '·'), width)}${RESET}`; +} + +export function renderViewer(state: ViewerState, columns: number, height: number, look: ViewerLook = {}): string[] { + const width = contentWidth(state, columns); + const gutter = gutterWidth(state); + const body = bodyHeight(state, columns, height); + clampTop(state, width, body, look); + const out = headerRows(state, columns, height); + const total = unitCount(state, width, look); + const subtle = foreground(UI_COLORS.subtle); + const rows: string[] = []; + let first = -1; + let last = -1; + for (let index = state.top; index < total && rows.length < body; index += 1) { + const unit = unitAt(state, index, width, look); + const highlights = state.query ? matchRanges(unit.text, state.query, state.match?.unit === index ? state.match.start : -1) : []; + let unitRowsList = unitRows(state, unit, width, highlights); + if (index === state.top) unitRowsList = unitRowsList.slice(state.sub); + unitRowsList.forEach((row, offset) => { + if (rows.length >= body) return; + const number = gutter ? `${subtle}${(offset === 0 && (index !== state.top || state.sub === 0) ? String(unit.number ?? '') : '').padStart(gutter - 1)}${RESET} ` : ''; + rows.push(` ${number}${row}`); + }); + if (first < 0) first = index; + last = index; + } + state.shown = first < 0 ? undefined : {first, last}; + if (!total) rows.push(` ${subtle}${state.document.origin.kind === 'output' ? 'No output.' : 'Empty file.'}${RESET}`); + while (rows.length < body) rows.push(''); + out.push(...rows, statusRow(state, Math.max(1, columns - 4), total), ...controls(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]; +} + +function matchRanges(text: string, query: string, current: number): Array<{start: number; end: number; current: boolean}> { + const ranges: Array<{start: number; end: number; current: boolean}> = []; + const find = smartMatch(text, query); + for (let at = find(0); at >= 0 && ranges.length < 256; at = find(at + Math.max(1, query.length))) ranges.push({start: at, end: at + query.length, current: at === current}); + return ranges; +} diff --git a/src/viewer/document.ts b/src/viewer/document.ts new file mode 100644 index 00000000..399cc359 --- /dev/null +++ b/src/viewer/document.ts @@ -0,0 +1,185 @@ +import {constants} from 'node:fs'; +import {lstat, open, realpath} from 'node:fs/promises'; +import {homedir} from 'node:os'; +import {basename, extname, isAbsolute, join, resolve} from 'node:path'; +import {languageId} from '../markdown/highlight.js'; + +/** + * A document for the native viewer (#397) and editor: lines of text and where they came from. Loading is read-only and + * bounded: only regular files are read (a FIFO or device is refused before anything waits on it), at most + * MAX_VIEW_BYTES and MAX_VIEW_LINES are kept, binary content is refused, and invalid UTF-8 is shown with replacement + * characters and a note. Nothing is executed, followed beyond the file's own symlink, fetched or written. + */ + +export const MAX_VIEW_BYTES = 8 * 1024 * 1024; +export const MAX_VIEW_LINES = 200_000; +/** Bytes inspected for binary content. */ +const SNIFF_BYTES = 8192; +const CHUNK = 64 * 1024; + +export type DocumentFormat = 'text' | 'markdown' | 'diff'; + +export type DocumentOrigin = + | {kind: 'file'; path: string; realPath: string; symlink: boolean; bytes: number; mtimeMs: number} + | {kind: 'output'; command: string; number: number; exitCode?: number}; + +export interface ViewDocument { + /** What the header names: the path as given (home shortened) or the command. */ + title: string; + origin: DocumentOrigin; + lines: string[]; + /** The text copying the whole document gives: the decoded file up to the limits, or the record's output. */ + text: string; + format: DocumentFormat; + /** A grammar id for syntax roles, when the file type is known. */ + language?: string; + /** Limits that applied, said plainly (truncation, encoding). */ + notes: string[]; + lineEnding: 'lf' | 'crlf' | 'mixed' | 'none'; + /** False when the text was cut at a read or line limit. */ + complete: boolean; +} + +export type LoadResult = {ok: true; document: ViewDocument} | {ok: false; reason: string}; + +/** `~` and `~/…` name the home directory; other relative paths are relative to the shell's directory. */ +export function resolveViewPath(input: string, cwd: string, home = homedir()): string { + const trimmed = input.trim(); + if (trimmed === '~') return home; + if (trimmed.startsWith('~/')) return join(home, trimmed.slice(2)); + return isAbsolute(trimmed) ? trimmed : resolve(cwd, trimmed); +} + +/** Text from a file name or path made inert for display: controls and invisible format characters become marks. */ +export function inert(text: string): string { + return text.replace(/[\u0000-\u001f\u007f-\u009f\p{Cf}\p{Zl}\p{Zp}]/gu, character => character === '\u001b' ? '␛' : '·'); +} + +/** The path as people read it (under home it starts with ~), safe to draw: a file name can hold control characters. */ +export function displayPath(path: string, home = homedir()): string { + return inert(home && (path === home || path.startsWith(`${home}/`)) ? `~${path.slice(home.length)}` : path); +} + +const FILE_NAMES: Record = {makefile: 'makefile', gnumakefile: 'makefile', dockerfile: 'dockerfile', containerfile: 'dockerfile', + '.bashrc': 'shell', '.zshrc': 'shell', '.zshenv': 'shell', '.profile': 'shell', '.bash_profile': 'shell', 'config.fish': 'fish', justfile: 'makefile'}; + +/** How a file reads: Markdown and diffs by their extension, syntax roles by extension or well-known name. */ +export function formatForPath(path: string): {format: DocumentFormat; language?: string} { + const name = basename(path).toLowerCase(); + const extension = extname(name).slice(1); + if (extension === 'md' || extension === 'markdown' || extension === 'mdx') return {format: 'markdown', language: 'markdown'}; + if (extension === 'diff' || extension === 'patch') return {format: 'diff', language: 'diff'}; + const language = languageId(FILE_NAMES[name] ?? extension); + return {format: 'text', ...(language ? {language} : {})}; +} + +/** Commands whose output is a unified diff; anything else is plain text (output is never implicitly Markdown). */ +const DIFF_COMMAND = /^\s*(?:git\s+(?:--no-pager\s+)?(?:diff|show|log\s+(?:.*\s)?-p)\b|diff\s+(?:-\S*u|--unified)|hg\s+diff\b|jj\s+diff\b)/u; + +/** A completed command's stored output as a document (the same text `/copy` copies). */ +export function outputDocument(record: {command: string; output: string; exitCode?: number}, number: number): ViewDocument { + const {lines, lineEnding} = splitLines(record.output); + const diff = DIFF_COMMAND.test(record.command) && lines.some(line => line.startsWith('@@')); + return {title: record.command, origin: {kind: 'output', command: record.command, number, ...(record.exitCode !== undefined ? {exitCode: record.exitCode} : {})}, + lines, text: record.output, format: diff ? 'diff' : 'text', ...(diff ? {language: 'diff'} : {}), notes: [], lineEnding, complete: true}; +} + +function splitLines(text: string): {lines: string[]; lineEnding: ViewDocument['lineEnding']} { + if (!text) return {lines: [], lineEnding: 'none'}; + const raw = text.split('\n'); + if (raw.at(-1) === '') raw.pop(); + let crlf = 0; + const terminated = text.endsWith('\n') ? raw.length : raw.length - 1; + const lines = raw.map((line, index) => { + if (line.endsWith('\r')) { if (index < terminated) crlf += 1; return line.slice(0, -1); } + return line; + }); + const lineEnding = terminated === 0 ? 'none' : crlf === 0 ? 'lf' : crlf === terminated ? 'crlf' : 'mixed'; + return {lines, lineEnding}; +} + +function describe(error: unknown, shown: string): string { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + if (code === 'ENOENT') return `${shown} does not exist.`; + if (code === 'EACCES' || code === 'EPERM') return `${shown} cannot be read: permission denied.`; + if (code === 'ELOOP') return `${shown} is a symlink loop.`; + if (code === 'ENOTDIR') return `${shown}: a part of the path is not a directory.`; + if (code === 'EISDIR') return `${shown} is a directory.`; + if (code === 'ABORT_ERR') return 'Cancelled.'; + return `${shown} cannot be read${code ? ` (${code})` : ''}.`; +} + +/** Read a regular file for viewing (see the module comment for the limits). */ +export async function loadFileDocument(input: string, cwd: string, options: {home?: string; maxBytes?: number; maxLines?: number; signal?: AbortSignal} = {}): Promise { + const home = options.home ?? homedir(); + const path = resolveViewPath(input, cwd, home); + const shown = displayPath(path, home); + const maxBytes = options.maxBytes ?? MAX_VIEW_BYTES; + let symlink = false; + try { symlink = (await lstat(path)).isSymbolicLink(); } catch (error) { return {ok: false, reason: describe(error, shown)}; } + let handle; + // Non-blocking: opening a FIFO never waits for a writer, and is refused below like any other non-regular file. + try { handle = await open(path, constants.O_RDONLY | constants.O_NONBLOCK | (constants.O_NOCTTY ?? 0)); } + catch (error) { return {ok: false, reason: describe(error, 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; /view reads files only.`}; + const chunks: Buffer[] = []; + let total = 0; + while (total <= maxBytes) { + if (options.signal?.aborted) return {ok: false, reason: 'Cancelled.'}; + const chunk = Buffer.allocUnsafe(Math.min(CHUNK, maxBytes + 1 - total)); + const {bytesRead} = await handle.read(chunk, 0, chunk.length, null); + if (bytesRead === 0) break; + chunks.push(chunk.subarray(0, bytesRead)); + total += bytesRead; + } + let bytes = Buffer.concat(chunks, total); + const notes: string[] = []; + let complete = true; + if (bytes.length > maxBytes) { + complete = false; + let cut = maxBytes; + // Never split a UTF-8 sequence: step back over continuation bytes. + while (cut > 0 && (bytes[cut]! & 0xc0) === 0x80) cut -= 1; + bytes = bytes.subarray(0, cut); + notes.push(`Showing the first ${formatBytes(cut)} of ${formatBytes(info.size)}; open it with less or an editor for the rest.`); + } + const sniff = bytes.subarray(0, SNIFF_BYTES); + if ((sniff[0] === 0xff && sniff[1] === 0xfe) || (sniff[0] === 0xfe && sniff[1] === 0xff)) { + return {ok: false, reason: `${shown} is UTF-16 text, which /view does not decode; try less or an editor.`}; + } + if (sniff.includes(0)) return {ok: false, reason: `${shown} looks binary (it contains NUL bytes); /view shows text only.`}; + if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) bytes = bytes.subarray(3); + let text: string; + try { text = new TextDecoder('utf-8', {fatal: true}).decode(bytes); } + catch { + text = new TextDecoder('utf-8').decode(bytes); + notes.push('Not valid UTF-8: undecodable bytes are shown as �.'); + } + let {lines, lineEnding} = splitLines(text); + const maxLines = options.maxLines ?? MAX_VIEW_LINES; + if (lines.length > maxLines) { + complete = false; + notes.push(`Showing the first ${maxLines.toLocaleString('en-US')} lines; open it with less or an editor for the rest.`); + lines = lines.slice(0, maxLines); + text = lines.join(lineEnding === 'crlf' ? '\r\n' : '\n'); + } + let realPath = path; + if (symlink) { try { realPath = await realpath(path); } catch { /* the link changed meanwhile; the descriptor read is what is shown */ } } + const {format, language} = formatForPath(path); + return {ok: true, document: {title: shown, origin: {kind: 'file', path, realPath, symlink, bytes: info.size, mtimeMs: info.mtimeMs}, lines, text, format, + ...(language ? {language} : {}), notes, lineEnding, complete}}; + } catch (error) { + return {ok: false, reason: describe(error, shown)}; + } finally { + await handle.close().catch(() => undefined); + } +} + +export function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0)} KiB`; + return `${(bytes / 1024 / 1024).toFixed(1)} MiB`; +} diff --git a/tests/viewer.test.ts b/tests/viewer.test.ts new file mode 100644 index 00000000..903d49cf --- /dev/null +++ b/tests/viewer.test.ts @@ -0,0 +1,205 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {spawnSync} from 'node:child_process'; +import {chmodSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {loadFileDocument, outputDocument, resolveViewPath, type ViewDocument} from '../src/viewer/document.js'; +import {createViewer, renderViewer, viewerKey, type ViewerState} from '../src/viewer/ViewerPanel.js'; +import {parseSlashCommand} from '../src/commands/slashCommands.js'; +import {displayWidth, stripAnsi} from '../src/util/text.js'; +import type {Key} from '../src/terminal/keys.js'; + +const scratch = () => realpathSync(mkdtempSync(join(tmpdir(), 'nmsh-view-'))); +const text = (value: string): Key => ({kind: 'text', value}); +const press = (state: ViewerState, keys: Array, columns = 80, height = 24) => { + let action; + for (const key of keys) action = viewerKey(state, typeof key === 'string' ? text(key) : key, columns, height); + return action; +}; +const draw = (state: ViewerState, columns = 80, height = 24) => renderViewer(state, columns, height); +const shown = (rows: string[]) => rows.map(row => stripAnsi(row)); +async function load(path: string, options: Parameters[2] = {}): Promise { + const result = await loadFileDocument(path, '/', options); + if (!result.ok) throw new Error(result.reason); + return result.document; +} + +test('/view parses a path, an output number or nothing', () => { + assert.deepEqual(parseSlashCommand('/view README.md'), {kind: 'view', target: 'README.md'}); + assert.deepEqual(parseSlashCommand('/view 2'), {kind: 'view', target: '2'}); + assert.deepEqual(parseSlashCommand('/view'), {kind: 'view', target: ''}); + assert.notEqual(parseSlashCommand('/viewer')?.kind, 'view'); + assert.equal(resolveViewPath('~/notes.md', '/work', '/home/me'), '/home/me/notes.md'); + assert.equal(resolveViewPath('docs/a.md', '/work', '/home/me'), '/work/docs/a.md'); +}); + +test('files: only regular, decodable text is read, within limits, and every refusal says why', async () => { + const root = scratch(); + try { + writeFileSync(join(root, 'a.ts'), 'const a = 1;\r\nconst b = 2;\r\n'); + const code = await load(join(root, 'a.ts')); + assert.deepEqual(code.lines, ['const a = 1;', 'const b = 2;']); + assert.equal(code.lineEnding, 'crlf'); + assert.equal(code.format, 'text'); + assert.equal(code.language, 'js'); + assert.equal(code.complete, true); + + mkdirSync(join(root, 'dir')); + assert.match((await loadFileDocument(join(root, 'dir'), '/') as {reason: string}).reason, /is a directory/u); + assert.match((await loadFileDocument(join(root, 'missing.txt'), '/') as {reason: string}).reason, /does not exist/u); + writeFileSync(join(root, 'bin'), Buffer.from([0x7f, 0x45, 0x4c, 0x46, 0, 1, 2])); + assert.match((await loadFileDocument(join(root, 'bin'), '/') as {reason: string}).reason, /binary/u); + writeFileSync(join(root, 'utf16.txt'), Buffer.from([0xff, 0xfe, 0x61, 0x00])); + assert.match((await loadFileDocument(join(root, 'utf16.txt'), '/') as {reason: string}).reason, /UTF-16/u); + + if (spawnSync('mkfifo', [join(root, 'pipe')]).status === 0) { + // Opening a FIFO must not wait for a writer: it is refused at once. + const started = Date.now(); + assert.match((await loadFileDocument(join(root, 'pipe'), '/') as {reason: string}).reason, /not a regular file/u); + assert.ok(Date.now() - started < 2000); + } + if (process.getuid?.() !== 0) { + writeFileSync(join(root, 'secret.txt'), 'x'); + chmodSync(join(root, 'secret.txt'), 0o000); + assert.match((await loadFileDocument(join(root, 'secret.txt'), '/') as {reason: string}).reason, /permission denied/u); + chmodSync(join(root, 'secret.txt'), 0o600); + } + + writeFileSync(join(root, 'bad.txt'), Buffer.from([0x61, 0xff, 0x62, 0x0a])); + const bad = await load(join(root, 'bad.txt')); + assert.deepEqual(bad.lines, ['a�b']); + assert.match(bad.notes.join(' '), /Not valid UTF-8/u); + + // A cut never splits a UTF-8 sequence, and says what it left out. + writeFileSync(join(root, 'big.txt'), 'é'.repeat(100)); + const cut = await load(join(root, 'big.txt'), {maxBytes: 51}); + assert.equal(cut.text, 'é'.repeat(25)); + assert.equal(cut.complete, false); + assert.match(cut.notes[0]!, /Showing the first/u); + writeFileSync(join(root, 'many.txt'), Array.from({length: 50}, (_, index) => `line ${index}`).join('\n')); + const many = await load(join(root, 'many.txt'), {maxLines: 10}); + assert.equal(many.lines.length, 10); + assert.equal(many.complete, false); + + symlinkSync(join(root, 'a.ts'), join(root, 'link.ts')); + const linked = await load(join(root, 'link.ts')); + assert.ok(linked.origin.kind === 'file' && linked.origin.symlink && linked.origin.realPath === join(root, 'a.ts')); + assert.match(shown(draw(createViewer(linked), 400)).join('\n'), new RegExp(`→ ${join(root, 'a.ts').replace(/[.*+?^${}()|[\]\\/]/gu, '\\$&')}`, 'u')); + + // A file name is untrusted text too: its controls never reach the transcript or the header as controls. + const hostileName = join(root, 'evil\u001b[2J\u202e.txt'); + const missing = await loadFileDocument(hostileName, '/') as {reason: string}; + assert.doesNotMatch(missing.reason, /[\u001b\u202e]/u); + assert.match(missing.reason, /evil␛\[2J·\.txt does not exist/u); + writeFileSync(hostileName, 'x\n'); + assert.doesNotMatch(draw(createViewer(await load(hostileName))).join('').replace(/\u001b\[[0-9;]*m/gu, ''), /[\u001b\u202e]/u); + + // Reading changes nothing. + assert.equal(readFileSync(join(root, 'a.ts'), 'utf8'), 'const a = 1;\r\nconst b = 2;\r\n'); + } finally { rmSync(root, {recursive: true, force: true}); } +}); + +test('hostile text is drawn inert at every width: no terminal controls, no bidi tricks, nothing wider than the panel', () => { + const hostile = ['\u001b]8;;https://evil.example\u0007click\u001b]8;;\u0007 \u001b[2J\u001b[31mred', 'admin‮gnp.exe', 'zero​width\ttab', '中文字符 and emoji 🎉', 'x'.repeat(500)].join('\n'); + const document = outputDocument({command: 'cat hostile', output: hostile}, 1); + for (const columns of [32, 80, 120]) { + const rows = draw(createViewer(document), columns, 24); + for (const row of rows) { + const visible = stripAnsi(row); + assert.doesNotMatch(row.replace(/\u001b\[[0-9;]*m/gu, ''), /\u001b/u, 'only NMSh SGR styling remains'); + assert.doesNotMatch(visible, /[‮​]/u, 'format characters are shown as marks'); + assert.ok(displayWidth(visible) <= columns, `row fits ${columns} columns`); + } + const joined = shown(rows).join('\n'); + assert.match(joined, /␛\]8;;https:\/\/evil/u, 'escape sequences are visible text, never links or colors'); + assert.match(joined, /admin·gnp\.exe/u); + } + assert.equal(document.format, 'text', 'command output is never implicitly Markdown'); +}); + +test('Markdown files render, switch to source with m, and output whose command is a diff reads as a diff', async () => { + const root = scratch(); + try { + writeFileSync(join(root, 'README.md'), '# Title\n\nSome **bold** text and `code`.\n\n- one\n- two\n'); + const document = await load(join(root, 'README.md')); + const viewer = createViewer(document); + assert.equal(viewer.rendered, true); + const rendered = shown(draw(viewer)).join('\n'); + assert.match(rendered, /Title/u); + assert.doesNotMatch(rendered, /\*\*bold\*\*/u, 'rendered, not source'); + press(viewer, ['m']); + assert.match(shown(draw(viewer)).join('\n'), /\*\*bold\*\*/u, 'source on m'); + const diff = outputDocument({command: 'git diff', output: 'diff --git a/x b/x\n@@ -1 +1 @@\n-old\n+new\n'}, 1); + assert.equal(diff.format, 'diff'); + assert.equal(outputDocument({command: 'cat notes.md', output: '# not markdown here\n'}, 1).format, 'text'); + } finally { rmSync(root, {recursive: true, force: true}); } +}); + +test('search finds, counts and steps through matches, wrapping with a note; Esc clears it before closing', () => { + const document = outputDocument({command: 'log', output: Array.from({length: 300}, (_, index) => index % 50 === 7 ? `ERROR at ${index}` : `ok ${index}`).join('\n')}, 1); + const viewer = createViewer(document); + draw(viewer); + press(viewer, ['/', ...'error'.split(''), {kind: 'enter'}]); + assert.equal(viewer.matchCount, 6, 'smart case: a lowercase query ignores case'); + assert.equal(viewer.match?.unit, 7); + draw(viewer); + press(viewer, ['n']); + assert.equal(viewer.match?.unit, 57); + press(viewer, ['N', 'N']); + assert.equal(viewer.match?.unit, 257, 'wrapped to the bottom'); + assert.match(viewer.note ?? '', /wrapped/u); + const rows = shown(draw(viewer)).join('\n'); + assert.match(rows, /6 matches for “error”/u); + assert.equal(press(viewer, [{kind: 'escape'}]), undefined, 'first Esc clears the search'); + assert.equal(viewer.query, undefined); + assert.deepEqual(press(viewer, [{kind: 'escape'}]), {kind: 'close'}); +}); + +test('navigation: page, end, go to line, wrap off with horizontal scroll, line numbers, mouse wheel, copy all', () => { + const lines = Array.from({length: 1000}, (_, index) => `${index + 1}: ${'word '.repeat(30)}`); + const viewer = createViewer(outputDocument({command: 'seq', output: lines.join('\n')}, 3)); + draw(viewer); + press(viewer, [{kind: 'pageDown'}]); + assert.ok(viewer.top > 0); + press(viewer, ['G']); + draw(viewer); + assert.equal(viewer.shown?.last, 999, 'End shows the last line at the bottom'); + press(viewer, [':', '5', '0', '0', {kind: 'enter'}]); + assert.equal(viewer.top, 499); + assert.match(shown(draw(viewer)).join('\n'), /^\s+500 500: word/mu, 'numbered from the source line'); + press(viewer, ['w', {kind: 'right'}, {kind: 'right'}]); + assert.equal(viewer.wrap, false); + assert.equal(viewer.left, 16); + assert.match(shown(draw(viewer)).join('\n'), /‹/u, 'more to the left is marked'); + press(viewer, [{kind: 'wheelDown'}]); + assert.equal(viewer.top, 502); + press(viewer, ['l']); + assert.equal(viewer.numbers, false); + assert.deepEqual(press(viewer, ['c']), {kind: 'copy', text: lines.join('\n'), lines: 1000}); + assert.match(shown(draw(viewer)).join('\n'), /output 3 · 1,000 lines/u); +}); + +test('bounded: a 200,000-line document opens and jumps to its end quickly, and one huge line wraps to a summary', () => { + const big = createViewer(outputDocument({command: 'big', output: Array.from({length: 200_000}, (_, index) => `row ${index}`).join('\n')}, 1)); + const started = performance.now(); + draw(big, 120, 40); + press(big, ['G'], 120, 40); + draw(big, 120, 40); + assert.equal(big.shown?.last, 199_999); + assert.ok(performance.now() - started < 1500, `took ${Math.round(performance.now() - started)}ms`); + const wide = createViewer(outputDocument({command: 'one line', output: 'y'.repeat(1_000_000)}, 1)); + const rows = shown(draw(wide, 80, 400)); + assert.ok(rows.some(row => /more characters on this line/u.test(row))); + assert.ok(rows.length <= 400); +}); + +test('narrow and tiny panels still fit and keep the close control reachable', () => { + const viewer = createViewer(outputDocument({command: 'x', output: 'a\nb\nc'}, 1)); + for (const [columns, height] of [[32, 10], [20, 6], [80, 3]] as const) { + const rows = draw(viewer, columns, height); + assert.ok(rows.length <= height, `${columns}x${height}: ${rows.length} rows`); + for (const row of rows) assert.ok(displayWidth(stripAnsi(row)) <= columns); + } + assert.deepEqual(press(viewer, ['q']), {kind: 'close'}); +}); diff --git a/tests/viewerApp.test.ts b/tests/viewerApp.test.ts new file mode 100644 index 00000000..c03d0c87 --- /dev/null +++ b/tests/viewerApp.test.ts @@ -0,0 +1,77 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtempSync, realpathSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {isolateConfig} from './support/isolatedConfig.js'; +import {TerminalApp} from '../src/app/TerminalApp.js'; + +/** A completed command block, as the shell would have produced it. */ +function completed(app: TerminalApp, command: string, output: string, exitCode = 0): number { + const buffer = app['output']; + const startId = buffer.beginCommand(command, [`$ ${command}`]); + buffer.write(output); + buffer.complete(exitCode); + return startId; +} + +async function until(check: () => boolean, what: string): Promise { + const deadline = Date.now() + 10_000; + while (!check()) { + if (Date.now() > deadline) throw new Error(`timed out waiting for ${what}`); + await new Promise(resolve => setTimeout(resolve, 20)); + } +} + +const transcriptText = (app: TerminalApp) => (app['output'].transcript().lines as Array>) + .map(line => line.map(cell => cell?.text ?? ' ').join('')).join('\n'); + +test('/view and View output open the native viewer on stored output; it owns keys until Esc and never writes to the transcript', async () => { + const isolation = isolateConfig(); + const app = new TerminalApp(); + app['render'] = () => {}; + try { + completed(app, 'printf a', 'first\r\n'); + const startId = completed(app, 'printf b', 'second\tline\r\nthird\r\n', 2); + const before = JSON.stringify(app['output'].transcript()); + app['openViewer']('', '/view'); + const viewer = app['viewer']!; + assert.equal(viewer.document.title, 'printf b'); + assert.deepEqual(viewer.document.lines, ['second\tline', 'third'], 'the text /copy copies, tabs as written'); + assert.ok(viewer.document.origin.kind === 'output' && viewer.document.origin.exitCode === 2 && viewer.document.origin.number === 1); + assert.equal(app['hasModalInput'](), true); + app['handleKey']({kind: 'text', value: 'x'}); + assert.equal(app['editor'].text, '', 'keys belong to the viewer, not the composer'); + app['handleKey']({kind: 'escape'}); + assert.equal(app['viewer'], undefined); + app['openViewer']('2', '/view 2'); + assert.equal(app['viewer']?.document.title, 'printf a'); + app['handleKey']({kind: 'escape'}); + await app['runBlockAction'](startId, 'view'); + assert.equal(app['viewer']?.document.title, 'printf b'); + app['handleKey']({kind: 'escape'}); + assert.equal(JSON.stringify(app['output'].transcript()), before, 'viewing writes nothing to the transcript'); + app['openViewer']('9', '/view 9'); + assert.equal(app['viewer'], undefined); + assert.match(transcriptText(app), /No completed command output at 9\./u); + } finally { app['stop'](0); app['session'].kill(); isolation.restore(); } +}); + +test('/view reads relative to the shell directory; a missing file is reported in the transcript', async () => { + const isolation = isolateConfig(); + const root = realpathSync(mkdtempSync(join(tmpdir(), 'nmsh-viewapp-'))); + const app = new TerminalApp(); + app['render'] = () => {}; + try { + app['shellCwd'] = root; + writeFileSync(join(root, 'notes.md'), '# Notes\n\nhello\n'); + app['openViewer']('notes.md', '/view notes.md'); + await until(() => app['viewer'] !== undefined, 'the viewer'); + assert.equal(app['viewer']!.document.format, 'markdown'); + assert.equal(app['viewer']!.rendered, true); + app['handleKey']({kind: 'escape'}); + app['openViewer']('missing.txt', '/view missing.txt'); + await until(() => /does not exist/u.test(transcriptText(app)), 'the missing-file notice'); + assert.equal(app['viewer'], 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 new file mode 100644 index 00000000..79f3e0e4 --- /dev/null +++ b/tests/viewerLive.test.ts @@ -0,0 +1,58 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync} from 'node:fs'; +import {join} from 'node:path'; +import {LiveSandbox, until} from './helpers/liveFrontend.js'; + +/** /view through the real composer and PTY: keys decoded from bytes, the real screen, a stand-in clipboard. */ +const supported = process.platform === 'darwin' || process.platform === 'linux'; + +test('live: /view reads a file and the latest output; find, go to line, copy all and Esc back to the composer', {skip: supported ? false : 'unsupported platform', timeout: 120_000}, async () => { + const sandbox = new LiveSandbox({provider: 'none', shellBackend: 'zsh'}); + const bin = join(sandbox.root, 'fake-bin'); + mkdirSync(bin); + const clip = join(sandbox.root, 'clipboard.txt'); + for (const name of ['pbcopy', 'xclip', 'wl-copy']) { + writeFileSync(join(bin, name), `#!/bin/sh\ncat > ${JSON.stringify(clip)}\n`); + chmodSync(join(bin, name), 0o755); + } + const frontend = sandbox.launch([], {cols: 100, rows: 30}, {PATH: `${bin}:${process.env.PATH}`, ...(process.platform === 'linux' ? {DISPLAY: ':nmsh-test'} : {})}); + try { + await frontend.waitFor(/Vespyr|notMyShell|zsh/u); + await frontend.run('echo READY', /READY/u); + writeFileSync(join(sandbox.home, 'notes.md'), '# Field notes\n\nThe **quick** fox.\n\n- alpha\n- beta\n'); + let mark = frontend.mark; + frontend.pty.write('/view notes.md\r'); + await frontend.waitFor(/notes\.md[\s\S]*Markdown · 6 lines[\s\S]*Field notes[\s\S]*quick fox[\s\S]*Esc close/u, mark); + mark = frontend.mark; + frontend.pty.write('m'); + await frontend.waitFor(/\*\*quick\*\*/u, mark); + // Esc closes; the composer is drawn again before anything is typed (a lone Esc followed at once by text is ambiguous). + mark = frontend.mark; + frontend.pty.write('\u001b'); + await frontend.waitFor(/❯/u, mark); + + await frontend.run('seq 1 400', /399[\s\S]*400/u); + mark = frontend.mark; + frontend.pty.write('/view\r'); + await frontend.waitFor(/\$ seq 1 400[\s\S]*output 1 · exit 0 · 400 lines[\s\S]*Lines 1–/u, mark); + mark = frontend.mark; + frontend.pty.write('/37\r'); + await frontend.waitFor(/match(?:es)? for “37”/u, mark); + mark = frontend.mark; + frontend.pty.write(':390\r'); + await frontend.waitFor(/Lines 3\d\d–400 of 400/u, mark); + frontend.pty.write('c'); + await until(() => existsSync(clip) && readFileSync(clip, 'utf8').length > 0, 15_000, 'the document on the clipboard'); + assert.equal(readFileSync(clip, 'utf8'), Array.from({length: 400}, (_, index) => String(index + 1)).join('\n') + '\n'); + // The first Esc clears the search, the second closes. + mark = frontend.mark; + frontend.pty.write('\u001b'); + await frontend.waitFor(/of 400 +↑↓ scroll/u, mark); + mark = frontend.mark; + frontend.pty.write('\u001b'); + await frontend.waitFor(/❯/u, mark); + // Back at the composer: a command runs as usual. + await frontend.run('echo AFTER-VIEW', /AFTER-VIEW/u); + } finally { await sandbox.dispose(); } +});