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 @@ -9,6 +9,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.
- **Expandable `<details>`** ([#462](https://github.com/raiseCatError/notMyShell/issues/462)). The native Markdown/HTML viewer now shows `<details><summary>…</summary>…</details>` as a disclosure: `▸ Summary` when closed (hiding its content), `▾ Summary` with the content indented when open — in the rendered view, the preview, the split's preview pane and the hybrid view. GitHub-style details, with a blank line and Markdown inside and `</details>` in a later block, are understood (nested ones too); the `open` attribute sets the starting state. **`z`** toggles the summary at or below the top of the screen, **`Z`** all summaries in view; choices are kept by summary text, survive scrolling, resizing and edits above, and never change the file. Scripts, handlers and unknown tags in a details block are inert text. Not yet: pointer clicks (the viewer has no pointer support), and toggling inside the hybrid view (it shows each details as written: closed unless `open`).
- **Problems: offline static diagnostics** ([#466](https://github.com/raiseCatError/notMyShell/issues/466)). In a Markdown, HTML or CSS document, **F1 then `e`** (the snippet palette's *Problems…* in HTML files; **F1** in a CSS file; **`P`** in the read-only views and the split's preview pane) lists what is wrong: ✖ errors, ▲ warnings, ℹ notes and `?` things that cannot be checked. Markdown: links to files that do not exist, `#fragments` with no heading or id, missing images, images without alt text, undefined `[text][ref]` references and footnotes, unused footnotes, repeated headings. HTML: missing images, stylesheets and scripts, `<img>` without `alt`, form controls without a label, duplicate ids, unresolved `#fragments`, unclosed or unexpected tags. CSS: missing `url()` assets and unbalanced braces. Type to search, Tab filters (all, problems, errors), Enter jumps to the exact text and selects it (Back returns), Del dismisses one, Ctrl+R checks again. Checking starts when you first open Problems, follows the text (typing, undo, snippets, restored drafts, a merge) after a short pause, drops results for text that has since changed, and counts show in the editor's status row. Everything is offline: external URLs are counted, never fetched; no script, style or command is run; files are only looked at (never read except another Markdown file's headings, 256 KiB at most) and only inside the document's project (a path that leaves it, by `..` or a symlink, is reported as not checked); nothing is written. Browser-console diagnostics are a later, separate step.
- **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.
Expand Down
5 changes: 5 additions & 0 deletions docs/design/document-workspace.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ 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.

## Disclosure (`<details>`)

`Block` gains `{kind: 'details'; open; summary; blocks}` (#462). Two sources: `htmlToBlocks` (an HTML document or a self-contained element) and `parseLines`, which recognises GitHub's layout — an HTML block that opens a `<details>` it does not close, Markdown after a blank line, `</details>` alone on a later line (nesting counted, within 5,000 lines) — and parses the lines between as Markdown at the right source offset, so inner blocks keep exact spans. Content written in the opening chunk is kept; anything that does not match the layout is left to the old HTML-block path, so no text is dropped. A summary is HTML, as on GitHub: Markdown punctuation in it stays literal; no summary reads "Details".

`renderMarkdown` takes `disclosure.isOpen(key, defaultOpen)`; the summary row carries `MarkdownRow.details`, the key (summary text and which one of that text). The viewer keeps `disclosure: Map<key, open>` (never persisted, never in the file); `z`/`Z` read each row's drawn state (▾/v), so a block's own `open` is respected, and set the opposite. The state is part of the rendered-rows cache key. The hybrid view renders inactive details as written (no toggle there yet), and the plain-text fallback is the source.
## Problems (static diagnostics)

`src/markdown/diagnostics.ts` (engine), `src/viewer/problems.ts` (runner) and the *problems* dialog of `EditorPanel.ts` ([#466](https://github.com/raiseCatError/notMyShell/issues/466)). Reuses `parseBlockLines` (code blocks are skipped through the block spans), `headingsFromLines` and `resolveFragment`; no second parser, no language server.
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 @@ -44,6 +44,7 @@ The paused issues #460–#469 are the milestone (the maintainer superseded the e
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.

11. **#462: `<details>`** — branch `feature/v024-details`, from `dev`. Done: block, parser (GitHub layout), renderer, `z`/`Z`, tests. **#462 remaining:** Mermaid and math (separate slices; not started), pointer toggling, hybrid toggling. The diagnostics engine descends into `details` blocks (so a link or code span inside one is checked like any other).
10. **#466: offline static diagnostics** — branch `feature/v024-diagnostics`, from `dev`. Done: engine, runner, Problems surface in all modes, tests, independent review (see its section in the design doc). **#466 remaining:** the optional browser console for trusted previews (separate gated slice, nothing started), a gutter marker, further languages. **#462 next:** `<details>` disclosure, then Mermaid and math (separate slices).

## Environment notes
Expand Down
2 changes: 2 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,8 @@ 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.

## Details (#462)
- [ ] A Markdown file with GitHub-style `<details>`: closed shows `▸ Summary`; `z` opens it; `Z`; in the preview, the split's preview pane and the hybrid view; narrow widths; Safe Glyphs (`>`/`v`); a hostile file.
## Problems (#466)
- [ ] F1 then `e` in a Markdown file with a broken link, a missing image and a bad `#fragment`: the list shows them; Enter selects the exact text; Back returns. `P` in the read-only views and in the split's preview pane.
- [ ] An HTML file (palette → Problems) and a CSS file (F1); the status row counts; fixing a finding removes it after a moment; undo brings it back.
Expand Down
46 changes: 46 additions & 0 deletions src/markdown/blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export type Block = (
| {kind: 'quote'; blocks: Block[]}
| {kind: 'table'; align: Alignment[]; header: string[]; rows: string[][]}
| {kind: 'rule'}
/** `<details>`: a disclosure, collapsed unless `open`; its content is blocks (Markdown between GitHub-style tags is Markdown). */
| {kind: 'details'; open: boolean; summary: string; blocks: Block[]}
) & {
/** The source lines the block came from (0-based; `to` is exclusive), when `parseBlocks` knows them: the shared source-position map. */
from?: number;
Expand Down Expand Up @@ -150,6 +152,7 @@ export function parseBlocks(source: string, options: {html?: boolean} = {}): Blo

/** 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[] {
detailsBudget = DETAILS_SCAN_LINES;
return parseLines(lines.length > MAX_LINES ? lines.slice(0, MAX_LINES) : lines, 0, Boolean(options.html), 0);
}

Expand All @@ -167,6 +170,42 @@ function htmlBlockKind(line: string): 1 | 2 | 6 | 7 | undefined {
return KNOWN.has(name) && text.length <= 4096 && /^<[^<>]*>\s*$/u.test(text) ? 7 : undefined;
}

const OPENS = /<details\b/giu, CLOSES = /<\/details\s*>/giu;
/** Lines the whole parse may look through to find closing `</details>` tags: one budget for all openers, so many unclosed ones cost a bounded amount in total. */
const DETAILS_SCAN_LINES = 100_000;
let detailsBudget = DETAILS_SCAN_LINES;
const count = (pattern: RegExp, text: string) => (text.match(pattern) ?? []).length;

/**
* GitHub writes a disclosure as `<details>` + `<summary>` in one HTML block, Markdown after a blank line, and `</details>` in a
* later block. When the first block opens a details it does not close, find the line that closes it (nesting counted, within a
* bound) and return what the first block holds: whether it is open, its summary and any content written right after it.
*/
function detailsAcross(lines: readonly string[], start: number, end: number): {close: number; open: boolean; summary: string; head: string} | undefined {
const chunk = lines.slice(start, end).join('\n');
if (!/^\s*<details\b/iu.test(chunk)) return undefined;
let depth = count(OPENS, chunk) - count(CLOSES, chunk);
if (depth <= 0) return undefined;
for (let at = end; at < Math.min(lines.length, end + 5000); at += 1) {
if (--detailsBudget < 0) return undefined;
depth += count(OPENS, lines[at]!) - count(CLOSES, lines[at]!);
if (depth <= 0) {
if (!/^\s*<\/details\s*>\s*$/iu.test(lines[at]!)) return undefined;
const opening = /^\s*<details\b([^>]*)>/iu.exec(chunk);
const summary = /<summary\b[^>]*>([\s\S]*?)<\/summary\s*>/iu.exec(chunk);
const head = chunk.slice((opening?.[0].length ?? 0)).replace(summary?.[0] ?? '', '').trim();
return {close: at, open: /\bopen\b/iu.test(opening?.[1] ?? ''), summary: summaryText(summary?.[1] ?? ''), head};
}
}
return undefined;
}

function summaryText(inner: string): string {
const paragraph = htmlToBlocks(`<summary>${inner}</summary>`)[0];
const text = paragraph && paragraph.kind === 'paragraph' ? paragraph.text.replace(/^<b>([\s\S]*)<\/b>$/u, '$1') : '';
return text.trim() || 'Details';
}

/** Index just past an HTML block starting at `start`: kinds 1 and 2 to their end marker, the others to a blank line. */
function htmlBlockEnd(lines: readonly string[], start: number, kind: 1 | 2 | 6 | 7): number {
const closes = kind === 1 ? /<\/(?:pre|script|style|textarea)>/iu : kind === 2 ? /-->/u : undefined;
Expand Down Expand Up @@ -218,6 +257,13 @@ function parseLines(lines: readonly string[], depth: number, html = false, offse
const markup = html ? htmlBlockKind(line) : undefined;
if (markup) {
const end = htmlBlockEnd(lines, index, markup);
const disclosure = markup === 6 && depth < MAX_DEPTH ? detailsAcross(lines, index, end) : undefined;
if (disclosure) {
const content = [...(disclosure.head ? htmlToBlocks(disclosure.head) : []), ...parseLines(lines.slice(end, disclosure.close), depth + 1, html, offset + end)];
blocks.push({kind: 'details', open: disclosure.open, summary: disclosure.summary, blocks: content});
index = disclosure.close + 1;
continue;
}
blocks.push(...htmlToBlocks(lines.slice(index, end).join('\n')));
index = end;
continue;
Expand Down
2 changes: 1 addition & 1 deletion src/markdown/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,7 @@ class Run {
function codeLines(blocks: readonly Block[], into: Set<number>): void {
for (const block of blocks) {
if (block.kind === 'code' && block.from !== undefined && block.to !== undefined) for (let line = block.from; line < block.to; line += 1) into.add(line);
else if (block.kind === 'quote') codeLines(block.blocks, into);
else if (block.kind === 'quote' || block.kind === 'details') codeLines(block.blocks, into);
else if (block.kind === 'list') for (const item of block.items) codeLines(item.blocks, into);
}
}
Expand Down
5 changes: 5 additions & 0 deletions src/markdown/html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,11 @@ function blockOf(element: Element): Block[] {
case 'blockquote': return [{kind: 'quote', blocks: blocksOf(element.children)}];
case 'ul': case 'ol': case 'menu': case 'dir': return [list(element)];
case 'table': return table(element);
case 'details': {
const summary = element.children.find((node): node is Element => typeof node !== 'string' && node.name === 'summary');
const text = summary ? clean(inlineText(summary.children)) : '';
return [{kind: 'details', open: 'open' in element.attributes, summary: text || 'Details', blocks: blocksOf(element.children.filter(node => node !== summary))}];
}
case 'summary': case 'dt': case 'caption': case 'legend': {
const text = clean(inlineText(element.children));
return text ? [{kind: 'paragraph', text: `<b>${text}</b>`}] : [];
Expand Down
26 changes: 25 additions & 1 deletion src/markdown/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ export interface MarkdownRow {
block?: RowBlock;
/** Set on image rows: the image's index in the document. */
image?: number;
/** Set on the summary row of a `<details>`: the key the viewer toggles it by (summary text and which one of that text). */
details?: string;
/** The source line (0-based) of the block this row belongs to: the shared source-position map (blank gap rows have none). */
line?: number;
}
Expand All @@ -80,6 +82,8 @@ export interface MarkdownOptions {
maxImageRows?: number;
/** Headings may be sized blocks (only a viewer that places them passes this): the most rows a heading may take. */
sizedHeadings?: {maxRows: number};
/** Which `<details>` are open: the viewer's choices over each block's own `open`. Without it a block's own state shows. */
disclosure?: {isOpen(key: string, defaultOpen: boolean): boolean};
}

const RESET = '\u001b[0m';
Expand All @@ -101,6 +105,8 @@ interface Context {
images?: ImageProvider;
maxImageRows: number;
sizedHeadings?: {maxRows: number};
disclosure?: {isOpen(key: string, defaultOpen: boolean): boolean};
detailsSeen: Map<string, number>;
blockIds: {next: number};
}

Expand Down Expand Up @@ -267,7 +273,7 @@ function tokenSgr(kind: CodeTokenKind, context: Context): string {
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, html: false, maxImageRows: 1, blockIds: {next: 1}});
fg: color => colorEscape(38, color, level), tone: UI_COLORS.primary, codeIndex: 0, html: false, maxImageRows: 1, detailsSeen: new Map(), blockIds: {next: 1}});
}

function codeRows(block: Extract<Block, {kind: 'code'}>, width: number, context: Context): MarkdownRow[] {
Expand Down Expand Up @@ -437,6 +443,22 @@ function renderBlocks(blocks: readonly Block[], width: number, context: Context,
case 'code':
out.push(...codeRows(block, width, context));
break;
case 'details': {
const nth = context.detailsSeen.get(block.summary) ?? 0;
context.detailsSeen.set(block.summary, nth + 1);
const key = `${block.summary}#${nth}`;
const open = context.disclosure ? context.disclosure.isOpen(key, block.open) : block.open;
const marker = glyph(context, open ? '▾' : '▸', open ? 'v' : '>');
const accent = context.fg(UI_COLORS.accent);
inlineRows(block.summary, Math.max(1, width - 2), context, `${BOLD}${accent}`).forEach((row, index) => out.push({
ansi: index === 0 ? `${accent}${marker}${RESET} ${row.ansi}` : ` ${row.ansi}`, plain: `${index === 0 ? `${marker} ` : ' '}${row.plain}`.trimEnd(), kind: 'text', ...(index === 0 ? {details: key} : {})}));
if (open && block.blocks.length) {
for (const row of renderBlocks(block.blocks, Math.max(1, width - 2), context, depth + 1)) {
out.push({...row, ansi: row.ansi ? ` ${row.ansi}` : '', plain: row.plain ? ` ${row.plain}` : ''});
}
}
break;
}
case 'rule': {
const line = glyph(context, '─', '-').repeat(Math.max(1, Math.min(width, 48)));
out.push({ansi: `${context.fg(UI_COLORS.separator)}${line}${RESET}`, plain: line, kind: 'rule'});
Expand Down Expand Up @@ -504,6 +526,8 @@ function renderSource(source: string, columns: number, options: MarkdownOptions,
...(options.images ? {images: options.images} : {}),
maxImageRows: Math.max(1, options.maxImageRows ?? 16),
...(options.sizedHeadings ? {sizedHeadings: options.sizedHeadings} : {}),
...(options.disclosure ? {disclosure: options.disclosure} : {}),
detailsSeen: new Map(),
blockIds: {next: 1},
};
// The renderer never trusts its caller to have scrubbed: terminal controls and bidi formatting go, tabs become spaces.
Expand Down
Loading
Loading