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

- **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).
Expand Down
2 changes: 2 additions & 0 deletions docs/design/document-workspace.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions docs/development/v024-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
1 change: 1 addition & 0 deletions src/viewer/EditorPalette.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
];
Expand Down
3 changes: 2 additions & 1 deletion src/viewer/EditorPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
73 changes: 73 additions & 0 deletions src/viewer/markdownEdit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(/(?<!\\)\[\^([^\]\s]+)\]/gu)) {
const start = from + match.index! + 2;
references.push({line, start, end: start + match[1]!.length, label: match[1]!});
}
});
const renamed = new Map<string, string>();
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<number, Array<Token & {to: string}>>();
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};
}
62 changes: 62 additions & 0 deletions tests/footnotes.test.ts
Original file line number Diff line number Diff line change
@@ -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'));
});
Loading