From fb827422a63239da04b34b5842e9e44993d8032b Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Sun, 26 Jul 2026 22:19:22 +0530 Subject: [PATCH] feat(editor): add indentation folding for legacy modes(as fallback) --- src/cm/baseExtensions.ts | 3 +- src/cm/indentationFolding.ts | 69 ++++++++++++++++++++++++++++++++++++ src/test/editor.tests.js | 30 ++++++++++++++++ 3 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 src/cm/indentationFolding.ts diff --git a/src/cm/baseExtensions.ts b/src/cm/baseExtensions.ts index 927c3ed943..336027f51a 100644 --- a/src/cm/baseExtensions.ts +++ b/src/cm/baseExtensions.ts @@ -15,6 +15,7 @@ import { import { highlightSelectionMatches } from "@codemirror/search"; import type { Extension } from "@codemirror/state"; import { EditorState, Prec } from "@codemirror/state"; +import indentationFolding from "./indentationFolding"; import { crosshairCursor, drawSelection, @@ -56,7 +57,7 @@ export default function createBaseExtensions( ]; if (enableHighlightActiveLine) extensions.push(highlightActiveLineGutter()); - if (codeFolding) extensions.push(foldGutter()); + if (codeFolding) extensions.push(foldGutter(), indentationFolding); extensions.push(drawSelection()); extensions.push(dropCursor()); extensions.push(EditorState.allowMultipleSelections.of(true)); diff --git a/src/cm/indentationFolding.ts b/src/cm/indentationFolding.ts new file mode 100644 index 0000000000..7be802cbfa --- /dev/null +++ b/src/cm/indentationFolding.ts @@ -0,0 +1,69 @@ +import { + foldService, + language, + StreamLanguage, +} from "@codemirror/language"; +import { countColumn, type EditorState } from "@codemirror/state"; + +interface FoldRange { + from: number; + to: number; +} + +const foldCache = new WeakMap< + EditorState, + Map +>(); + +function findIndentationFold( + state: EditorState, + from: number, +): FoldRange | null { + const activeLanguage = state.facet(language); + if (activeLanguage && !(activeLanguage instanceof StreamLanguage)) { + return null; + } + + const line = state.doc.lineAt(from); + let stateCache = foldCache.get(state); + if (!stateCache) { + stateCache = new Map(); + foldCache.set(state, stateCache); + } + if (stateCache.has(line.from)) return stateCache.get(line.from) ?? null; + + const indentationEnd = line.text.search(/\S|$/); + if (indentationEnd === line.text.length) { + stateCache.set(line.from, null); + return null; + } + + const indentation = countColumn(line.text, state.tabSize, indentationEnd); + let foldEnd: number | null = null; + + for ( + let lineNumber = line.number + 1; + lineNumber <= state.doc.lines; + lineNumber++ + ) { + const nextLine = state.doc.line(lineNumber); + const nextIndentationEnd = nextLine.text.search(/\S|$/); + if (nextIndentationEnd === nextLine.text.length) continue; + + const nextIndentation = countColumn( + nextLine.text, + state.tabSize, + nextIndentationEnd, + ); + if (nextIndentation <= indentation) break; + + foldEnd = nextLine.to; + } + + const range = + foldEnd === null ? null : { from: line.to, to: foldEnd }; + stateCache.set(line.from, range); + return range; +} + +export default foldService.of(findIndentationFold); diff --git a/src/test/editor.tests.js b/src/test/editor.tests.js index 551311ab32..e9a6928494 100644 --- a/src/test/editor.tests.js +++ b/src/test/editor.tests.js @@ -10,9 +10,11 @@ import { javascript } from "@codemirror/lang-javascript"; import { bracketMatching, defaultHighlightStyle, + foldable, foldEffect, foldedRanges, foldGutter, + StreamLanguage, syntaxHighlighting, } from "@codemirror/language"; import { highlightSelectionMatches, searchKeymap } from "@codemirror/search"; @@ -754,6 +756,34 @@ export async function runCodeMirrorTests(writeOutput) { }, ); + runner.test( + "Legacy modes fold indentation while preserving blank lines", + (test) => { + const streamLanguage = StreamLanguage.define({ + startState: () => ({}), + copyState: (state) => ({ ...state }), + token: (stream) => { + stream.skipToEnd(); + return null; + }, + }); + const state = EditorState.create({ + doc: "root\n\tchild\n\n\tchild2\nnext", + extensions: [ + ...createBaseExtensions(), + EditorState.tabSize.of(4), + streamLanguage, + ], + }); + const firstLine = state.doc.line(1); + const range = foldable(state, firstLine.from, firstLine.to); + + test.assert(range != null, "Indented lines should be foldable"); + test.assertEqual(range.from, firstLine.to); + test.assertEqual(range.to, state.doc.line(4).to); + }, + ); + runner.test( "Fold all includes same-line nested blocks and unfold all clears them", async (test) => {