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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
190 changes: 190 additions & 0 deletions src/__tests__/foldableHeadings.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
/**
* foldableHeadings.test.ts
*
* Unit + integration coverage for collapsible heading sections.
*
* Unit — `headingFoldRange` (the pure range computation off the lezer tree):
* - a `##` folds until the next `##` or `#`
* - a nested `###` folds independently (stops at the next `###`/`##`/`#`)
* - the last heading folds to EOF
* - a heading with no body folds nothing (null)
* - a non-heading line folds nothing (null)
*
* Integration — mount an EditorView with the folding extension:
* - folding a heading hides the body lines (foldedRanges covers them) and
* leaves the document text byte-for-byte unchanged
* - unfolding restores (no folded ranges, text still unchanged)
* - folding coexists with the markdown live-preview decorations (no crash,
* content intact)
*
* idb-keyval is mocked because pulling in CodeMirrorEditor (for the live
* preview integration check) transitively imports persist-backed stores.
*/

jest.mock('idb-keyval', () => ({
get: jest.fn().mockResolvedValue(undefined),
set: jest.fn().mockResolvedValue(undefined),
del: jest.fn().mockResolvedValue(undefined),
}))

import { EditorState } from '@codemirror/state'
import { EditorView } from '@codemirror/view'
import { markdown, markdownLanguage } from '@codemirror/lang-markdown'
import { foldCode, unfoldCode, foldedRanges } from '@codemirror/language'
import { headingFoldRange, foldableHeadings } from '../components/editor/foldableHeadings'
import { markdownLivePreview } from '../components/editor/markdownLivePreview'

const md = markdown({ base: markdownLanguage })

function makeState(doc: string, extensions: unknown[] = [md]) {
return EditorState.create({ doc, extensions: extensions as never })
}

/** Resolve the fold range for the heading on the 1-indexed `lineNo`. */
function foldRangeForLine(state: EditorState, lineNo: number) {
const line = state.doc.line(lineNo)
return headingFoldRange(state, line.from, line.to)
}

describe('headingFoldRange — section range off the lezer tree', () => {
test('a `##` folds until the next `##`', () => {
const doc = ['# Title', '## A', 'body a1', 'body a2', '## B', 'body b'].join('\n')
const state = makeState(doc)
const range = foldRangeForLine(state, 2) // "## A"
expect(range).not.toBeNull()
// from = end of "## A" line
expect(range!.from).toBe(state.doc.line(2).to)
// to = just before the "## B" line
expect(range!.to).toBe(state.doc.line(5).from - 1)
// The folded text is exactly the two body lines.
expect(state.doc.sliceString(range!.from, range!.to)).toBe('\nbody a1\nbody a2')
})

test('a `##` also stops at a higher-level `#`', () => {
const doc = ['# One', '## Sub', 'body', '# Two', 'more'].join('\n')
const state = makeState(doc)
const range = foldRangeForLine(state, 2) // "## Sub"
expect(range).not.toBeNull()
// Stops before "# Two" (level 1 <= 2).
expect(range!.to).toBe(state.doc.line(4).from - 1)
})

test('a nested `###` folds independently of the enclosing `##`', () => {
const doc = [
'## Parent', // 1
'p body', // 2
'### Child', // 3
'c body 1', // 4
'c body 2', // 5
'## Next', // 6
].join('\n')
const state = makeState(doc)
const child = foldRangeForLine(state, 3) // "### Child"
expect(child).not.toBeNull()
// The ### section stops at the next heading of level <= 3, i.e. "## Next".
expect(child!.to).toBe(state.doc.line(6).from - 1)
expect(state.doc.sliceString(child!.from, child!.to)).toBe('\nc body 1\nc body 2')

// The enclosing ## folds the whole thing (its body + the child section).
const parent = foldRangeForLine(state, 1) // "## Parent"
expect(parent!.to).toBe(state.doc.line(6).from - 1)
})

test('the last heading folds to end-of-document', () => {
const doc = ['# A', 'a body', '## Last', 'tail 1', 'tail 2'].join('\n')
const state = makeState(doc)
const range = foldRangeForLine(state, 3) // "## Last"
expect(range).not.toBeNull()
expect(range!.to).toBe(state.doc.length)
expect(state.doc.sliceString(range!.from, range!.to)).toBe('\ntail 1\ntail 2')
})

test('a heading with no body folds nothing', () => {
const doc = ['## Empty', '## Next', 'body'].join('\n')
const state = makeState(doc)
expect(foldRangeForLine(state, 1)).toBeNull() // "## Empty" → "## Next" immediately
})

test('a heading at EOF with no body folds nothing', () => {
const doc = ['# A', 'body', '## End'].join('\n')
const state = makeState(doc)
expect(foldRangeForLine(state, 3)).toBeNull()
})

test('a non-heading line folds nothing', () => {
const doc = ['# A', 'just a paragraph'].join('\n')
const state = makeState(doc)
expect(foldRangeForLine(state, 2)).toBeNull()
})
})

