From 31a5e8c57089647577bccc83ba47d67fddabc50e Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Thu, 22 Oct 2026 03:01:15 +0530 Subject: [PATCH 1/3] Markdown viewer: expandable
disclosure (#462) --- CHANGELOG.md | 1 + src/markdown/blocks.ts | 41 ++++++++++ src/markdown/html.ts | 5 ++ src/markdown/render.ts | 26 ++++++- src/markdown/sourceMap.ts | 2 +- src/viewer/ViewerPanel.ts | 37 ++++++++- tests/details.test.ts | 155 ++++++++++++++++++++++++++++++++++++++ tests/viewHtml.test.ts | 4 +- 8 files changed, 264 insertions(+), 7 deletions(-) create mode 100644 tests/details.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b361e74..5c807595 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `
`** ([#462](https://github.com/raiseCatError/notMyShell/issues/462)). The native Markdown/HTML viewer now shows `
……
` 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 `
` 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`). - **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. diff --git a/src/markdown/blocks.ts b/src/markdown/blocks.ts index b955995d..2bc9a54d 100644 --- a/src/markdown/blocks.ts +++ b/src/markdown/blocks.ts @@ -22,6 +22,8 @@ export type Block = ( | {kind: 'quote'; blocks: Block[]} | {kind: 'table'; align: Alignment[]; header: string[]; rows: string[][]} | {kind: 'rule'} + /** `
`: 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; @@ -167,6 +169,38 @@ function htmlBlockKind(line: string): 1 | 2 | 6 | 7 | undefined { return KNOWN.has(name) && text.length <= 4096 && /^<[^<>]*>\s*$/u.test(text) ? 7 : undefined; } +const OPENS = //giu; +const count = (pattern: RegExp, text: string) => (text.match(pattern) ?? []).length; + +/** + * GitHub writes a disclosure as `
` + `` in one HTML block, Markdown after a blank line, and `
` 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*\s*$/iu.test(lines[at]!)) return undefined; + const opening = /^\s*]*)>/iu.exec(chunk); + const summary = /]*>([\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(`${inner}`)[0]; + const text = paragraph && paragraph.kind === 'paragraph' ? paragraph.text.replace(/^([\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; @@ -218,6 +252,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; diff --git a/src/markdown/html.ts b/src/markdown/html.ts index bd228cd8..cb1064ec 100644 --- a/src/markdown/html.ts +++ b/src/markdown/html.ts @@ -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: `${text}`}] : []; diff --git a/src/markdown/render.ts b/src/markdown/render.ts index 8f7da4f7..7a02d40d 100644 --- a/src/markdown/render.ts +++ b/src/markdown/render.ts @@ -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 `
`: 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; } @@ -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 `
` 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'; @@ -101,6 +105,8 @@ interface Context { images?: ImageProvider; maxImageRows: number; sizedHeadings?: {maxRows: number}; + disclosure?: {isOpen(key: string, defaultOpen: boolean): boolean}; + detailsSeen: Map; blockIds: {next: number}; } @@ -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, width: number, context: Context): MarkdownRow[] { @@ -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'}); @@ -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. diff --git a/src/markdown/sourceMap.ts b/src/markdown/sourceMap.ts index f397e1e4..eaef7765 100644 --- a/src/markdown/sourceMap.ts +++ b/src/markdown/sourceMap.ts @@ -68,7 +68,7 @@ function walk(blocks: readonly Block[], lines: readonly string[], out: Array; + /** The person's choices for `
` (key → open); a block without one shows its own `open` state (#462). */ + disclosure?: Map; query?: string; match?: {unit: number; start: number; length: number}; matchCount?: number; @@ -168,12 +170,13 @@ function gutterWidth(state: ViewerState): number { } function markdownRows(state: ViewerState, width: number, look: ViewerLook): MarkdownRow[] { - const version = `${look.images?.version ?? 0}|${look.sized ? 'sized' : 'plain'}`; + const version = `${look.images?.version ?? 0}|${look.sized ? 'sized' : 'plain'}|${[...(state.disclosure ?? [])].map(([key, open]) => `${key}=${open}`).join(',')}`; if (state.markdown?.width !== width || state.markdown.version !== version) { const options = { ...(look.syntax ? {syntax: look.syntax} : {}), hyperlinks: Boolean(look.hyperlinks), ...(look.images ? {images: look.images} : {}), ...(look.sized ? {sizedHeadings: {maxRows: 3}} : {}), + ...(state.disclosure?.size ? {disclosure: {isOpen: (key: string, open: boolean) => state.disclosure!.get(key) ?? open}} : {}), }; state.markdown = {width, version, rows: state.document.format === 'html' ? renderHtml(state.document.text, width, options) : renderMarkdown(state.document.text, width, {...options, html: true})}; } @@ -370,6 +373,33 @@ function countMatches(state: ViewerState, width: number, look: ViewerLook): numb return total; } +// ---- Disclosure ------------------------------------------------------------------------------------------------- + +/** + * `z` opens or closes the `
` summary at or below the top of the screen (else the last one above it); `Z` all the + * summaries now in the rendered rows. The choices live in the viewer, keyed by summary text, and never touch the source. + */ +function toggleDetails(state: ViewerState, every: boolean, width: number, body: number, look: ViewerLook): void { + if (!state.rendered) { state.note = 'Details open and close in the rendered view (m).'; return; } + const rows = markdownRows(state, width, look); + const keys: Array<{key: string; at: number}> = []; + rows.forEach((row, at) => { if (row.details) keys.push({key: row.details, at}); }); + if (!keys.length) { state.note = 'No details in this document.'; return; } + const disclosure = state.disclosure ??= new Map(); + // Whether a summary is open is read from how its row was drawn (▾ or v), so a block's own `open` attribute is respected. + const isOpen = (at: number) => /^(▾|v )/u.test(rows[at]!.plain); + if (every) { + const anyClosed = keys.some(({at}) => !isOpen(at)); + for (const {key} of keys) disclosure.set(key, anyClosed); + } else { + const target = keys.find(({at}) => at >= state.top) ?? keys.at(-1)!; + disclosure.set(target.key, !isOpen(target.at)); + } + delete state.markdown; + state.sub = 0; + clampTop(state, width, body, look); +} + // ---- Headings --------------------------------------------------------------------------------------------------- const outlines = new WeakMap(); @@ -474,6 +504,7 @@ export function viewerKey(state: ViewerState, key: Key, columns: number, height: 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 === 'z' || text === 'Z') toggleDetails(state, text === 'Z', width, body, look); else if (text === ':') state.input = {kind: 'goto', value: ''}; else if (text === '#' && renders(state.document)) state.input = {kind: 'heading', value: '', focus: 0}; else if (key.kind === 'backspace' && state.jumps) { @@ -536,14 +567,14 @@ function controls(state: ViewerState, columns: number, look: ViewerLook): string if (look.workspace) { const preview = look.workspace.mode === 'preview'; return renderControlRows([ - ['↑↓', 'scroll'], ['/', 'find'], ['#', 'headings'], ...(preview ? [] : [[':', 'line'] as const]), + ['↑↓', 'scroll'], ['/', 'find'], ['#', 'headings'], ...(state.rendered && state.markdown?.rows.some(row => row.details) ? [['z', 'details'] as const] : []), ...(preview ? [] : [[':', 'line'] as const]), ['h', preview ? 'source' : 'preview'], ['e', 'edit'], ...(preview ? [['r', 'refresh'] as const] : []), ...(preview && state.rendered && look.images?.count ? [['i', 'images'] as const] : []), ['Ctrl+S', 'save'], ['R', 'reload from disk'], ['Esc', preview ? 'source' : 'close'], ], width); } return renderControlRows([ - ['↑↓', 'scroll'], ['/', 'find'], ...(renders(state.document) ? [['#', 'headings'] as const] : []), ...(state.query ? [['n/N', 'next/previous'] as const] : []), [':', 'line'], + ['↑↓', 'scroll'], ['/', 'find'], ...(renders(state.document) ? [['#', 'headings'] as const] : []), ...(state.rendered && state.markdown?.rows.some(row => row.details) ? [['z', 'details'] as const] : []), ...(state.query ? [['n/N', 'next/previous'] as const] : []), [':', 'line'], ...(renders(state.document) ? [['m', state.rendered ? 'source' : 'rendered'] as const] : []), ...(!state.rendered ? [['w', state.wrap ? 'no wrap' : 'wrap'] as const, ['l', 'numbers'] as const] : []), ...(state.rendered && look.images?.count ? [['i', 'images'] as const] : []), diff --git a/tests/details.test.ts b/tests/details.test.ts new file mode 100644 index 00000000..e477bca1 --- /dev/null +++ b/tests/details.test.ts @@ -0,0 +1,155 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {parseBlocks, type Block} from '../src/markdown/blocks.js'; +import {htmlToBlocks} from '../src/markdown/html.js'; +import {renderMarkdown, renderHtml} from '../src/markdown/render.js'; +import {headingsOf} from '../src/markdown/sourceMap.js'; +import {createViewer, renderViewer, viewerKey, type ViewerState} from '../src/viewer/ViewerPanel.js'; +import {decodeKeys} from '../src/terminal/keys.js'; +import {displayWidth, stripAnsi} from '../src/util/text.js'; +import type {ViewDocument} from '../src/viewer/document.js'; + +const plain = (rows: ReturnType) => rows.map(row => row.plain); +const DOC = ['# Title', '', '
', 'Click for more', '', 'Hidden *markdown* paragraph.', '', '- item one', '- item two', '', '
', '', 'After.'].join('\n'); +const GH = (extra = '') => parseBlocks(DOC.replace('
', ``), {html: true}); +const kinds = (blocks: Block[]) => blocks.map(block => block.kind); + +test('markdown: GitHub-style
with a blank line and Markdown inside is one details block with its source span', () => { + const blocks = GH(); + assert.deepEqual(kinds(blocks), ['heading', 'details', 'paragraph']); + const details = blocks[1] as Extract; + assert.equal(details.summary, 'Click for more', 'a summary is HTML, as on GitHub: Markdown punctuation in it stays literal'); + assert.equal(details.open, false); + assert.deepEqual(kinds(details.blocks), ['paragraph', 'list']); + assert.deepEqual([details.from, details.to], [2, 11]); + assert.deepEqual(details.blocks.map(block => block.from), [5, 7]); + assert.equal((GH(' open')[1] as Extract).open, true); +}); + +test('html documents and one-block details parse the same way; nested details and content in the opening chunk are kept', () => { + const html = htmlToBlocks('
S

one

inner

deep

'); + const outer = html[0] as Extract; + assert.deepEqual([outer.kind, outer.open, outer.summary], ['details', true, 'S']); + assert.equal((outer.blocks.find(block => block.kind === 'details') as Extract).summary, 'inner'); + const md = parseBlocks('
\nS\nfirst line right away\n\nsecond\n\n
', {html: true}); + const details = md[0] as Extract; + assert.deepEqual(kinds(details.blocks), ['paragraph', 'paragraph']); + const nested = parseBlocks('
\na\n\n
\nb\n\ninner text\n\n
\n\nouter text\n\n
', {html: true}); + const a = nested[0] as Extract; + assert.deepEqual(kinds(a.blocks), ['details', 'paragraph']); +}); + +test('unclosed or odd details never lose text: it renders as the HTML would; a missing summary says Details', () => { + const unclosed = parseBlocks('
\nS\n\ntext after', {html: true}); + assert.ok(unclosed.length >= 1); + assert.ok(plain(renderMarkdown('
\nS\n\ntext after', 60, {html: true, level: 'none'} as never)).join('\n').includes('text after')); + const bare = htmlToBlocks('

x

')[0] as Extract; + assert.equal(bare.summary, 'Details'); +}); + +test('rendering: collapsed shows only the summary, expanded shows the content indented; Safe Glyphs use > and v; narrow widths fit', () => { + const collapsed = plain(renderMarkdown(DOC, 40, {html: true, level: 'none', glyphs: 'nerd'} as never)).join('\n'); + assert.match(collapsed, /▸ Click for more/u); + assert.doesNotMatch(collapsed, /Hidden/u); + const open = plain(renderMarkdown(DOC, 40, {html: true, level: 'none', glyphs: 'nerd', disclosure: {isOpen: () => true}} as never)); + assert.match(open.join('\n'), /▾ Click for more/u); + assert.ok(open.some(row => /^ {2}Hidden markdown paragraph\./u.test(row)), open.join('|')); + assert.ok(open.some(row => /^ {2}\S? ?item one/u.test(row))); + const safe = plain(renderMarkdown(DOC, 40, {html: true, level: 'none', glyphs: 'safe', disclosure: {isOpen: () => true}} as never)).join('\n'); + assert.match(safe, /v Click for more/u); + for (const width of [8, 12, 30, 80]) for (const row of renderMarkdown(DOC, width, {html: true, level: 'none', disclosure: {isOpen: () => true}} as never)) assert.ok(displayWidth(row.plain) <= width, `${width}: ${row.plain}`); + const withHtml = plain(renderHtml('
S

body

', 40, {level: 'none'} as never)).join('\n'); + assert.match(withHtml, /body/u, 'the open attribute is honoured'); +}); + +test('rows carry the disclosure key and the source line; headings inside details are still in the outline', () => { + const rows = renderMarkdown(DOC, 60, {html: true, level: 'none', disclosure: {isOpen: () => true}} as never); + const header = rows.find(row => row.plain.includes('Click for more'))!; + assert.equal(header.details, 'Click for more#0'); + assert.equal(header.line, 2); + assert.deepEqual(headingsOf('
\ns\n\n## Inside\n\n
', {html: true}).map(heading => heading.text), ['Inside']); +}); + +test('scripts and handlers in a details block are inert text', () => { + const rows = renderMarkdown('
Safetext
', 60, {html: true, level: 'none'} as never); + const text = plain(rows).join('\n'); + assert.doesNotMatch(text, /alert|onerror|onclick/u); + assert.match(text, /Safe/u); +}); + +// ---- The viewer ------------------------------------------------------------------------------------------------- + +const doc = (text: string): ViewDocument => ({title: 'd.md', origin: {kind: 'file', path: '/x/d.md', realPath: '/x/d.md', symlink: false, bytes: text.length, mtimeMs: 0}, lines: text.split('\n'), text, format: 'markdown', language: 'markdown', notes: [], lineEnding: 'lf', complete: true}); +const keys = (state: ViewerState, bytes: string, columns = 80, height = 24) => { for (const key of decodeKeys(bytes)) viewerKey(state, key, columns, height); }; +const shown = (state: ViewerState, columns = 80, height = 24) => renderViewer(state, columns, height).map(row => stripAnsi(row)).join('\n'); + +test('viewer: z opens and closes the summary on screen, Z all that are visible, state survives scrolling and a resize and never touches the source', () => { + const state = createViewer(doc(`${DOC}\n\n
\nSecond\n\nBody two.\n\n
\n`)); + assert.match(shown(state), /▸ Click for more/u); + assert.doesNotMatch(shown(state), /Hidden markdown/u); + keys(state, 'z'); + assert.match(shown(state), /Hidden markdown paragraph/u); + assert.doesNotMatch(shown(state), /Body two/u); + assert.match(shown(state, 50), /Hidden markdown paragraph/u, 'a resize keeps the state'); + keys(state, 'z'); + assert.doesNotMatch(shown(state), /Hidden markdown/u); + keys(state, 'Z'); + assert.match(shown(state), /Hidden markdown paragraph/u); assert.match(shown(state), /Body two/u); + keys(state, 'Z'); + assert.doesNotMatch(shown(state), /Body two/u); + assert.equal(state.document.text, `${DOC}\n\n
\nSecond\n\nBody two.\n\n
\n`); +}); + +test('viewer: the state follows the summary text, so editing above does not lose it; the control row names z', () => { + const state = createViewer(doc(DOC)); + keys(state, 'z'); + state.document = doc(`New first paragraph.\n\nAnother.\n\n${DOC}`); + delete state.markdown; + assert.match(shown(state), /Hidden markdown paragraph/u); + assert.match(shown(state, 110), /z details/u); +}); + +// ---- Workspace modes -------------------------------------------------------------------------------------------- + +import {mkdtempSync, realpathSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {createDocumentSession, documentSessionKey, renderDocumentSession} from '../src/viewer/DocumentWorkspace.js'; +import {openForEdit} from '../src/viewer/fileEdit.js'; + +async function workspace() { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'nmsh-details-'))); + const path = join(root, 'd.md'); writeFileSync(path, `${DOC}\n`); + const opened = await openForEdit(path, '/'); + if (!opened.ok) throw new Error(opened.reason); + return {root, state: createDocumentSession(opened.file, 'source')}; +} +const press = (state: ReturnType, bytes: string, columns = 120, height = 24) => { for (const key of decodeKeys(bytes)) documentSessionKey(state, key, columns, height); }; +const draw = (state: ReturnType, columns = 120, height = 24) => renderDocumentSession(state, columns, height).map(row => stripAnsi(row)).join('\n'); + +test('preview, split and hybrid all show details; z works in the preview and in the split\'s preview pane; the text is never changed', async () => { + const { root, state } = await workspace(); + try { + press(state, 'h'); + assert.equal(state.mode, 'preview'); + assert.match(draw(state), /▸ Click for more/u); + press(state, 'z'); + assert.match(draw(state), /Hidden markdown paragraph/u); + press(state, 'z'); + assert.doesNotMatch(draw(state), /Hidden markdown/u); + press(state, '\u001b'); + press(state, 's'); + assert.equal(state.mode, 'split'); + assert.match(draw(state, 140), /▸ Click for more/u); + press(state, 'z', 140); + assert.match(draw(state, 140).split('\n').map(row => row.split('│').at(-1)!).join('\n'), /Hidden markdown paragraph/u); + press(state, '\u001b'); + press(state, 'H', 120); + assert.equal(state.mode, 'hybrid'); + state.editor.buffer.caret = {line: 0, column: 0}; + assert.match(draw(state), /Click for more/u, 'hybrid renders the block when the caret is elsewhere'); + state.editor.buffer.caret = {line: 3, column: 3}; + assert.match(draw(state), /Click for more<\/b><\/summary>/u, 'and shows its source under the caret'); + assert.equal(state.editor.buffer.text, `${DOC}\n`.replace(/\n$/u, '')); + } finally { rmSync(root, {recursive: true, force: true}); } +}); diff --git a/tests/viewHtml.test.ts b/tests/viewHtml.test.ts index 32e45f74..5c17263e 100644 --- a/tests/viewHtml.test.ts +++ b/tests/viewHtml.test.ts @@ -68,7 +68,7 @@ test('F01: an HTML document renders its structure: headings, paragraphs, nested

Quoted


  • done
  • open
-
More

Hidden text

+
More

Hidden text

`); const text = plain(rows); assert.ok(rows.some(row => row.kind === 'heading' && row.plain === 'Guide & notes')); @@ -81,7 +81,7 @@ test('F01: an HTML document renders its structure: headings, paragraphs, nested assert.ok(rows.some(row => row.kind === 'quote' && /Quoted/u.test(row.plain))); assert.ok(rows.some(row => row.kind === 'rule')); assert.match(text, /☑ done\n☐ open/u); - assert.match(text, /More\n*Hidden text/u); + assert.match(text, /More\n+\s*Hidden text/u, "an open
shows its content under the summary (a closed one shows only the summary: tests/details.test.ts)"); assert.doesNotMatch(text, /color:red||\bT\b\n/u, 'head and styles are not content'); }); From 65560980c5e4d5955607a63096aaf7c4cc467948 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Thu, 22 Oct 2026 03:01:29 +0530 Subject: [PATCH 2/3] Docs: details disclosure --- docs/design/document-workspace.md | 6 ++++++ docs/development/v024-ledger.md | 2 ++ docs/testing/v024-editor-physical-qa.md | 3 +++ 3 files changed, 11 insertions(+) diff --git a/docs/design/document-workspace.md b/docs/design/document-workspace.md index 93dbbecd..b1ea44a0 100644 --- a/docs/design/document-workspace.md +++ b/docs/design/document-workspace.md @@ -51,6 +51,12 @@ 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. + ## 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. diff --git a/docs/development/v024-ledger.md b/docs/development/v024-ledger.md index 9a46cf2d..2d9f2c18 100644 --- a/docs/development/v024-ledger.md +++ b/docs/development/v024-ledger.md @@ -44,6 +44,8 @@ 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. When #499 merges, `codeLines` in the diagnostics engine must also descend into `details` blocks (done on rebase). + ## 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 b3e783f6..ef15d067 100644 --- a/docs/testing/v024-editor-physical-qa.md +++ b/docs/testing/v024-editor-physical-qa.md @@ -21,6 +21,9 @@ 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. + ## 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. From d9b235a830b948ebb75be64f5acdbd1fe2b53381 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Thu, 22 Oct 2026 03:16:03 +0530 Subject: [PATCH 3/3] Details: one scan budget per parse for unclosed <details> --- src/markdown/blocks.ts | 5 +++++ tests/details.test.ts | 11 +++++++++++ 2 files changed, 16 insertions(+) diff --git a/src/markdown/blocks.ts b/src/markdown/blocks.ts index 2bc9a54d..3b77772b 100644 --- a/src/markdown/blocks.ts +++ b/src/markdown/blocks.ts @@ -152,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); } @@ -170,6 +171,9 @@ function htmlBlockKind(line: string): 1 | 2 | 6 | 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; /** @@ -183,6 +187,7 @@ function detailsAcross(lines: readonly string[], start: number, end: number): {c 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; diff --git a/tests/details.test.ts b/tests/details.test.ts index e477bca1..beb42c77 100644 --- a/tests/details.test.ts +++ b/tests/details.test.ts @@ -153,3 +153,14 @@ test('preview, split and hybrid all show details; z works in the preview and in assert.equal(state.editor.buffer.text, `${DOC}\n`.replace(/\n$/u, '')); } finally { rmSync(root, {recursive: true, force: true}); } }); + +test('many unclosed <details> cost a bounded amount in total (one scan budget per parse), and a normal document still pairs its tags', () => { + const lines: string[] = []; + for (let index = 0; index < 15_000; index += 1) lines.push('<details>', `<summary>s${index}</summary>`, ''); + const started = process.hrtime.bigint(); + const blocks = parseBlocks(lines.join('\n'), {html: true}); + assert.ok(Number(process.hrtime.bigint() - started) / 1e9 < 3, 'bounded'); + assert.ok(blocks.length > 0); + const paired = parseBlocks(['<details>', '<summary>a</summary>', '', 'x', '', '</details>', '', '<details>', '<summary>b</summary>', '', 'y', '', '</details>'].join('\n'), {html: true}); + assert.deepEqual(paired.map(block => block.kind), ['details', 'details']); +});