From c41d91388159af8891d0b146e6795b31f8505044 Mon Sep 17 00:00:00 2001 From: Mitsuki Fukunaga Date: Wed, 29 Jul 2026 21:00:19 +1000 Subject: [PATCH 1/3] fix(reseed): clamp stale folds that swallow an inserted sibling heading The minimal-span reseed maps overlapping folds through an external change rather than dropping them (PR #292). The trade-off is that an external insert INTO a collapsed section can widen the mapped fold to swallow a newly-inserted sibling heading, hiding it until the user unfolds. After a content-changing reseed, applyDocument now reconciles native folds: reconcileReseedFolds clamps any fold that extends past its line's current foldable() section end AND whose excess span conceals a heading boundary back to the real foldable range (or unfolds it). foldable()'s sectionEnd already excludes legit child headings, and requiring a concealed heading leaves benign over-wide remaps untouched. Guarded on syntaxTreeAvailable; dispatched display-only (no changes), so the round-trip stays byte-identical. --- CHANGELOG.md | 6 +++ package.json | 2 +- src/webview/cm/fold/index.ts | 98 ++++++++++++++++++++++++++++++++++++ src/webview/editor.ts | 22 +++++++- test/webview/editor.test.ts | 34 +++++++++++++ 5 files changed, 160 insertions(+), 2 deletions(-) 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..5a7e7b64 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,99 @@ 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). The trade-off: an external insert INTO a collapsed section can WIDEN the + * mapped fold to swallow bytes it never meant to hide — 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. + * + * This walks every folded range and, for any that now extends PAST its own line's + * current `foldable()` section end AND whose excess span conceals a heading + * boundary, clamps it back to the real foldable range (or unfolds it when the line + * is no longer foldable at all). The two conditions together are precise: + * - `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 > canonicalTo`) 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. Exported for the reseed-reconciliation test. */ +export function reconcileReseedFolds( + state: EditorState +): 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); + // Canonical span end for this fold's own line: the real section/list end, or + // `from` (empty span) when the line is no longer foldable. + const canonicalTo = fresh ? fresh.to : from; + if (canonicalTo >= to) { + return; // fold not wider than its canonical span — nothing was swallowed. + } + if (!concealsHeadingBoundary(state, tree, canonicalTo, to)) { + return; // over-wide, but the excess hides no section boundary — leave it. + } + effects.push(unfoldEffect.of({ from, to })); + if (fresh) { + 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..4987335d 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"; @@ -930,6 +930,26 @@ 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. Clamp any + // such over-wide fold back to its real foldable range. Only meaningful when the + // reseed actually changed the document (insertText !== 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. + if (insertText !== null) { + const foldEffects = reconcileReseedFolds(view.state); + 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..85517ca2 100644 --- a/test/webview/editor.test.ts +++ b/test/webview/editor.test.ts @@ -1642,4 +1642,38 @@ 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); + }); }); From 585504d3b3a749cb9bde6f461e87ac919b5c51de Mon Sep 17 00:00:00 2001 From: Mitsuki Fukunaga Date: Wed, 29 Jul 2026 21:31:24 +1000 Subject: [PATCH 2/3] fix(reseed): release orphaned folds whose line is no longer foldable; pin child-heading clamp reconcileReseedFolds only clamped a stale fold back to its canonical range when the excess span concealed a heading boundary. A fold whose own heading line lost its marker entirely (fresh foldable() === null) had no canonical range to clamp to, but fell through the same concealment gate and could survive indefinitely with nothing left to show for it. Release that fold instead, gated on the reseed's edit actually touching the fold's own line (editedRange, threaded from computeReseedChange's post-change span) so an unrelated edit elsewhere in the document cannot spring open a fold that was never canonical to begin with (e.g. a hand-applied fold over a non-foldable paragraph). Also documents the reconcileReseedFolds contract accurately: it is exercised indirectly via editor.ts's applyDocument, not imported directly by any test. --- src/webview/cm/fold/index.ts | 68 +++++++++++++++++++++++------------- src/webview/editor.ts | 37 +++++++++++++------- test/webview/editor.test.ts | 51 +++++++++++++++++++++++++++ 3 files changed, 119 insertions(+), 37 deletions(-) diff --git a/src/webview/cm/fold/index.ts b/src/webview/cm/fold/index.ts index 5a7e7b64..e21bf221 100644 --- a/src/webview/cm/fold/index.ts +++ b/src/webview/cm/fold/index.ts @@ -921,22 +921,35 @@ function concealsHeadingBoundary( * 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). The trade-off: an external insert INTO a collapsed section can WIDEN the - * mapped fold to swallow bytes it never meant to hide — 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. + * policy). Two distinct problems can result, and this walks every folded range to + * fix both: * - * This walks every folded range and, for any that now extends PAST its own line's - * current `foldable()` section end AND whose excess span conceals a heading - * boundary, clamps it back to the real foldable range (or unfolds it when the line - * is no longer foldable at all). The two conditions together are precise: - * - `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 > canonicalTo`) 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. + * 1. Orphaned fold — the fold's OWN line is no longer `foldable()` at all (e.g. an + * external edit stripped its heading marker). 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 overlaps this + * fold's line. 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. Requiring the edit to touch the line is what makes this a targeted + * response to a change ON that line, 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 @@ -944,9 +957,11 @@ function concealsHeadingBoundary( * 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. Exported for the reseed-reconciliation test. */ + * 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 + state: EditorState, + editedRange: { from: number; to: number } ): StateEffect<{ from: number; to: number }>[] { if (!syntaxTreeAvailable(state, state.doc.length)) { return []; @@ -956,19 +971,22 @@ export function reconcileReseedFolds( foldedRanges(state).between(0, state.doc.length, (from, to) => { const line = state.doc.lineAt(from); const fresh = foldable(state, line.from, line.to); - // Canonical span end for this fold's own line: the real section/list end, or - // `from` (empty span) when the line is no longer foldable. - const canonicalTo = fresh ? fresh.to : from; - if (canonicalTo >= to) { + if (!fresh) { + // Orphaned — but only release when THIS line is what the reseed edited + // (touching test on possibly-zero-width ranges, both in post-change coords). + if (editedRange.from <= line.to && editedRange.to >= line.from) { + 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, canonicalTo, to)) { + 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 })); - if (fresh) { - effects.push(foldEffect.of(fresh)); // clamp back to the real foldable range. - } + effects.push(foldEffect.of(fresh)); // clamp back to the real foldable range. }); return effects; } diff --git a/src/webview/editor.ts b/src/webview/editor.ts index 4987335d..dc1cd369 100644 --- a/src/webview/editor.ts +++ b/src/webview/editor.ts @@ -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. @@ -933,16 +938,24 @@ export function mountEditor(opts: EditorOptions): EditorHandle { // 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. Clamp any - // such over-wide fold back to its real foldable range. Only meaningful when the - // reseed actually changed the document (insertText !== 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. - if (insertText !== null) { - const foldEffects = reconcileReseedFolds(view.state); + // 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 the reseed actually touched, per its JSDoc. + if (reseedChange !== null) { + 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)], diff --git a/test/webview/editor.test.ts b/test/webview/editor.test.ts index 85517ca2..c04b2107 100644 --- a/test/webview/editor.test.ts +++ b/test/webview/editor.test.ts @@ -1676,4 +1676,55 @@ describe("editor — external reseed preserves unrelated folds (r)", () => { 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 + }); }); From b31ae31f825d714f842e6a893e565fa5dac62df9 Mon Sep 17 00:00:00 2001 From: Mitsuki Fukunaga Date: Wed, 29 Jul 2026 22:01:52 +1000 Subject: [PATCH 3/3] fix(reseed): release orphaned folds when the reseed deletes the section body --- src/webview/cm/fold/index.ts | 37 ++++++++++++++++++++++++------------ src/webview/editor.ts | 16 ++++++++++++++-- test/webview/editor.test.ts | 29 ++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 14 deletions(-) diff --git a/src/webview/cm/fold/index.ts b/src/webview/cm/fold/index.ts index e21bf221..ceed49f4 100644 --- a/src/webview/cm/fold/index.ts +++ b/src/webview/cm/fold/index.ts @@ -925,15 +925,20 @@ function concealsHeadingBoundary( * 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). 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 overlaps this - * fold's line. 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. Requiring the edit to touch the line is what makes this a targeted - * response to a change ON that line, not a spurious spring-open. + * 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 @@ -972,9 +977,17 @@ export function reconcileReseedFolds( const line = state.doc.lineAt(from); const fresh = foldable(state, line.from, line.to); if (!fresh) { - // Orphaned — but only release when THIS line is what the reseed edited - // (touching test on possibly-zero-width ranges, both in post-change coords). - if (editedRange.from <= line.to && editedRange.to >= line.from) { + // 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; diff --git a/src/webview/editor.ts b/src/webview/editor.ts index dc1cd369..78d2b46e 100644 --- a/src/webview/editor.ts +++ b/src/webview/editor.ts @@ -950,8 +950,20 @@ export function mountEditor(opts: EditorOptions): EditorHandle { // 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 the reseed actually touched, per its JSDoc. - if (reseedChange !== null) { + // 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, diff --git a/test/webview/editor.test.ts b/test/webview/editor.test.ts index c04b2107..1e080ffe 100644 --- a/test/webview/editor.test.ts +++ b/test/webview/editor.test.ts @@ -1727,4 +1727,33 @@ describe("editor — external reseed preserves unrelated folds (r)", () => { 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 + }); });