diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cbcfa91..67a01430 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to Quoll are documented here. +## 0.1.62 — 2026-07-29 + +### Fixed + +- A collapsed section no longer hides a heading that an external change (a formatter, git, or another editor) inserts inside it. The fold now shrinks back to its own section, so the newly-added section stays visible instead of being concealed until you unfold. + ## 0.1.61 — 2026-07-29 ### Fixed diff --git a/package.json b/package.json index 144bafc8..2d6abed7 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "gfm", "MEO" ], - "version": "0.1.61", + "version": "0.1.62", "publisher": "mtskf", "type": "module", "engines": { diff --git a/src/webview/cm/fold/index.ts b/src/webview/cm/fold/index.ts index eae89a35..ceed49f4 100644 --- a/src/webview/cm/fold/index.ts +++ b/src/webview/cm/fold/index.ts @@ -50,17 +50,22 @@ import { codeFolding, foldAll, + foldable, foldCode, + foldEffect, + foldedRanges, foldGutter, syntaxTree, syntaxTreeAvailable, unfoldAll, unfoldCode, + unfoldEffect, } from "@codemirror/language"; import { type EditorState, type Extension, type RangeSet, + type StateEffect, StateField, type Transaction, } from "@codemirror/state"; @@ -875,6 +880,130 @@ export const quollFoldKeymap: readonly KeyBinding[] = [ * the table exists). */ export const quollFoldKeymapExtension: Extension = keymap.of(quollFoldKeymap); +// ATX (`# …`) or Setext (`…\n===`) heading node of ANY level 1–6 — the fold- +// boundary constructs whose appearance INSIDE a mapped fold means a section was +// split. Distinct from HEADING_NODE above (H1–H3 only, gutter row-scale). +const ANY_HEADING_NODE = /^(?:ATXHeading|SetextHeading)[1-6]$/; + +/** True when a heading LINE starts strictly inside `(lo, hi)`. Used to decide + * whether the excess span of an over-wide mapped fold conceals a real section + * boundary (see {@link reconcileReseedFolds}). Bounded iterate over the excess + * span only — reseed is not a hot path, but this still avoids a whole-tree walk. */ +function concealsHeadingBoundary( + state: EditorState, + tree: ReturnType, + lo: number, + hi: number +): boolean { + let found = false; + tree.iterate({ + from: lo, + to: hi, + enter: (node) => { + if (found) { + return false; + } + if (!ANY_HEADING_NODE.test(node.name)) { + return undefined; + } + const lineStart = state.doc.lineAt(node.from).from; + if (lineStart > lo && lineStart < hi) { + found = true; + return false; + } + return undefined; + }, + }); + return found; +} + +/** Reconcile native fold ranges after an external reseed has REMAPPED them through + * a minimal-span change (editor.ts#applyDocument → computeReseedChange). That diff + * deliberately maps overlapping folds THROUGH the change rather than dropping them + * (so an unrelated external touch never springs every fold open — the PR #292 + * policy). Two distinct problems can result, and this walks every folded range to + * fix both: + * + * 1. Orphaned fold — the fold's OWN line is no longer `foldable()` at all (e.g. an + * external edit stripped its heading marker, or deleted the whole section body so + * an empty heading no longer folds). There is no canonical range left to clamp + * back to, so the fold is released — but ONLY when `editedRange` (the reseed's + * edit span, in POST-change coordinates) actually touched this fold: either its + * heading LINE or its collapsed SPAN. Without that gate, a fold that was NEVER + * canonical to begin with (e.g. hand-applied over a plain paragraph, which + * `foldable()` always excludes — see the module header) would get sprung open by + * any unrelated edit anywhere else in the document, violating the PR #292 policy + * this whole function exists to uphold. The SPAN test is what catches a body + * delete: deleting a folded section's entire body trims the heading + its newline + * into the change's common prefix, so the edited span starts BELOW the heading + * line (the line test alone would miss it) yet still overlaps the collapsed span. + * Requiring the edit to touch the fold's line OR span is what makes this a + * targeted response to a change ON that fold, not a spurious spring-open. + * 2. Over-wide fold — the line is still foldable, but the mapped fold now extends + * PAST its current `foldable()` section end AND the excess span conceals a + * heading boundary (most visibly a same-level sibling heading dropped inside a + * folded section — a formatter / git inserting `# New` into `# One`'s body, + * which then stays concealed until the user unfolds). Clamp back to the real + * foldable range instead. Unlike case 1, this is NOT gated on `editedRange` + * touching the fold's own heading line — the inserted sibling lands inside the + * section BODY, not on the heading line itself. The two conditions together are + * already precise enough: + * - `foldable()`'s `sectionEnd` already stops at the first same-or-higher + * heading, so a legitimately-nested CHILD heading stays INSIDE the canonical + * span and is never treated as concealed — only a genuine sibling/ancestor + * boundary that landed in the fold via the remap trips the check; + * - requiring a concealed heading (not merely `to > fresh.to`) leaves benign + * over-wide remaps untouched — e.g. a hand/legacy fold whose end sits one + * char past the canonical section end, or a fold whose excess is plain prose. + * + * Guarded on `syntaxTreeAvailable`: `foldable()` and the heading walk read the + * syntax tree, and an incomplete post-reseed parse frontier would mis-report a + * section end and could clamp WRONG. When the tree is not yet complete we return no + * effects — the mapped fold survives exactly as before (the pre-existing accepted + * behaviour), never a bad clamp. Returns the fold/unfold effects to dispatch (empty + * when nothing needs reconciling). View-layer only — no document change, so the + * round-trip stays byte-identical. Pinned indirectly by editor.test.ts's (r7)–(r9) + * reseed tests (via applyDocument), not a direct import here. */ +export function reconcileReseedFolds( + state: EditorState, + editedRange: { from: number; to: number } +): StateEffect<{ from: number; to: number }>[] { + if (!syntaxTreeAvailable(state, state.doc.length)) { + return []; + } + const tree = syntaxTree(state); + const effects: StateEffect<{ from: number; to: number }>[] = []; + foldedRanges(state).between(0, state.doc.length, (from, to) => { + const line = state.doc.lineAt(from); + const fresh = foldable(state, line.from, line.to); + if (!fresh) { + // Orphaned — release only when the reseed's edit actually touched THIS fold: + // either its heading LINE (marker stripped) OR its SPAN (body deleted). Both + // overlap tests are in POST-change coords on possibly-zero-width ranges. + // Requiring the edit to touch the fold (not fire on unrelated edits elsewhere) + // preserves the PR #292 never-spring-unrelated-folds policy, while catching the + // body-delete case the line-only gate missed: a full-body deletion trims the + // heading + its newline into the common prefix, so the edited span starts BELOW + // the heading line (touchesLine false) yet still overlaps the collapsed span. + const touchesLine = editedRange.from <= line.to && editedRange.to >= line.from; + const touchesFold = editedRange.from <= to && editedRange.to >= from; + if (touchesLine || touchesFold) { + effects.push(unfoldEffect.of({ from, to })); + } + return; + } + if (fresh.to >= to) { + return; // fold not wider than its canonical span — nothing was swallowed. + } + if (!concealsHeadingBoundary(state, tree, fresh.to, to)) { + return; // over-wide, but the excess hides no section boundary — leave it. + } + effects.push(unfoldEffect.of({ from, to })); + effects.push(foldEffect.of(fresh)); // clamp back to the real foldable range. + }); + return effects; +} + /** The Quoll fold extension. Always on (no setting) — chevrons appear only on * foldable lines. `headingFoldGutterLineClass` tags H1–H3 gutter lines so the * theme can cap their chevron at the correct (taller) row height; diff --git a/src/webview/editor.ts b/src/webview/editor.ts index 959536b2..78d2b46e 100644 --- a/src/webview/editor.ts +++ b/src/webview/editor.ts @@ -35,7 +35,7 @@ import { fencedCodeEnterKeymap } from "./cm/fenced-code/fenced-code-enter-keymap import { quollCodeHighlighting } from "./cm/fenced-code/fenced-code-highlight-languages.js"; import { fencedCodeLanguagePicker } from "./cm/fenced-code/fenced-code-language-picker.js"; import { quollFloatingToolbarScroll } from "./cm/floating-toolbar-scroll.js"; -import { quollFolding } from "./cm/fold/index.js"; +import { quollFolding, reconcileReseedFolds } from "./cm/fold/index.js"; import { runFormatDocument } from "./cm/format/format-document-command.js"; import { frontmatterBlockField, frontmatterRevealKeymap } from "./cm/frontmatter/index.js"; import { hostDocumentReseed } from "./cm/host-reseed.js"; @@ -881,6 +881,11 @@ export function mountEditor(opts: EditorOptions): EditorHandle { const insertText = needsReseed ? splitToCmText(rawText) : null; const newDocLength = insertText !== null ? insertText.length : view.state.doc.length; const prevMain = prevSelection?.main; + // Computed BEFORE dispatch (needs the PRE-change view.state.doc — see the + // helper's CRLF note). Reused below, post-dispatch, to derive the edited + // span in POST-change coordinates for reconcileReseedFolds's orphan gate. + const reseedChange = + insertText !== null ? computeReseedChange(view.state.doc, insertText) : null; seeding = true; try { view.dispatch({ @@ -896,7 +901,7 @@ export function mountEditor(opts: EditorOptions): EditorHandle { EditorState.readOnly.of(!canWrite), ]), ], - ...(insertText !== null + ...(reseedChange !== null ? { // Minimal single-span change (not a wholesale {0, doc.length} // replace): CM maps every foldState range through the change, and @@ -906,7 +911,7 @@ export function mountEditor(opts: EditorOptions): EditorHandle { // Operands are CM Text in LF-internal coords (view.state.doc, not // the sliceDoc() render) — see the helper's CRLF note. insertText // stays the pre-split snapshot Text (also feeds newDocLength). - changes: computeReseedChange(view.state.doc, insertText), + changes: reseedChange, } : {}), // Restore ONLY the main range, clamped to new doc bounds. @@ -930,6 +935,46 @@ export function mountEditor(opts: EditorOptions): EditorHandle { } sync.onHostSnapshot(baseDocVersion, canWrite, externalEpoch, epochGeneration); setReadOnlyClass(canWrite); + // Reconcile native folds that the minimal-span reseed REMAPPED. The diff maps + // overlapping folds through the change (preserving them — PR #292), but an + // external insert INTO a collapsed section can widen a mapped fold to swallow a + // newly-inserted sibling heading, hiding it until the user unfolds; and an edit + // that strips a folded heading's OWN marker can orphan its fold entirely (no + // canonical range left to clamp to). Only meaningful when the reseed actually + // changed the document (reseedChange !== null). This is a SEPARATE, display-only + // dispatch carrying NO `changes` — byte-identical round-trip, and a no-op for the + // updateListener's `docChanged`-gated edit-sync — so it behaves exactly like a + // user fold/unfold (hence NOT annotated as a hostDocumentReseed; it rewrites no + // document bytes). addToHistory.of(false) keeps the clamp out of undo. + // reconcileReseedFolds self-guards on a complete parse tree. The edited-span + // argument is in POST-change coordinates (reseedChange.from is unaffected by its + // own edit; the edit's far end shifts to reseedChange.from + insert.length) — + // reconcileReseedFolds uses it to gate the orphan-release path to folds whose OWN + // line OR span the reseed actually touched, per its JSDoc. + // + // Skip an EMPTY change (from === to AND no insert): computeReseedChange yields + // that iff the two docs are content-identical (a no-op reseed — e.g. EOL-only + // normalisation), so the fold structure is unchanged and there is nothing to + // reconcile. Skipping is also load-bearing, not just an optimisation: the + // orphan gate's span test is boundary-inclusive, so a zero-width edited span + // sitting on a fold's boundary would otherwise (mis)count as touching it and + // spring a still-valid fold open. Bailing on the empty change keeps the no-op + // reseed a true no-op for folds (pinned by (r5)). + if ( + reseedChange !== null && + (reseedChange.from !== reseedChange.to || reseedChange.insert.length > 0) + ) { + const foldEffects = reconcileReseedFolds(view.state, { + from: reseedChange.from, + to: reseedChange.from + reseedChange.insert.length, + }); + if (foldEffects.length > 0) { + view.dispatch({ + annotations: [Transaction.addToHistory.of(false)], + effects: foldEffects, + }); + } + } }, isIdentityTransition(externalEpoch, epochGeneration) { return sync.isIdentityTransition(externalEpoch, epochGeneration); diff --git a/test/webview/editor.test.ts b/test/webview/editor.test.ts index 41573475..1e080ffe 100644 --- a/test/webview/editor.test.ts +++ b/test/webview/editor.test.ts @@ -1642,4 +1642,118 @@ describe("editor — external reseed preserves unrelated folds (r)", () => { // |delta| (the edit is inside, before the fold end). expect(foldRanges(view)).toEqual([{ from: foldFrom, to: foldTo + delta }]); }); + + it("(r7) a reseed that inserts a sibling heading INTO a folded section clamps the stale fold so the new heading is not hidden", () => { + const { handle, view } = mount(); + const doc = "# One\n\nalpha\nbravo\n\n# Two\n\ncharlie"; + handle.applyDocument(doc, true, 1); + // Fold "# One" at its CANONICAL range (what foldCode/the gutter would produce), + // so the fold matches foldable() exactly BEFORE the reseed remaps it. + ensureSyntaxTree(view.state, view.state.doc.length, 5000); + const line1 = view.state.doc.line(1); // "# One" + const canonical = foldable(view.state, line1.from, line1.to); + if (!canonical) { + throw new Error("heading line should be foldable"); + } + view.dispatch({ effects: foldEffect.of(canonical) }); + expect(foldedCount(view)).toBe(1); + + // External reseed drops a same-level sibling heading INTO "# One"'s body (a + // formatter / git inserting a new section). The minimal-span diff maps the + // overlapping fold THROUGH the insert, so without reconciliation it widens to + // swallow "# New" — hiding the new section behind the stale fold. + const next = doc.replace("alpha", "alpha\n\n# New\n\ngamma"); + handle.applyDocument(next, true, 2); + expect(view.state.sliceDoc()).toBe(next); + + // The section is still collapsed (the fold is preserved, not sprung open)... + expect(foldedCount(view)).toBe(1); + // ...but clamped back to "# One"'s real section end, so "# New" is NOT concealed. + const newHeadingPos = next.indexOf("# New"); + const [range] = foldRanges(view); + expect(range.to).toBeLessThanOrEqual(newHeadingPos); + // And the clamp lands exactly on the current foldable range for "# One". + const freshLine1 = view.state.doc.line(1); + expect(foldable(view.state, freshLine1.from, freshLine1.to)).toEqual(range); + }); + + it("(r8) a reseed that removes the heading marker on a folded line fully unfolds it (no clamp target)", () => { + const { handle, view } = mount(); + const doc = "# One\n\nalpha\nbravo\n\n# Two\n\ncharlie"; + handle.applyDocument(doc, true, 1); + ensureSyntaxTree(view.state, view.state.doc.length, 5000); + const line1 = view.state.doc.line(1); + const canonical = foldable(view.state, line1.from, line1.to); + if (!canonical) { + throw new Error("heading line should be foldable"); + } + view.dispatch({ effects: foldEffect.of(canonical) }); + expect(foldedCount(view)).toBe(1); + + // External reseed strips the ATX marker: "# One" -> "One" (no longer a heading, + // so line 1 is no longer foldable at all). + const next = doc.replace("# One", "One"); + handle.applyDocument(next, true, 2); + + expect(view.state.sliceDoc()).toBe(next); + expect(foldedCount(view)).toBe(0); // fully released, not clamped to an empty range + }); + + it("(r9) clamping a fold that contains a nested child heading keeps the child but reveals an inserted sibling", () => { + const { handle, view } = mount(); + const doc = "# One\n\n## Sub\n\nfoo\n\n# Two\n\nbar"; + handle.applyDocument(doc, true, 1); + ensureSyntaxTree(view.state, view.state.doc.length, 5000); + const line1 = view.state.doc.line(1); + const canonical = foldable(view.state, line1.from, line1.to); // includes "## Sub" + if (!canonical) { + throw new Error("heading line should be foldable"); + } + view.dispatch({ effects: foldEffect.of(canonical) }); + expect(foldedCount(view)).toBe(1); + + // External reseed inserts a same-level SIBLING "# New" after the child section, + // INTO One's folded body. Reconciliation must clamp so "# New" is revealed, + // while the child "## Sub" stays inside the fold. + const next = doc.replace("foo", "foo\n\n# New\n\ngamma"); + handle.applyDocument(next, true, 2); + expect(view.state.sliceDoc()).toBe(next); + + expect(foldedCount(view)).toBe(1); + const newPos = next.indexOf("# New"); + const subPos = next.indexOf("## Sub"); + const [range] = foldRanges(view); + expect(range.to).toBeLessThanOrEqual(newPos); // "# New" NOT concealed + expect(range.from).toBeLessThanOrEqual(subPos); + expect(range.to).toBeGreaterThan(subPos); // "## Sub" (child) STILL inside the fold + }); + + it("(r10) a reseed that deletes a folded section's BODY (heading kept) releases the orphaned fold", () => { + const { handle, view } = mount(); + // "# One" is the LAST section; folding it then deleting its ENTIRE body leaves + // the heading with no foldable content (foldable() → null), so the mapped fold + // is ORPHANED. The reseed's common prefix swallows the heading line + its + // trailing newline, so the edit span starts BELOW the heading line — exactly the + // body-delete case the line-only orphan gate missed, which left a phantom fold + // pill on a heading that no longer folds. + const doc = "intro\n\n# One\n\nalpha\nbravo"; + handle.applyDocument(doc, true, 1); + ensureSyntaxTree(view.state, view.state.doc.length, 5000); + const line = view.state.doc.line(3); // "# One" + const canonical = foldable(view.state, line.from, line.to); + if (!canonical) { + throw new Error("heading line should be foldable"); + } + view.dispatch({ effects: foldEffect.of(canonical) }); + expect(foldedCount(view)).toBe(1); + + // External reseed deletes the whole body of "# One" (heading kept). The + // minimal-span diff is a pure deletion whose edited span starts after the + // heading line, so the line-only gate never sees the touch. + const next = "intro\n\n# One\n"; + handle.applyDocument(next, true, 2); + + expect(view.state.sliceDoc()).toBe(next); + expect(foldedCount(view)).toBe(0); // orphaned fold released, no phantom pill + }); });