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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
"gfm",
"MEO"
],
"version": "0.1.61",
"version": "0.1.62",
"publisher": "mtskf",
"type": "module",
"engines": {
Expand Down
129 changes: 129 additions & 0 deletions src/webview/cm/fold/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<typeof syntaxTree>,
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;
Expand Down
51 changes: 48 additions & 3 deletions src/webview/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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({
Expand All @@ -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
Expand All @@ -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.
Expand All @@ -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);
Expand Down
114 changes: 114 additions & 0 deletions test/webview/editor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
});
});