Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

### Added

- **Hybrid Markdown editing** ([#460](https://github.com/raiseCatError/notMyShell/issues/460)). **F3** in a Markdown document (or `H` from the read-only views) shows it *rendered* — headings, emphasis, links, lists, tables, quotes, code — except for the block your caret (or selection) is in, which shows its exact Markdown source and is fully editable. Move on and it renders again. It is another presentation of the same buffer: every key is the editor's (letters always type; snippets, list/table helpers, undo/redo, hunk review, recovery drafts and Save As all work as in the source editor), the caret lives in source positions, and switching views never changes the text. The smallest safe block is revealed — the whole list for a nested item, the whole table, the whole fence — and a selection shows every block it touches as source. The view is anchored to a source line, so revealing a block does not move what is above it. F3 again returns to the source editor; in the split view (F2) F3 switches the text pane between source and hybrid. Limits: Markdown files only (not HTML); images appear as `[image: alt]`; Up/Down move by source line; pointer input is not handled.
- **Footnote renumbering** ([#463](https://github.com/raiseCatError/notMyShell/issues/463)). *Renumber footnotes* in the snippet palette (F1, then `/` in a Markdown file) numbers the footnotes 1, 2, 3… in the order they are first referenced and updates each definition to match, as one undo step. Named footnotes (`[^note]`), escaped references, code spans and fenced code are left alone; a definition nobody references keeps its number unless a renumbered footnote needs it.
- **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.
Expand Down
15 changes: 15 additions & 0 deletions docs/design/document-workspace.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,21 @@ 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.

## Hybrid view

`DocumentMode` gains `'hybrid'` (#460), implemented in `src/viewer/HybridView.ts` over the shared source map (`docs/architecture/document-source-map.md`). It is a presentation of the one `TextBuffer`; there is no second editor and no second document.

- **Items:** `hybridItems` cuts the buffer into items from `Block.from/to`: each top-level block is one item; every line no block owns (blank lines, lines past the parser's 50,000-line limit, a block the parser could not place) is a one-line item and is shown raw. Blocks that share a span (an HTML block's several blocks) are one item.
- **Active region:** the items that contain the caret line or intersect the selection are shown as raw source (wrapped to the width at grapheme boundaries, tabs expanded, caret and selection drawn as in the editor); all others are rendered by the ordinary Markdown renderer applied to that item's own lines. A block is the smallest safe region: a nested list item reveals its whole list, a table cell the whole table, a fenced line the whole fence. A block that renders to no rows is shown raw rather than vanishing.
- **Input:** `documentSessionKey` treats hybrid like edit mode: every key goes to `editorKey`. Because the caret is always in a raw item, no rendered-column mapping exists to get wrong; the only mapping is source line → rows drawn. Up/Down move by source line (a wrapped line is one step). Wheel scrolls the view only; Page keys move the caret by about a screen of source lines.
- **Viewport:** anchored to `{line of an item's first line, row offset}`, not an absolute row, so re-rendering the active block does not move the rows above it; the caret is revealed with minimal scrolling and, when far away, by placing its block near the bottom.
- **Cost:** moving the caret never re-parses; a text change re-parses once (`DocumentIndex`) and re-renders only items whose text changed (cached by text, width, colour depth and glyph set, 600 entries); only rows on screen are drawn. Counters (`stats.parses`, `stats.renders`) back the budget tests.
- **Modes:** F3 toggles hybrid ⇄ source editor; from the read-only views `H`; Esc leaves for the read-only source view (text kept); Ctrl+O previews and returns; in the split F3 switches the text pane's view and F2 closes the split back to hybrid or edit. Dialogs, the name prompt and the review take the whole panel as before.
- **Independent review (opus subagent) and what changed:** a lone CR inside a line shifted every later block span (the index now parses the buffer's own lines, not its joined text); the caret was drawn twice and a character hidden at a wrap boundary (one rule now: the caret belongs to the last row of its line that starts at or before its column, and only that row draws it); the wheel was undone by the next draw (a "free scroll" state that any editing or moving key clears; in the split's hybrid pane too); the render cache grew without bound (current width only, 2 MB of rows, blocks over 2,000 lines are shown as source instead of rendered whole); every render re-wrapped the whole active block (per-line wrap cache: a caret move wraps nothing, a keystroke one line); the cache key lacked the link setting, syntax roles and theme colours; a one-row viewport and terminals under 12 columns could lose the caret; reloading from disk dropped the hybrid view. The reviewer's 150 randomized 400-step runs and 20,000 adversarial documents found no change to the text, no gap or overlap in the items and no unsafe output.
- **Known limits:** a source line over 4,096 characters is wrapped on every draw (not cached), so a megabyte-long line is slow here — use the source editor; an unclosed fence's trailing blank lines are separate raw lines.
- **Not provided:** HTML files, images drawn in place (the placeholder is shown), visual-line Up/Down, pointer input, rendered-text selection with the mouse, tables edited as a grid.
- **Verified by** `tests/hybrid.test.ts` (items, wrapping, rendering, smallest region, ownership, a 300-step mode/move/resize walk that never changes the text, selection across blocks, undo through a snippet, heading navigation, viewport stability, parse/render budgets on 4,000 lines, widths, NO_COLOR, hostile text, CRLF/BOM, split with a hybrid pane, and the real app with the Playground, Save As, recovery draft and hunk review).

## 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.
Expand Down
1 change: 1 addition & 0 deletions docs/development/v024-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ The paused issues #460–#469 are the milestone (the maintainer superseded the e

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.

8. **#460: hybrid Markdown editing** — branch `feature/v024-hybrid` (from `dev` after #489–#496 merged). Done: hybrid mode, items from the source map, active-block raw editing, anchored viewport, split text-pane toggle, tests, independent review (see its section). **#460 remaining:** images in place, visual-line movement, pointer input, tables as a grid, HTML. **#461** still lacks pointer input, preview→caret sync, vertical orientation and the Chromium preview; **#463** footnote renumbering; **#466**, **#462** (details/Mermaid/math), **#464/#465/#467** not started.
9. **#463: footnote renumbering** — branch `feature/v024-footnotes`, from `dev`. Done (tests in `footnotes.test.ts`). **#463 acceptance:** every implemented item is covered by automated tests; the only open requirement is the physical Ghostty/Zed pass (NOT RUN), so the issue stays open.

## Environment notes
Expand Down
6 changes: 6 additions & 0 deletions docs/testing/v024-editor-physical-qa.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

## Hybrid view (#460)
- [ ] Open a Markdown file, F3 (is F3 delivered by the terminal and by Zed?): headings, bold, links, lists, tables, quotes and fences render; the block under the caret shows its source; moving with the arrows swaps blocks without the screen jumping.
- [ ] Type in every kind of block; select across blocks with Shift+Down (both show source); undo/redo; insert a snippet and Tab through it.
- [ ] Resize the window while editing; NO_COLOR; Safe Glyphs; 30, 50, 80 and 120 columns; a large file.
- [ ] F2 with F3: the split with a hybrid text pane; Esc, Ctrl+O, Save, recovery after a kill, hunk review.

## 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.
Expand Down
2 changes: 1 addition & 1 deletion src/app/TerminalApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4784,7 +4784,7 @@ export class TerminalApp {
if (this.docSession !== session) return;
if (!result.ok) { editor.note = {text: result.reason, tone: 'error'}; session.viewer.note = result.reason; this.render(); return; }
this.drafts().discard(editor);
const fresh = createDocumentSession(result.file, session.mode === 'edit' ? 'edit' : 'source');
const fresh = createDocumentSession(result.file, session.mode === 'edit' || session.mode === 'hybrid' ? session.mode : 'source');
fresh.note = 'Reloaded from disk; your changes were discarded.';
fresh.viewer.note = fresh.note;
session.preview?.dispose();
Expand Down
9 changes: 6 additions & 3 deletions src/markdown/blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,9 +145,12 @@ function interrupts(line: string, next: string | undefined): boolean {
}

export function parseBlocks(source: string, options: {html?: boolean} = {}): Block[] {
const lines = source.replace(/\r\n?/gu, '\n').split('\n');
if (lines.length > MAX_LINES) lines.length = MAX_LINES;
return parseLines(lines, 0, Boolean(options.html), 0);
return parseBlockLines(source.replace(/\r\n?/gu, '\n').split('\n'), options);
}

/** The same, for lines already cut (a buffer's): a stray CR inside a line stays in it and cannot shift the line numbers after it. */
export function parseBlockLines(lines: readonly string[], options: {html?: boolean} = {}): Block[] {
return parseLines(lines.length > MAX_LINES ? lines.slice(0, MAX_LINES) : lines, 0, Boolean(options.html), 0);
}

/** The kind of HTML block a line starts (CommonMark 1, 2, 6, 7), or undefined. */
Expand Down
18 changes: 12 additions & 6 deletions src/markdown/sourceMap.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type {Position, TextBuffer} from '../viewer/textBuffer.js';
import {mapPosition} from '../viewer/snippets.js';
import {parseBlocks, type Block} from './blocks.js';
import {parseBlockLines, parseBlocks, type Block} from './blocks.js';
import type {MarkdownRow} from './render.js';

/**
Expand Down Expand Up @@ -131,21 +131,27 @@ export interface DocumentIndexOptions {

/** The parsed structure of a buffer, kept until the buffer changes. */
export class DocumentIndex {
private version = 0;
private versionCounter = 0;
/** How many times the buffer was parsed: tests and budgets read it. */
parseCount = 0;
private cached?: {version: number; blocks: Block[]; headings: HeadingEntry[]};
private readonly stop: () => void;
constructor(private readonly buffer: TextBuffer, private readonly options: DocumentIndexOptions = {}) {
this.stop = buffer.observe(() => { this.version += 1; });
this.stop = buffer.observe(() => { this.versionCounter += 1; });
}

/** Changes whenever the buffer's text does. */
get version(): number { return this.versionCounter; }

/** The document was longer than the parser reads; only its first lines are indexed. */
get truncated(): boolean { return this.buffer.lines.length > 50_000; }

private ensure() {
if (this.cached?.version === this.version) return this.cached;
if (this.cached?.version === this.versionCounter) return this.cached;
this.parseCount += 1;
this.options.onParse?.();
const blocks = parseBlocks(this.buffer.text, {html: this.options.html ?? false});
this.cached = {version: this.version, blocks, headings: outline(blocks, this.buffer.lines)};
const blocks = parseBlockLines(this.buffer.lines, {html: this.options.html ?? false});
this.cached = {version: this.versionCounter, blocks, headings: outline(blocks, this.buffer.lines)};
return this.cached;
}

Expand Down
4 changes: 3 additions & 1 deletion src/terminal/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' | 'splitToggle'
| {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' | 'hybridToggle'
/** 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. */
Expand Down Expand Up @@ -106,6 +106,8 @@ const SEQUENCES: Array<[string, Key['kind']]> = [
['\u001BOP', 'palette'], ['\u001B[11~', 'palette'],
// F2 opens or closes the document workspace's split view.
['\u001BOQ', 'splitToggle'], ['\u001B[12~', 'splitToggle'], ['\u001B[Q', 'splitToggle'],
// F3 switches the document workspace's text between its source and the hybrid view.
['\u001BOR', 'hybridToggle'], ['\u001B[13~', 'hybridToggle'],
// 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
Expand Down
Loading
Loading