From b20d81c457b4a7680706b97e87c9d6dd16bce549 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:51:07 +0530 Subject: [PATCH 1/3] Native /view: a read-only viewer for files and completed output (#397) /view reads a regular file relative to the shell's directory, /view N completed output N (numbered as /copy), /view alone the latest output; block Actions gain View output. Markdown files render (m shows the source), source files get syntax roles and line numbers, diffs read as diffs. Find, go to line, wrap or horizontal scroll, copy all. Files are opened non-blocking and only regular files are read: at most 8 MiB and 200,000 lines, binary and UTF-16 refused, invalid UTF-8 shown with a note. Drawing is bounded to what is visible, and every control or format character is drawn as a visible mark. Viewing never writes to the file or transcript. --- src/app/TerminalApp.ts | 64 ++++- src/ask/concepts.ts | 5 +- src/commands/slashCommands.ts | 5 + src/markdown/highlight.ts | 4 +- src/markdown/render.ts | 7 + src/ui/BlockActions.ts | 3 +- src/viewer/ViewerPanel.ts | 502 ++++++++++++++++++++++++++++++++++ src/viewer/document.ts | 180 ++++++++++++ tests/viewer.test.ts | 197 +++++++++++++ tests/viewerApp.test.ts | 77 ++++++ tests/viewerLive.test.ts | 58 ++++ 11 files changed, 1097 insertions(+), 5 deletions(-) create mode 100644 src/viewer/ViewerPanel.ts create mode 100644 src/viewer/document.ts create mode 100644 tests/viewer.test.ts create mode 100644 tests/viewerApp.test.ts create mode 100644 tests/viewerLive.test.ts diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 07f2d810..fafe132e 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,48 @@ 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; } + 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 +4962,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 +5004,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 +5933,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..652cd1ea --- /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, 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 ? ` · → ${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..fdfc4fcf --- /dev/null +++ b/src/viewer/document.ts @@ -0,0 +1,180 @@ +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); +} + +/** The path as people read it: under home it starts with ~. */ +export function displayPath(path: string, home = homedir()): string { + return 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..7266934b --- /dev/null +++ b/tests/viewer.test.ts @@ -0,0 +1,197 @@ +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')); + + // 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(); } +}); From d51593bd9cde641a4ac698fb729474db145a02fa Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:51:19 +0530 Subject: [PATCH 2/3] /view slice: changelog, physical QA checklist and ledger --- CHANGELOG.md | 5 +++++ docs/development/v021-ledger.md | 5 +++-- docs/testing/v021-physical-qa.md | 21 +++++++++++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) 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). From 5ebdd29b5ec549a13055d181c71a855cf3495395 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:52:15 +0530 Subject: [PATCH 3/3] /view: file names are drawn inert; a late load never opens over another panel --- src/app/TerminalApp.ts | 2 ++ src/viewer/ViewerPanel.ts | 4 ++-- src/viewer/document.ts | 9 +++++++-- tests/viewer.test.ts | 8 ++++++++ 4 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index fafe132e..c04748b5 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -3595,6 +3595,8 @@ export class TerminalApp { 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(); diff --git a/src/viewer/ViewerPanel.ts b/src/viewer/ViewerPanel.ts index 652cd1ea..01d3c5de 100644 --- a/src/viewer/ViewerPanel.ts +++ b/src/viewer/ViewerPanel.ts @@ -9,7 +9,7 @@ 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, type ViewDocument} from './document.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 @@ -413,7 +413,7 @@ function detail(document: ViewDocument): string { } 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 ? ` · → ${document.origin.realPath}` : ''; + const link = document.origin.symlink ? ` · → ${inert(document.origin.realPath)}` : ''; return `${kind} · ${lines} · ${formatBytes(document.origin.bytes)}${ending}${link}`; } diff --git a/src/viewer/document.ts b/src/viewer/document.ts index fdfc4fcf..399cc359 100644 --- a/src/viewer/document.ts +++ b/src/viewer/document.ts @@ -50,9 +50,14 @@ export function resolveViewPath(input: string, cwd: string, home = homedir()): s return isAbsolute(trimmed) ? trimmed : resolve(cwd, trimmed); } -/** The path as people read it: under home it starts with ~. */ +/** 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 home && (path === home || path.startsWith(`${home}/`)) ? `~${path.slice(home.length)}` : path; + 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', diff --git a/tests/viewer.test.ts b/tests/viewer.test.ts index 7266934b..903d49cf 100644 --- a/tests/viewer.test.ts +++ b/tests/viewer.test.ts @@ -87,6 +87,14 @@ test('files: only regular, decodable text is read, within limits, and every refu 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}); }