diff --git a/CHANGELOG.md b/CHANGELOG.md index f6c37b43..90eefcbc 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 +- **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. - **`/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). diff --git a/docs/design/document-workspace.md b/docs/design/document-workspace.md index 4c7de6d3..48f9b68b 100644 --- a/docs/design/document-workspace.md +++ b/docs/design/document-workspace.md @@ -45,6 +45,8 @@ the preview always show what is in the buffer, including unsaved edits, and noth **Tables** (`src/viewer/markdownTable.ts`, slice 2). A table is a header row followed by a delimiter row with the same number of cells (cells split on unescaped pipes, outer pipes optional), through the next blank line; fences are excluded. Tab / Shift+Tab select the neighbouring cell's text (an empty cell puts the caret in its padding; the delimiter row is skipped; at the first or last cell the caret stays and the status says why). F1 in a table adds **r** row below (shaped like the header), **d** delete row (body rows only), **o** column right (each row's new cell mirrors its neighbour's width; a table without trailing pipes gets them on that edit), **x** delete column (never the last), **a** cycle alignment none → left → centre → right (a dash becomes a colon, so the column keeps its width). The editor always shows the source, so there is no separate narrow-terminal rendering: the status line carries the row and column. Not provided: re-padding a table to align its pipes, moving rows, tables inside block quotes or lists, pipes inside HTML. +**Footnote renumbering** (palette entry *Renumber footnotes*): references `[^n]` outside fences, code spans and escapes are numbered 1, 2, … by first appearance; a definition line (`[^n]:` at the start of a line, optionally quoted/indented) takes its footnote's new number; named labels are untouched; an unreferenced numbered definition keeps its label unless a renumbered footnote now owns it, then it takes the next free number. Only the label text changes (one edit over the changed lines, caret and selection mapped). + Limits: code fences are recognised by a line scan (indented code blocks and fences inside block quotes are not); nesting is by indentation columns with tabs as four; HTML blocks are not parsed. Table editing and hunk-by-hunk review of external changes are separate slices of #463. **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. diff --git a/docs/development/v024-ledger.md b/docs/development/v024-ledger.md index 93c2cdde..12928528 100644 --- a/docs/development/v024-ledger.md +++ b/docs/development/v024-ledger.md @@ -41,6 +41,8 @@ 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. +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 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/src/viewer/EditorPalette.ts b/src/viewer/EditorPalette.ts index 320d2d86..f3e12347 100644 --- a/src/viewer/EditorPalette.ts +++ b/src/viewer/EditorPalette.ts @@ -23,6 +23,7 @@ const MARKDOWN_ACTIONS: ReadonlyArray<[action: string, title: string, descriptio ['Action.Code', 'Inline code', 'Wrap or unwrap the selection in backticks', 'code inline backtick wrap'], ['Action.Strike', 'Strikethrough', 'Wrap or unwrap the selection in ~~', 'strike strikethrough wrap'], ['Action.Footnote', 'Footnote', 'A numbered reference here and its definition at the end', 'footnote reference note'], + ['Action.FootnoteRenumber', 'Renumber footnotes', 'Number the footnotes 1, 2, 3… in the order they are referenced', 'footnote footnotes renumber number order'], ['Action.Headings', 'Go to heading…', 'Search the document\'s headings and jump to one', 'heading outline navigate jump toc contents'], ['Action.Back', 'Back to previous position', 'Return to where you were before the last jump', 'back return previous jump'], ]; diff --git a/src/viewer/EditorPanel.ts b/src/viewer/EditorPanel.ts index 4b1390e9..05daf02c 100644 --- a/src/viewer/EditorPanel.ts +++ b/src/viewer/EditorPanel.ts @@ -13,7 +13,7 @@ import {reviewKey, renderReview, type ReviewState} from './ReviewPanel.js'; import {paletteEntries, type PaletteEntry, type PaletteFormat} from './EditorPalette.js'; import {DocumentIndex, SourceAnchor, type HeadingEntry} from '../markdown/sourceMap.js'; import {advanceSnippet, insertFootnote, insertSnippet, insideSnippet, type SnippetSession} from './snippets.js'; -import {continueList, insertLink, renumberList, shiftListItems, toggleTask, wrapSelection, type EditResult, type WrapKind} from './markdownEdit.js'; +import {continueList, insertLink, renumberFootnotes, renumberList, shiftListItems, toggleTask, wrapSelection, type EditResult, type WrapKind} from './markdownEdit.js'; import type {EditableFile, SaveConflict} from './fileEdit.js'; import {displayColumn, TAB_WIDTH, TextBuffer, type Position} from './textBuffer.js'; import {describeDifference, type DraftOffer} from './draftRecovery.js'; @@ -181,6 +181,7 @@ function runPaletteEntry(state: EditorState, entry: PaletteEntry): void { endSnippet(state); if (entry.run.kind === 'snippet') { state.snippet = insertSnippet(state.buffer, entry.run.snippet); return; } if (entry.run.action === 'Action.Footnote') { state.snippet = insertFootnote(state.buffer); return; } + if (entry.run.action === 'Action.FootnoteRenumber') { const result = renumberFootnotes(state.buffer); if (result.note) state.note = {text: result.note, tone: 'info'}; return; } if (entry.run.action === 'Action.Headings') { state.dialog = {kind: 'outline', focus: 0, query: ''}; return; } if (entry.run.action === 'Action.Back') { jumpBack(state); return; } const result = runMarkdownAction(state, entry.run.action); diff --git a/src/viewer/markdownEdit.ts b/src/viewer/markdownEdit.ts index 95c99ffc..d5aa9d40 100644 --- a/src/viewer/markdownEdit.ts +++ b/src/viewer/markdownEdit.ts @@ -407,3 +407,76 @@ export function wrapSelection(buffer: TextBuffer, kind: WrapKind): EditResult { replaceLines(buffer, start.line, last, out, position => (position === buffer.caret ? caretAfter : anchorAfter)); return {changed: true}; } + +// ---- Footnotes -------------------------------------------------------------------------------------------------- + +const DEFINITION = /^((?:[ ]{0,3}>[ ]?)*[ ]{0,3})\[\^([^\]\s]+)\]:/u; + +/** Spans of inline code on a line, blanked with spaces so a reference inside them is not seen. */ +function maskCode(line: string): string { + return line.replace(/(`+)[\s\S]*?\1/gu, span => ' '.repeat(span.length)); +} + +/** + * Renumber the numbered footnotes 1, 2, 3… in the order of their first reference, definitions following their references. Named + * footnotes, escaped references, code spans and fenced code are left alone; a definition nobody references keeps its number + * unless a renumbered footnote needs it, and is then given the next free one. One undo step; only the labels change. + */ +export function renumberFootnotes(buffer: TextBuffer): EditResult { + const lines = buffer.lines; + const fenced = fenceMap(lines, lines.length - 1); + const numeric = (label: string) => /^\d+$/u.test(label); + interface Token {line: number; start: number; end: number; label: string} + const references: Token[] = [], definitions: Token[] = []; + lines.forEach((text, line) => { + if (fenced[line]) return; + const masked = maskCode(text); + const definition = DEFINITION.exec(masked); + let from = 0; + if (definition) { + const start = definition[1]!.length + 2; + definitions.push({line, start, end: start + definition[2]!.length, label: definition[2]!}); + from = definition[0].length; + } + for (const match of masked.slice(from).matchAll(/(?(); + let next = 1; + for (const reference of references) if (numeric(reference.label) && !renamed.has(reference.label)) renamed.set(reference.label, String(next++)); + const used = new Set(renamed.values()); + const referenced = new Set(references.map(reference => reference.label)); + const taken = new Set([...used, ...definitions.filter(d => !numeric(d.label) || !referenced.has(d.label)).map(d => d.label).filter(numeric)]); + for (const definition of definitions) { + if (!numeric(definition.label) || renamed.has(definition.label)) continue; + if (!used.has(definition.label)) continue; + let free = 1; + while (taken.has(String(free)) || used.has(String(free))) free += 1; + renamed.set(definition.label, String(free)); taken.add(String(free)); + } + const changes = new Map>(); + for (const token of [...references, ...definitions]) { + const to = numeric(token.label) ? renamed.get(token.label) : undefined; + if (to === undefined || to === token.label) continue; + const list = changes.get(token.line) ?? []; + list.push({...token, to}); changes.set(token.line, list); + } + if (!changes.size) return {changed: false, note: 'The footnotes are already numbered in order.'}; + const first = Math.min(...changes.keys()), last = Math.max(...changes.keys()); + const out = lines.slice(first, last + 1); + for (const [line, list] of changes) { + let text = lines[line]!; + for (const token of [...list].sort((a, b) => b.start - a.start)) text = text.slice(0, token.start) + token.to + text.slice(token.end); + out[line - first] = text; + } + replaceLines(buffer, first, last, out, position => { + const list = changes.get(position.line); + if (!list) return position; + let column = position.column; + for (const token of list) if (position.column >= token.end) column += token.to.length - token.label.length; + return {line: position.line, column}; + }); + return {changed: true}; +} diff --git a/tests/footnotes.test.ts b/tests/footnotes.test.ts new file mode 100644 index 00000000..a5554904 --- /dev/null +++ b/tests/footnotes.test.ts @@ -0,0 +1,62 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {TextBuffer} from '../src/viewer/textBuffer.js'; +import {renumberFootnotes} from '../src/viewer/markdownEdit.js'; +import {paletteEntries} from '../src/viewer/EditorPalette.js'; + +const buf = (lines: string[], caret = {line: 0, column: 0}) => { const buffer = new TextBuffer(lines, () => 0); buffer.caret = caret; return buffer; }; + +test('footnotes are numbered 1, 2, 3… in the order of their first reference; each definition follows its reference', () => { + const b = buf(['Second[^7] then first[^3] and again[^7].', '', '[^3]: three', '[^7]: seven']); + const result = renumberFootnotes(b); + assert.equal(result.changed, true); + assert.deepEqual(b.lines, ['Second[^1] then first[^2] and again[^1].', '', '[^2]: three', '[^1]: seven']); +}); + +test('already in order: nothing changes and the user is told; one undo step otherwise, caret and selection kept', () => { + const ok = buf(['a[^1] b[^2]', '', '[^1]: x', '[^2]: y']); + assert.deepEqual(renumberFootnotes(ok), {changed: false, note: 'The footnotes are already numbered in order.'}); + const b = buf(['b[^2] a[^1]', '', '[^1]: x', '[^2]: y'], {line: 0, column: 11}); + renumberFootnotes(b); + assert.deepEqual(b.lines, ['b[^1] a[^2]', '', '[^2]: x', '[^1]: y']); + assert.deepEqual(b.caret, {line: 0, column: 11}); + assert.equal(b.undo(), true); + assert.deepEqual(b.lines, ['b[^2] a[^1]', '', '[^1]: x', '[^2]: y']); + assert.equal(b.undo(), false); +}); + +test('named footnotes keep their names and take no number; escaped references, code spans and fenced code are not references', () => { + const b = buf(['x[^note] y[^5] z `[^9]` \\[^8] w[^2]', '```', '[^4] in code', '```', '', '[^note]: named', '[^5]: five', '[^2]: two']); + renumberFootnotes(b); + assert.deepEqual(b.lines, ['x[^note] y[^1] z `[^9]` \\[^8] w[^2]', '```', '[^4] in code', '```', '', '[^note]: named', '[^1]: five', '[^2]: two']); +}); + +test('definitions with no reference keep their number unless a renumbered footnote now needs it; multi-line definitions keep their body', () => { + const b = buf(['see[^2] and[^1]', '', '[^1]: first', ' continued line', '[^2]: second', '[^9]: orphan']); + renumberFootnotes(b); + assert.deepEqual(b.lines, ['see[^1] and[^2]', '', '[^2]: first', ' continued line', '[^1]: second', '[^9]: orphan']); + const clash = buf(['a[^2]', '', '[^1]: orphan', '[^2]: used']); + renumberFootnotes(clash); + assert.equal(clash.lines[0], 'a[^1]'); + assert.equal(clash.lines[3], '[^1]: used'); + assert.notEqual(clash.lines[2], '[^1]: orphan', 'the orphan was moved out of the way, never merged into the used footnote'); + assert.match(clash.lines[2]!, /^\[\^\d+\]: orphan$/u); +}); + +test('Unicode, quote and list prefixes, CRLF-free lines and very long documents', () => { + const b = buf(['> 日本語[^4] 😀[^2]', '- item[^3]', '', '[^2]: b', '[^3]: c', '[^4]: a']); + renumberFootnotes(b); + assert.deepEqual(b.lines, ['> 日本語[^1] 😀[^2]', '- item[^3]', '', '[^2]: b', '[^3]: c', '[^1]: a']); + const many = Array.from({length: 3000}, (_, index) => `ref[^${3000 - index}]`); + const defs = Array.from({length: 3000}, (_, index) => `[^${index + 1}]: d${index + 1}`); + const big = buf([...many, '', ...defs]); + const started = process.hrtime.bigint(); + renumberFootnotes(big); + assert.ok(Number(process.hrtime.bigint() - started) / 1e9 < 2); + assert.equal(big.lines[0], 'ref[^1]'); + assert.equal(big.lines[3001], '[^3000]: d1'); +}); + +test('the palette offers it', () => { + assert.ok(paletteEntries('markdown', 'renumber footnotes').some(entry => entry.run.kind === 'action' && entry.run.action === 'Action.FootnoteRenumber')); +});