diff --git a/CHANGELOG.md b/CHANGELOG.md index 6eb8006e..f6c37b43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- **Split view for Markdown and HTML documents** ([#461](https://github.com/raiseCatError/notMyShell/issues/461)). **F2** (or `s` in the read-only views) in the document workspace shows the editor beside NMSh's own preview of the *same* buffer: the text on the left, the rendered document on the right, updated as you type, without saving. The preview follows the caret to its block (`l` in the preview pane turns that off), **Ctrl+O** moves between the panes, `x` swaps the sides and `<` `>` change their widths (30–70%). Only the text pane is editable; keys typed while the preview has the focus never reach the text. The preview pane has its own find (`/`) and heading prompt (`#`). Below 100 columns only the focused pane is shown at full width, and widening the terminal brings the split back. Esc leaves for the read-only source view with your unsaved text kept; F2 again returns to editing. The browser-quality preview is not used in the split; the pane is NMSh's renderer. Mouse input in the split is not handled yet. - **Heading navigation** ([#462](https://github.com/raiseCatError/notMyShell/issues/462), first slice). In a Markdown or HTML document, **`#`** in the viewer or workspace opens a heading prompt: type to narrow, Up/Down choose, Enter jumps (source or rendered view, any width) and **Backspace** goes back to where you were. In the editor, F1 then `h` (or *Go to heading…* in the snippet palette) lists the headings with their depth; `j` / *Back* returns to the caret position you left, even after edits. `/view README.md#install` opens the file at that heading. Anchors follow GitHub's rules (lower-case, punctuation dropped, `-1`, `-2` for repeats; raw HTML headings listed without an anchor); Jekyll/Kramdown ids are different and are not implemented (see the [architecture note](docs/architecture/document-source-map.md)). Underneath, every parsed Markdown block now knows its source lines and every rendered row its block's line, the shared map the coming split view and hybrid editing build on. - **`/playground`: an unsaved Markdown or HTML document** ([#468](https://github.com/raiseCatError/notMyShell/issues/468), second slice). `/playground md`, `/playground html`, or `/playground` to choose, opens an editable scratch document in the document workspace at once — type, switch between source, edit and preview with the usual keys, and use the snippets. Nothing is saved for you: **Ctrl+S asks for a file name** (Save As creates a new file with the same checked save as `/edit` and never replaces an existing one), and closing with unsaved text asks Save As, Discard or Keep editing. If NMSh or the terminal dies, the text is kept as a private draft and `/playground recover` brings it back. The preview is NMSh's own renderer: no browser process, no folder served, scripts and network stay off. Split view arrives with [#461](https://github.com/raiseCatError/notMyShell/issues/461). - **Snippets in `/edit`** ([#468](https://github.com/raiseCatError/notMyShell/issues/468), first slice). In a Markdown or HTML file, **F1** (Markdown: F1 then `/`) opens a searchable palette of built-in templates — headings, tables, task lists, fenced code, block quotes, footnotes, Mermaid and math blocks as source text (NMSh does not render them), an HTML page skeleton, semantic sections, lists, tables, links and images — together with the editing actions that already exist. Type to filter, Enter inserts. A snippet is plain text inserted as one undoable edit; its placeholders are selected one by one with **Tab / Shift+Tab** for as long as you are inside it, **Esc** or moving away ends that without touching the text, and Tab is an ordinary tab everywhere else. Block templates start on their own line, wrap a selection where it makes sense, and follow the line's indentation. Nothing is executed, fetched or installed. diff --git a/docs/design/document-workspace.md b/docs/design/document-workspace.md index 7fca848f..4c7de6d3 100644 --- a/docs/design/document-workspace.md +++ b/docs/design/document-workspace.md @@ -49,6 +49,15 @@ Limits: code fences are recognised by a line scan (indented code blocks and fenc **Verified by** `tests/markdownEdit.test.ts` (each helper: empty and multi-line selections, nested and mixed lists, quotes, fences, Unicode, very long lines, one undo step, restored drafts) and `tests/markdownEditorKeys.test.ts` (keys, menu at 30–120 columns, non-Markdown files unchanged, CRLF saved as CRLF). Physical Ghostty/Zed: NOT RUN. +## Split view + +`DocumentMode` gains `'split'` (#461). The session keeps the one `EditorState` and one `ViewerState`; the split adds a `SplitState` (focus, swapped, ratio, linked, a buffer version counter fed by `TextBuffer.observe`, the last caret line). The preview pane is the existing native renderer (`renderViewer` with `rendered` true), re-synced from the buffer only when the version changed. Keys: **F2** (the new `splitToggle` key) or `s` from the read-only views enters; **Ctrl+O** switches the focused pane (elsewhere it still opens the preview); a key goes to exactly one pane. The source pane is the editor unchanged (snippets, helpers, recovery, review and Save As all work in it; a dialog or prompt takes the whole panel). The preview pane takes the viewer's navigation, find and heading keys plus `x` swap, `<`/`>` width (30–70%), `l` link/unlink, `e` focus the text, `s` back to editing, Esc to the source view. + +- **Linked scrolling:** when the caret moves to another line the preview shows the first row of that line's block (`rowForLine`, the source map). Scrolling the preview by hand is respected until the caret moves again; `l` turns following off. +- **Layout:** at 100 columns and up the panes share the width minus a one-column divider (preview 30–70%, never under 40 columns); below 100 only the focused pane is drawn full width and Ctrl+O switches — nothing is crushed. Resizing re-lays out from the same state. +- **Not provided:** the Chromium preview in the split (it owns a page and a served folder per file; the native renderer is what the split shows), pointer input and independent pane focus by mouse, a vertical (stacked) orientation, synchronised scrolling from preview back to the caret, and a per-heading synchronisation beyond block granularity. +- **Verified by** `tests/documentSplit.test.ts` (layout, both panes of one buffer, edits and undo reaching the preview, linked scrolling, focus and key ownership, swap and resize bounds, narrow collapse and restore, Esc/F2 transitions, dirty and save from the split, preview find and heading prompts, NO_COLOR, hostile and 40,000-line documents, and the real app). + ## Playground `/playground [md|html|recover]` ([#468](https://github.com/raiseCatError/notMyShell/issues/468), slice 2) opens a **scratch document**: an `EditableFile` with `scratch: {id}`, `path` `Untitled.md` / `Untitled.html` (a display name that gives the format), `realPath` `scratch:<16 hex>` and no disk identity. The `scratch:` identity is what the recovery drafts of #469 are keyed by; it is never a path, and nothing reads or writes it as one. diff --git a/docs/development/v024-ledger.md b/docs/development/v024-ledger.md index de0c4955..93c2cdde 100644 --- a/docs/development/v024-ledger.md +++ b/docs/development/v024-ledger.md @@ -39,6 +39,8 @@ The paused issues #460–#469 are the milestone (the maintainer superseded the e 6. **#462 slice 1: source-position map and heading navigation** — branch `feature/v024-outline` (stacked on the Playground slice). Done: spans on every block, `MarkdownRow.line`, `DocumentIndex`, `SourceAnchor`, GitHub slugs, viewer `#` prompt + Backspace, editor *Go to heading…*/*Back*, `/view file#heading`, architecture note `docs/architecture/document-source-map.md`. **#462 remaining:** `
/` disclosure, Mermaid, math (distinct later slices), a persistent outline rail (not started: the prompt/palette fit the existing layout without taking width), following `#fragment` links inside a rendered document (no link focus exists yet). **Next in order:** #461 (Source/Preview/Split, using `rowForLine`/`lineForRow`), then #460, then #466. **#463** still lacks footnote renumbering. +7. **#461: split view** — branch `feature/v024-split` (stacked on #462 slice 1). Done: `split` mode with editor + native preview of one buffer, linked scrolling, focus/ownership, swap/ratio, narrow collapse, F2. **#461 remaining:** pointer input and hit testing, vertical orientation, the Chromium preview inside the split, preview→caret sync; physical QA. **Next:** #460 (hybrid Markdown editing) builds on `blockAt`/`rowForLine`; #466 diagnostics uses the heading index and block spans; #462's details/Mermaid/math slices; #463 footnote renumbering. + ## Environment notes Scratch and test runs: `t7run`; focused tests via `/Volumes/T7Tmp/ai-dev/runtests.mjs` (see the testing-gotchas memory). `npm ci` was needed in a fresh worktree (the main checkout's `node_modules` is stale). diff --git a/docs/testing/v024-editor-physical-qa.md b/docs/testing/v024-editor-physical-qa.md index 295b4a0c..09fbe545 100644 --- a/docs/testing/v024-editor-physical-qa.md +++ b/docs/testing/v024-editor-physical-qa.md @@ -21,6 +21,12 @@ Automated tests cover the logic headlessly. Everything below is **NOT RUN** unti - [ ] Mermaid and math entries say they are source text; nothing in the preview claims to render them. - [ ] The palette rows and footer fit 30, 50, 80 and 120 columns; NO_COLOR and Safe Glyphs stay legible. +## Split view (#461) +- [ ] F2 is delivered by the terminal and by Zed's terminal (otherwise use `s` from the read-only view). The split shows the text and the rendered document side by side; typing updates the preview; the preview follows the caret; `l` unlinks. +- [ ] Ctrl+O switches panes; letters typed in the preview pane never change the text; `x`, `<`, `>` behave; below 100 columns one pane at a time; widen the window and the split returns. +- [ ] Esc keeps unsaved text; Ctrl+S saves (or asks for a name in a Playground); resize while typing does not corrupt the screen. +- [ ] 30, 50, 80, 100 and 120+ columns; NO_COLOR; Safe Glyphs (divider is `|`). + ## Heading navigation (#462) - [ ] In a long Markdown file `#` opens the heading prompt (is `#` delivered by your terminal and Zed?); type, Up/Down, Enter jumps in both the source and the rendered view; Backspace returns. - [ ] `/view README.md#some-heading` opens at the heading. In `/edit`, F1 then `h` lists headings with indentation; `j` returns after editing above the starting point. diff --git a/src/terminal/keys.ts b/src/terminal/keys.ts index 1a21fdc6..6af8573e 100644 --- a/src/terminal/keys.ts +++ b/src/terminal/keys.ts @@ -6,7 +6,7 @@ export type Key = | {kind: 'left' | 'right' | 'up' | 'down' | 'lineHome' | 'lineEnd' | 'backspace' | 'delete' | 'enter' | 'newline' | 'complete' | 'escape' | 'selectAll'} | {kind: 'selectLeft' | 'selectRight' | 'selectUp' | 'selectDown' | 'selectLineHome' | 'selectLineEnd'} | {kind: 'bufferHome' | 'bufferEnd' | 'selectBufferHome' | 'selectBufferEnd'} - | {kind: 'historySearch' | 'historyDelete' | 'find' | 'previousFailure' | 'nextFailure'} | {kind: 'suggestNext' | 'suggestPrevious' | 'palette'} | {kind: 'pageUp' | 'pageDown' | 'latest' | 'interrupt' | 'suspend' | 'eof' | 'wheelUp' | 'wheelDown' | 'mouseMove' | 'mouseClick' | 'mouseDrag' | 'mouseRelease' | 'focusPrevious' | 'focusNext' | 'toggleDetails' + | {kind: 'historySearch' | 'historyDelete' | 'find' | 'previousFailure' | 'nextFailure'} | {kind: 'suggestNext' | 'suggestPrevious' | 'palette'} | {kind: 'pageUp' | 'pageDown' | 'latest' | 'interrupt' | 'suspend' | 'eof' | 'wheelUp' | 'wheelDown' | 'mouseMove' | 'mouseClick' | 'mouseDrag' | 'mouseRelease' | 'focusPrevious' | 'focusNext' | 'toggleDetails' | 'splitToggle' /** Ctrl+Q: add the composer's command to the queue; Ctrl+S: send it to the running program's input. */ | 'queueCommand' | 'sendToProgram' /** Ctrl+T: open the Arcade. NMSh's own key: it is never written to a program, so it is safe while a command runs. */ @@ -104,6 +104,8 @@ const SEQUENCES: Array<[string, Key['kind']]> = [ // Command palette: Ctrl+Shift+P and Cmd+Shift+P need CSI-u reporting (plain Ctrl+Shift+P is Ctrl+P); F1 is the legacy fallback. ['\u001B[112;6u', 'palette'], ['\u001B[80;6u', 'palette'], ['\u001B[112;10u', 'palette'], ['\u001B[80;10u', 'palette'], ['\u001BOP', 'palette'], ['\u001B[11~', 'palette'], + // F2 opens or closes the document workspace's split view. + ['\u001BOQ', 'splitToggle'], ['\u001B[12~', 'splitToggle'], ['\u001B[Q', 'splitToggle'], // Kitty keyboard protocol functional-key form (`CSI 1 P`, the 1 omitted when unmodified); NMSh enables that protocol where supported. ['\u001B[P', 'palette'], ['\u001B[110;5u', 'suggestNext'], // Kitty Ctrl+N diff --git a/src/viewer/DocumentWorkspace.ts b/src/viewer/DocumentWorkspace.ts index ee3a62a6..e68f5faa 100644 --- a/src/viewer/DocumentWorkspace.ts +++ b/src/viewer/DocumentWorkspace.ts @@ -3,7 +3,10 @@ import type {TextOverlay} from '../arcade/Canvas.js'; import {createEditor, editorKey, renderEditor, type EditorAction, type EditorState} from './EditorPanel.js'; import {formatForPath, type ViewDocument} from './document.js'; import type {EditableFile} from './fileEdit.js'; -import {createViewer, renderViewer, viewerKey, type ViewerAction, type ViewerLook, type ViewerState} from './ViewerPanel.js'; +import {displayWidth, stripAnsi, truncateAnsi} from '../util/text.js'; +import {foreground, UI_COLORS} from '../ui/palette.js'; +import {getIconStyle} from '../ui/glyphs.js'; +import {createViewer, renderViewer, showSourceLine, viewerKey, type ViewerAction, type ViewerLook, type ViewerState} from './ViewerPanel.js'; /** * The document workspace for Markdown and HTML files: one document, three ways to look at it, switched with a key and never @@ -20,7 +23,39 @@ import {createViewer, renderViewer, viewerKey, type ViewerAction, type ViewerLoo * and line endings kept). In navigation modes `h`, `e`, `r` are commands; while typing they are text, and Ctrl+O is the * switch that works there. */ -export type DocumentMode = 'source' | 'edit' | 'preview'; +export type DocumentMode = 'source' | 'edit' | 'preview' | 'split'; + +/** + * The split view (#461): the editor, the only editable pane, beside NMSh's own preview of the same buffer. F2 opens and closes + * it; Ctrl+O moves between the panes; in the preview pane `x` swaps the sides, `<` `>` change the widths and `l` links or + * unlinks the preview's scrolling to the caret. Below SPLIT_MIN_COLUMNS only the focused pane is shown, full width. + */ +export interface SplitState { + focus: 'source' | 'preview'; + /** The preview is on the left. */ + swapped: boolean; + /** The preview's share of the width, in percent. */ + ratio: number; + /** The preview follows the caret when it moves to another line. */ + linked: boolean; + /** Bumped by every change to the buffer, so the preview is rebuilt only when the text changed. */ + version: number; + seen: number; + caretLine: number; + stop(): void; +} + +export const SPLIT_MIN_COLUMNS = 100; +const RATIO_MIN = 30, RATIO_MAX = 70, RATIO_STEP = 5; + +/** The pane widths for a terminal of `columns` (one column is the divider); `narrow` shows one pane only. */ +export function splitLayout(columns: number, split: Pick): {narrow: boolean; source: number; preview: number} { + if (columns < SPLIT_MIN_COLUMNS) return {narrow: true, source: columns, preview: columns}; + const usable = columns - 1; + const ratio = Math.max(RATIO_MIN, Math.min(RATIO_MAX, split.ratio)); + const preview = Math.max(40, Math.min(usable - 40, Math.round(usable * ratio / 100))); + return {narrow: false, source: usable - preview, preview}; +} /** The browser-backed preview as the workspace sees it (see browser/ for the engine behind it). */ export interface PreviewView { @@ -51,6 +86,8 @@ export interface DocumentSession { before: DocumentMode; /** Scroll positions per read-only mode, so switching back finds the place again. */ tops: {source: {top: number; sub: number}; preview: {top: number; sub: number}}; + /** Present only while the mode is 'split'. */ + split?: SplitState; note?: string; /** Sized-text blocks of the last render, positioned in the returned panel rows. */ overlays: TextOverlay[]; @@ -90,6 +127,7 @@ function documentOf(editor: EditorState): ViewDocument { export function createDocumentSession(file: EditableFile, mode: DocumentMode = 'source'): DocumentSession { const editor = createEditor(file); + editor.workspace = true; const viewer = createViewer(documentOf(editor)); viewer.rendered = false; viewer.numbers = true; @@ -112,8 +150,19 @@ export function syncDocumentViewer(state: DocumentSession, rendered: boolean): v export function setDocumentMode(state: DocumentSession, mode: DocumentMode): void { if (mode === state.mode) return; const viewer = state.viewer; - if (state.mode !== 'edit') state.tops[state.mode] = {top: viewer.top, sub: viewer.sub}; - if (mode === 'preview') state.before = state.mode; + if (state.mode === 'source' || state.mode === 'preview') state.tops[state.mode] = {top: viewer.top, sub: viewer.sub}; + if (state.mode === 'split') { state.split?.stop(); delete state.split; } + if (mode === 'preview') state.before = state.mode === 'split' ? 'edit' : state.mode; + if (mode === 'split') { + const split: SplitState = {focus: state.mode === 'edit' ? 'source' : 'preview', swapped: false, ratio: 50, linked: true, version: 0, seen: -1, caretLine: -1, stop: () => {}}; + split.stop = state.editor.buffer.observe(() => { split.version += 1; }); + state.split = split; + syncDocumentViewer(state, true); + state.viewer.rendered = true; + state.mode = 'split'; + state.note = undefined; + return; + } if (mode !== 'edit') { syncDocumentViewer(state, mode === 'preview'); viewer.top = state.tops[mode].top; @@ -131,6 +180,8 @@ export function documentSessionKey(state: DocumentSession, key: Key, columns: nu state.note = undefined; // A save or conflict dialog belongs to the editor, whatever mode raised it. if (editor.dialog || editor.review || editor.input || editor.busy) return fromEditor(state, editorKey(editor, key, columns, height)); + if (key.kind === 'splitToggle') { setDocumentMode(state, state.mode === 'split' ? 'edit' : 'split'); return undefined; } + if (state.mode === 'split') return splitKey(state, key, columns, height, look); if (key.kind === 'sendToProgram') return editor.file.scratch ? fromEditor(state, editorKey(editor, key, columns, height)) : {kind: 'save'}; if (state.mode === 'edit') { @@ -146,6 +197,7 @@ export function documentSessionKey(state: DocumentSession, key: Key, columns: nu const searching = Boolean(state.viewer.input) && state.mode === 'source'; if (!searching) { if (text === 'e') { setDocumentMode(state, 'edit'); return undefined; } + if (text === 's') { setDocumentMode(state, 'split'); return undefined; } if (key.kind === 'toggleDetails' && state.mode === 'preview') { setDocumentMode(state, state.before); return undefined; } if (text === 'h' || key.kind === 'toggleDetails') { if (state.mode === 'preview') { setDocumentMode(state, 'source'); return undefined; } @@ -180,6 +232,65 @@ export function documentSessionKey(state: DocumentSession, key: Key, columns: nu return undefined; } +function splitLook(state: DocumentSession, look: ViewerLook): ViewerLook { + const split = state.split!; + const {workspace: _workspace, sized: _sized, ...rest} = look; + return {...rest, split: {linked: split.linked, focused: split.focus === 'preview'}}; +} + +function splitKey(state: DocumentSession, key: Key, columns: number, height: number, look: ViewerLook): DocumentSessionAction | undefined { + const split = state.split!; + const editor = state.editor; + const layout = splitLayout(columns, split); + const text = key.kind === 'text' ? key.value : undefined; + if (key.kind === 'toggleDetails' && !editor.dialog) { split.focus = split.focus === 'source' ? 'preview' : 'source'; return undefined; } + if (split.focus === 'source') { + if (key.kind === 'escape' && !editor.input && !editor.snippet) { setDocumentMode(state, 'source'); return undefined; } + return fromEditor(state, editorKey(editor, key, layout.narrow ? columns : layout.source, height)); + } + const viewer = state.viewer; + if (!viewer.input) { + if (key.kind === 'escape' && !viewer.query) { setDocumentMode(state, 'source'); return undefined; } + if (text === 'x') { split.swapped = !split.swapped; return undefined; } + if (text === '<' || text === '>') { split.ratio = Math.max(RATIO_MIN, Math.min(RATIO_MAX, split.ratio + (text === '>' ? RATIO_STEP : -RATIO_STEP))); return undefined; } + if (text === 'l') { split.linked = !split.linked; return undefined; } + if (text === 'e') { split.focus = 'source'; return undefined; } + if (text === 's') { setDocumentMode(state, 'edit'); return undefined; } + } + // Scrolling the preview by hand is the person's choice of place: it is not undone by a caret that has not moved. + split.caretLine = editor.buffer.caret.line; + const action = viewerKey(viewer, key, layout.narrow ? columns : layout.preview, height, splitLook(state, look)); + return action?.kind === 'copy' ? {kind: 'copy', text: action.text} : undefined; +} + +function pad(row: string, width: number): string { + const cut = truncateAnsi(row, width); + return `${cut}${' '.repeat(Math.max(0, width - displayWidth(stripAnsi(cut))))}\u001b[0m`; +} + +function renderSplit(state: DocumentSession, columns: number, height: number, look: ViewerLook): string[] { + const split = state.split!; + const editor = state.editor; + const layout = splitLayout(columns, split); + const previewLook = splitLook(state, look); + const previewWidth = layout.narrow ? columns : layout.preview; + if (split.seen !== split.version) { syncDocumentViewer(state, true); state.viewer.rendered = true; split.seen = split.version; } + const line = editor.buffer.caret.line; + if (split.linked && line !== split.caretLine) showSourceLine(state.viewer, line, previewWidth, height, previewLook); + split.caretLine = line; + if (state.note) state.viewer.note = state.note; + if (layout.narrow) return split.focus === 'source' ? renderEditor(editor, columns, height) : renderViewer(state.viewer, columns, height, previewLook); + const source = renderEditor(editor, layout.source, height); + const preview = renderViewer(state.viewer, layout.preview, height, previewLook); + const [left, right] = split.swapped ? [preview, source] : [source, preview]; + const [leftWidth, rightWidth] = split.swapped ? [layout.preview, layout.source] : [layout.source, layout.preview]; + const safe = getIconStyle() === 'safe'; + const bar = `${foreground(UI_COLORS.separator)}${safe ? '|' : '│'}\u001b[0m`; + const joint = (top: boolean) => `${foreground(UI_COLORS.separator)}${safe ? '+' : top ? '┬' : '┴'}\u001b[0m`; + const count = Math.max(left.length, right.length); + return Array.from({length: count}, (_, index) => `${pad(left[index] ?? '', leftWidth)}${index === 0 ? joint(true) : index === count - 1 ? joint(false) : bar}${pad(right[index] ?? '', rightWidth)}`); +} + function fromEditor(state: DocumentSession, action: EditorAction | undefined): DocumentSessionAction | undefined { if (!action) return undefined; if (action.kind === 'close') return {kind: 'close'}; @@ -198,7 +309,8 @@ export function documentSessionLook(state: DocumentSession, look: ViewerLook = { export function renderDocumentSession(state: DocumentSession, columns: number, height: number, look: ViewerLook = {}): string[] { state.overlays = []; const editor = state.editor; - if (state.mode === 'edit' || editor.dialog || editor.review || editor.input) return renderEditor(editor, columns, height); + if (state.mode === 'split' && state.split && !(editor.dialog || editor.review || editor.input)) return renderSplit(state, columns, height, look); + if (state.mode === 'edit' || state.mode === 'split' || editor.dialog || editor.review || editor.input) return renderEditor(editor, columns, height); if (state.mode === 'preview' && state.preview) { const view = state.preview.render(columns, height); state.overlays = view.overlays ?? []; diff --git a/src/viewer/EditorPanel.ts b/src/viewer/EditorPanel.ts index 3346cfd8..4b1390e9 100644 --- a/src/viewer/EditorPanel.ts +++ b/src/viewer/EditorPanel.ts @@ -63,6 +63,8 @@ export interface EditorState { /** The parsed structure of the buffer (headings), kept until it changes; and where jumps came from, newest last (#462). */ index?: DocumentIndex; jumps?: SourceAnchor[]; + /** The editor sits in a document workspace: its footer names the split key. */ + workspace?: boolean; note?: {text: string; tone: 'info' | 'error' | 'success'}; /** A save or reload is in progress; keys wait. */ busy?: boolean; @@ -567,7 +569,8 @@ function footerRows(state: EditorState, columns: number): string[] { } if (state.input) return renderControlRows([['Enter', state.input.kind === 'saveas' ? 'save here' : 'find'], ['Esc', 'cancel']], width); return renderControlRows([['Ctrl+S', 'save'], ['Esc', 'close'], ['Ctrl+Z', 'undo'], ['Ctrl+Y', 'redo'], ['Ctrl+F', 'find'], ['Ctrl+C', 'copy'], ['Ctrl+X', 'cut'], - ...(isMarkdownFile(state) ? [['F1', 'Markdown']] as Array<[string, string]> : paletteFormat(state) ? [['F1', 'Snippets']] as Array<[string, string]> : [])], width); + ...(isMarkdownFile(state) ? [['F1', 'Markdown']] as Array<[string, string]> : paletteFormat(state) ? [['F1', 'Snippets']] as Array<[string, string]> : []), + ...(state.workspace ? [['F2', 'split']] as Array<[string, string]> : [])], width); } export function renderEditor(state: EditorState, columns: number, height: number): string[] { diff --git a/src/viewer/ViewerPanel.ts b/src/viewer/ViewerPanel.ts index f3aad58c..213488c0 100644 --- a/src/viewer/ViewerPanel.ts +++ b/src/viewer/ViewerPanel.ts @@ -78,6 +78,8 @@ export interface ViewerLook { sized?: boolean; /** The viewer is the read-only side of a document workspace (Markdown/HTML): its controls name the workspace's keys. */ workspace?: {mode: 'source' | 'preview'; dirty: boolean; browser: boolean; trusted: boolean}; + /** The viewer is the preview pane of the split view (#461): its controls name the split's keys. */ + split?: {linked: boolean; focused: boolean}; } export function createViewer(document: ViewDocument): ViewerState { @@ -398,6 +400,15 @@ function step(state: ViewerState, direction: 1 | -1, width: number, height: numb reveal(state, found.unit, width, height, look); } +/** Scroll to the first row of the block holding source line `line` (the split view's linked scrolling). */ +export function showSourceLine(state: ViewerState, line: number, columns: number, height: number, look: ViewerLook = {}): void { + const width = contentWidth(state, columns); + const body = bodyHeight(state, columns, height, look); + state.top = state.rendered ? rowForLine(markdownRows(state, width, look), line) : Math.min(line, Math.max(0, state.document.lines.length - 1)); + state.sub = 0; + clampTop(state, width, body, look); +} + // ---- Keys --------------------------------------------------------------------------------------------------------- export function viewerKey(state: ViewerState, key: Key, columns: number, height: number, look: ViewerLook = {}): ViewerAction | undefined { @@ -520,6 +531,7 @@ function headerRows(state: ViewerState, columns: number, height: number, look: V function controls(state: ViewerState, columns: number, look: ViewerLook): string[] { const width = Math.max(1, columns - 4); if (state.input?.kind === 'heading') return renderControlRows([['↑↓', 'choose'], ['Enter', 'go'], ['Esc', 'cancel']], width); + if (look.split && !state.input) return renderControlRows([['Ctrl+O', look.split.focused ? 'to the text' : 'here'], ['F2', 'close split'], ['↑↓', 'scroll'], ['/', 'find'], ['#', 'headings'], ['x', 'swap'], ['< >', 'width'], ['l', look.split.linked ? 'unlink' : 'link'], ['Esc', 'source']], width); if (state.input) return renderControlRows([['Enter', state.input.kind === 'search' ? 'find' : 'go'], ['Esc', 'cancel']], width); if (look.workspace) { const preview = look.workspace.mode === 'preview'; diff --git a/tests/documentSplit.test.ts b/tests/documentSplit.test.ts new file mode 100644 index 00000000..e03db039 --- /dev/null +++ b/tests/documentSplit.test.ts @@ -0,0 +1,222 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {createDocumentSession, documentSessionKey, renderDocumentSession, setDocumentMode, splitLayout, type DocumentSession} from '../src/viewer/DocumentWorkspace.js'; +import {openForEdit} from '../src/viewer/fileEdit.js'; +import {decodeKeys} from '../src/terminal/keys.js'; +import {displayWidth, stripAnsi} from '../src/util/text.js'; + +/** + * #461: Source / Preview / Split in one pane. The split is the editor (the only editable pane) beside NMSh's own preview of the + * SAME buffer: no second buffer, no second process, nothing written by looking. + */ +const body = (from: number, to: number) => Array.from({length: to - from}, (_, index) => `filler ${from + index}`); +const SOURCE = ['# Hello world', '', ...body(0, 30), '', '## Middle part', '', ...body(30, 60), '', '## End part', '', 'last words'].join('\n'); +async function session(content = `${SOURCE}\n`, mode: 'edit' | 'source' = 'edit') { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'nmsh-split-'))); + const path = join(root, 'doc.md'); writeFileSync(path, content); + const opened = await openForEdit(path, '/'); + if (!opened.ok) throw new Error(opened.reason); + return {root, path, state: createDocumentSession(opened.file, mode)}; +} +const press = (state: DocumentSession, bytes: string, columns = 120, height = 26) => { for (const key of decodeKeys(bytes)) documentSessionKey(state, key, columns, height); }; +const draw = (state: DocumentSession, columns = 120, height = 26) => renderDocumentSession(state, columns, height).map(row => stripAnsi(row)); +const F2 = '\u001bOQ'; + +test('layout: wide terminals show both panes, narrow ones the focused pane; the share of the preview is bounded', () => { + assert.deepEqual(splitLayout(120, {ratio: 50}), {narrow: false, source: 59, preview: 60}); + assert.equal(splitLayout(99, {ratio: 50}).narrow, true); + assert.equal(splitLayout(100, {ratio: 50}).narrow, false); + for (const ratio of [0, 10, 95, 100]) { const layout = splitLayout(160, {ratio}); assert.ok(layout.source >= 40 && layout.preview >= 40, `${ratio}: ${JSON.stringify(layout)}`); } +}); + +test('F2 from edit mode opens the split: the editor on the left, the rendered preview on the right, both of the same buffer', async () => { + const { root, state } = await session(); + try { + press(state, F2); + assert.equal(state.mode, 'split'); + assert.equal(state.split?.focus, 'source'); + const rows = draw(state); + assert.ok(rows.every(row => displayWidth(row) <= 120)); + const joined = rows.join('\n'); + assert.match(joined, /# Hello world/u, 'the source (with its # marker) on the left'); + assert.ok(rows.some(row => /│/u.test(row) && /# Hello world/u.test(row) && /Hello world/u.test(row.split('│').at(-1)!)), 'the rendered heading beside it'); + } finally { rmSync(root, {recursive: true, force: true}); } +}); + +test('typing in the source updates the preview of the same unsaved buffer; the file is not written', async () => { + const { root, path, state } = await session(); + try { + press(state, F2); + state.editor.buffer.caret = {line: 0, column: 8}; + press(state, 'NEW '); + const rows = draw(state); + assert.ok(rows.some(row => /Hello NEW world/u.test(row.split('│').at(-1)!)), rows.join('\n')); + assert.equal(state.editor.buffer.dirty, true); + assert.equal(readFileSync(path, 'utf8'), `${SOURCE}\n`); + press(state, '\u001a'); press(state, '\u001a'); + assert.ok(draw(state).some(row => /Hello world/u.test(row.split('│').at(-1)!) && !/NEW/u.test(row)), 'undo reaches the preview too'); + } finally { rmSync(root, {recursive: true, force: true}); } +}); + +test('linked scrolling: the preview follows the caret to its heading, and to the top when the caret returns', async () => { + const { root, state } = await session(); + try { + press(state, F2); + const lines = SOURCE.split('\n'); + state.editor.buffer.caret = {line: lines.indexOf('## End part'), column: 0}; + press(state, '\u001b[B'); + const right = (rows: string[]) => rows.map(row => row.split('│').at(-1)!).join('\n'); + assert.match(right(draw(state)), /End part/u); + state.editor.buffer.caret = {line: 0, column: 0}; + press(state, '\u001b[B'); press(state, '\u001b[A'); + assert.match(right(draw(state)), /Hello world/u); + press(state, '\u000f'); press(state, 'l'); + assert.equal(state.split?.linked, false); + state.editor.buffer.caret = {line: lines.indexOf('## End part'), column: 0}; + press(state, '\u000f'); press(state, '\u001b[B'); + assert.match(right(draw(state)), /Hello world/u, 'unlinked: the preview stays where it was'); + } finally { rmSync(root, {recursive: true, force: true}); } +}); + +test('Ctrl+O moves between the panes; keys go only to the focused one; x swaps the sides and < > resize within bounds', async () => { + const { root, state } = await session(); + try { + press(state, F2); + press(state, '\u000f'); + assert.equal(state.split?.focus, 'preview'); + const before = state.editor.buffer.text; + press(state, 'qzx'); + assert.equal(state.editor.buffer.text, before, 'letters typed in the preview pane never reach the text'); + assert.equal(state.split?.swapped, true, 'x swapped the panes'); + const first = draw(state)[2]!; + assert.match(first.split('│')[0]!, /Hello world/u); + assert.doesNotMatch(first.split('│')[0]!, /# Hello/u, 'the preview is now on the left'); + const ratio = state.split!.ratio; + press(state, '>'); assert.ok(state.split!.ratio > ratio); + for (let index = 0; index < 20; index += 1) press(state, '<'); + assert.ok(state.split!.ratio >= 30); + for (let index = 0; index < 40; index += 1) press(state, '>'); + assert.ok(state.split!.ratio <= 70); + press(state, 'e'); + assert.equal(state.split?.focus, 'source'); + press(state, 'Z'); + assert.match(state.editor.buffer.lines[0]!, /Z/u); + } finally { rmSync(root, {recursive: true, force: true}); } +}); + +test('narrow terminals show the focused pane at full width and resizing back restores the split', async () => { + const { root, state } = await session(); + try { + press(state, F2); + for (const columns of [30, 50, 80, 99]) { + const rows = draw(state, columns); + assert.ok(rows.every(row => displayWidth(row) <= columns), `${columns}`); + assert.ok(rows.every(row => !row.includes('│') || columns >= 100), `${columns}: one pane only`); + assert.match(rows.join('\n'), /Hello world/u); + } + press(state, '\u000f', 80); + assert.match(draw(state, 80).join('\n'), /Hello world/u); + assert.equal(state.split?.focus, 'preview'); + assert.ok(draw(state, 120).some(row => row.includes('│')), 'back to two panes'); + } finally { rmSync(root, {recursive: true, force: true}); } +}); + +test('Esc leaves the split for the read-only source view with the text kept; F2 again returns to editing; the dirty dialog and Save work from the split', async () => { + const { root, path, state } = await session(); + try { + press(state, F2); + press(state, 'x'); + assert.equal(state.mode, 'split'); + press(state, '\u001b'); + assert.equal(state.mode, 'source'); + assert.equal(state.split, undefined); + assert.equal(state.editor.buffer.dirty, true); + press(state, F2); + assert.equal(state.mode, 'split'); + assert.equal(state.split?.focus, 'preview', 'from the read-only view the preview has the keys'); + press(state, F2); + assert.equal(state.mode, 'edit'); + press(state, F2); + press(state, '\u0013'); + const action = documentSessionKey(state, decodeKeys('\u0013')[0]!, 120, 26); + assert.deepEqual(action, {kind: 'save'}); + assert.equal(readFileSync(path, 'utf8'), `${SOURCE}\n`, 'asking to save writes nothing by itself'); + } finally { rmSync(root, {recursive: true, force: true}); } +}); + +test('the preview pane has its own find and heading prompt that do not touch the text; search never types into the editor', async () => { + const { root, state } = await session(); + try { + press(state, F2); press(state, '\u000f'); + const before = state.editor.buffer.text; + press(state, '/'); press(state, 'End'); press(state, '\r'); + press(state, '#'); press(state, 'middle'); press(state, '\r'); + assert.equal(state.editor.buffer.text, before); + assert.match(draw(state).map(row => row.split('│').at(-1)!).join('\n'), /Middle part/u); + } finally { rmSync(root, {recursive: true, force: true}); } +}); + +test('NO_COLOR: no colour escapes in either pane; any width and height render without throwing', async () => { + const { root, state } = await session(); + const saved = process.env.NO_COLOR; + try { + process.env.NO_COLOR = '1'; + press(state, F2); + for (const [columns, height] of [[30, 8], [50, 12], [100, 10], [120, 26], [200, 40]]) { + const raw = renderDocumentSession(state, columns, height); + assert.ok(raw.every(row => !/\u001b\[(?:3[0-7]|9[0-7]|38;)/u.test(row)), `${columns}x${height}`); + assert.ok(raw.map(row => stripAnsi(row)).every(row => displayWidth(row) <= columns)); + } + } finally { if (saved === undefined) delete process.env.NO_COLOR; else process.env.NO_COLOR = saved; rmSync(root, {recursive: true, force: true}); } +}); + +test('a hostile document does not break the split: control sequences stay inert and a huge file keeps the preview bounded', async () => { + const hostile = `# \u001b]0;pwned\u0007Title\n\n\n\n${'x'.repeat(5000)}\n`; + const { root, state } = await session(hostile); + try { + press(state, F2); + const raw = renderDocumentSession(state, 120, 26).join('\n'); + assert.doesNotMatch(raw, /\u001b\]0;/u); + const big = await session(`${Array.from({length: 40_000}, (_, index) => `line ${index}`).join('\n')}\n`); + try { + press(big.state, F2); + const started = process.hrtime.bigint(); + press(big.state, 'abc'); + draw(big.state); + assert.ok(Number(process.hrtime.bigint() - started) / 1e9 < 5); + } finally { rmSync(big.root, {recursive: true, force: true}); } + } finally { rmSync(root, {recursive: true, force: true}); } +}); + +import {executionFrameFixture} from './support/executionTimelineFrames.js'; + +test('app: F2 in a real workspace shows both panes of the same file, keys stay with the focused pane, Save As works for a Playground split, and closing restores the composer', async () => { + const f = executionFrameFixture(120, 30); + const root = realpathSync(mkdtempSync(join(tmpdir(), 'nmsh-split-app-'))); + try { + const file = join(root, 'doc.md'); writeFileSync(file, `${SOURCE}\n`); + await f.app['openDocumentSession'](file, '/edit doc.md', 'edit', false); + const key = (bytes: string) => { for (const k of decodeKeys(bytes)) (f.app as unknown as {handleKey(k: unknown): void}).handleKey(k); }; + key(F2); + const rows = f.capture().frame.rows.map(row => stripAnsi(row)); + assert.ok(rows.some(row => row.includes('│') && /Hello world/u.test(row)), rows.join('\n')); + key('Z'); + assert.match(f.app['docSession']!.editor.buffer.lines[0]!, /Z/u); + key('\u001b'); + key('q'); + assert.equal(f.app['docSession']!.editor.dialog?.kind, 'unsaved', 'the unsaved text is asked about, not dropped'); + key('d'); + assert.equal(f.app['docSession'], undefined, 'discarding closes the workspace and returns to the shell view'); + const playground = executionFrameFixture(120, 30); + try { + await playground.app['openPlayground']('markdown'); + for (const k of decodeKeys(`# Hi${'\n'}text`)) (playground.app as unknown as {handleKey(k: unknown): void}).handleKey(k); + for (const k of decodeKeys(F2)) (playground.app as unknown as {handleKey(k: unknown): void}).handleKey(k); + assert.equal(playground.app['docSession']!.mode, 'split'); + assert.ok(playground.capture().frame.rows.some(row => stripAnsi(row).includes('│'))); + } finally { playground.dispose(); } + } finally { f.dispose(); rmSync(root, {recursive: true, force: true}); } +});