describe('heading folding — EditorView integration (view-only, no corruption)', () => {
function mount(doc: string, extensions: unknown[]) {
const state = makeState(doc, extensions)
const view = new EditorView({ state })
return view
}

test('folding hides the body and leaves the text unchanged; unfold restores', () => {
const doc = ['# Title', '## A', 'body a1', 'body a2', '## B', 'body b'].join('\n')
const view = mount(doc, [md, foldableHeadings])

// Put the cursor on "## A" and fold via the command (exercises foldService).
const aLine = view.state.doc.line(2)
view.dispatch({ selection: { anchor: aLine.from } })
expect(foldCode(view)).toBe(true)

// The body lines are now inside a folded range; text is unchanged.
const folded = foldedRanges(view.state)
let foldedFrom = -1
let foldedTo = -1
folded.between(0, view.state.doc.length, (f, t) => {
foldedFrom = f
foldedTo = t
})
expect(foldedFrom).toBe(view.state.doc.line(2).to)
expect(foldedTo).toBe(view.state.doc.line(5).from - 1)
expect(view.state.doc.toString()).toBe(doc)

// Unfold restores: no folded ranges, text still pristine.
view.dispatch({ selection: { anchor: view.state.doc.line(2).from } })
expect(unfoldCode(view)).toBe(true)
let stillFolded = false
foldedRanges(view.state).between(0, view.state.doc.length, () => {
stillFolded = true
})
expect(stillFolded).toBe(false)
expect(view.state.doc.toString()).toBe(doc)

view.destroy()
})

test('folding coexists with the markdown live-preview decorations', () => {
const doc = [
'# Heading **bold**',
'## Section',
'- a list item',
'- [ ] a task',
'a #tag here',
'## Other',
'more',
].join('\n')
// Mount with BOTH the live-preview StateField and the folding bundle.
const view = mount(doc, [md, markdownLivePreview, foldableHeadings])

const sectionLine = view.state.doc.line(2) // "## Section"
view.dispatch({ selection: { anchor: sectionLine.from } })
expect(() => foldCode(view)).not.toThrow()

// The section body is folded, decorations didn't corrupt anything, and the
// document text is byte-for-byte identical.
let folded = false
foldedRanges(view.state).between(0, view.state.doc.length, () => {
folded = true
})
expect(folded).toBe(true)
expect(view.state.doc.toString()).toBe(doc)

view.destroy()
})
})
19 changes: 19 additions & 0 deletions src/components/editor/CodeMirrorEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ import { markdown, markdownLanguage } from '@codemirror/lang-markdown'
import { EditorView, keymap, drawSelection, type Command } from '@codemirror/view'
import { Prec, Compartment } from '@codemirror/state'
import { moveLineUp, moveLineDown, deleteLine } from '@codemirror/commands'
import { toggleFold, foldAll, unfoldAll } from '@codemirror/language'
import { search, searchKeymap, openSearchPanel } from '@codemirror/search'
import { diffGutterExtension, setDiffBaseline } from './diffGutter'
import { foldableHeadings } from './foldableHeadings'
import { getLastPushedContent } from '@/utils/lastPushedContent'
import { useDebouncedCallback } from '@/hooks/useDebounce'
import { useUIStore, useGitHubStore } from '@/stores'
Expand Down Expand Up @@ -784,6 +786,13 @@ export function CodeMirrorEditor({
onWikilinkNavigate: (note) => navigateRef.current(note),
}),
diffGutterExtension,
// Collapsible heading sections (Obsidian parity). Adds a fold gutter with
// a chevron next to every ATX heading; clicking collapses everything under
// it until the next heading of the same-or-higher level. View-only — the
// markdown text is untouched, so collab + live-preview decorations + save
// all keep working. See foldableHeadings.ts for the range logic. Keyboard
// toggles (Mod-., Mod-Alt-[ / Mod-Alt-]) are wired in the keymap below.
foldableHeadings,
// Built-in find / replace panel. `top: true` opens it above the
// editor — matches VS Code / Obsidian placement. Keymap includes
// Ctrl+F (find), Ctrl+H (replace), F3/Shift+F3 (next/prev), Esc
Expand Down Expand Up @@ -828,6 +837,16 @@ export function CodeMirrorEditor({
// "bookmark this page" dialog (Ctrl+D), which is interceptable (unlike
// Ctrl+W). Replaces the old selectNextOccurrence binding we removed.
{ key: 'Mod-d', preventDefault: true, run: deleteLine },
// ── Heading folding (Obsidian parity) ──────────────────────────────────
// Mod+. toggles the fold on the heading section at the cursor. Mod+Alt+[
// / Mod+Alt+] fold / unfold every section (CodeMirror's default fold-all
// chords; the app's own foldKeymap is disabled in basicSetup so these
// don't double-fire). None of these collide with the editor's existing
// bindings or the app-level shortcuts (Ctrl+. and the Alt-bracket chords
// are unused — see src/utils/shortcuts.ts).
{ key: 'Mod-.', preventDefault: true, run: toggleFold },
{ key: 'Mod-Alt-[', preventDefault: true, run: foldAll },
{ key: 'Mod-Alt-]', preventDefault: true, run: unfoldAll },
// Enter on an EMPTY checkbox exits the list. No preventDefault: when it
// returns false (any other line) the event falls through to the markdown
// keymap's normal Enter continuation.
Expand Down
Loading
Loading