From e2de08ca86f627ab6fec2445688169f415d79998 Mon Sep 17 00:00:00 2001 From: Malik Glossop Date: Mon, 4 May 2026 12:56:35 +0200 Subject: [PATCH] Merge pull request #33146 from overleaf/copilot/fix-code-folding-bug Fix code folding when a comment or blank line precedes an indented sectioning command GitOrigin-RevId: 2a955311c1ce073b2eb80fdfbf45d00705e22d69 --- .../languages/latex/latex-language.ts | 14 ++ .../languages/latex/latex-folding.test.ts | 130 ++++++++++++++++++ 2 files changed, 144 insertions(+) diff --git a/services/web/frontend/js/features/source-editor/languages/latex/latex-language.ts b/services/web/frontend/js/features/source-editor/languages/latex/latex-language.ts index 44e74cd412..ac07ea954a 100644 --- a/services/web/frontend/js/features/source-editor/languages/latex/latex-language.ts +++ b/services/web/frontend/js/features/source-editor/languages/latex/latex-language.ts @@ -111,6 +111,20 @@ export const LaTeXLanguage = LRLanguage.define({ if (sibling?.type.is(termsModule.NewLine)) { return { from: content!.from, to: sibling.from } } + if (sibling?.type.is(termsModule.Comment)) { + // A trailing comment line (e.g. `%`) before an indented + // sectioning command should be included in the fold, but the + // sectioning command itself should still appear on its own + // line. The Comment node consumes its trailing newline, so + // end the fold one position before the Comment ends. + return { from: content!.from, to: sibling.to - 1 } + } + if (sibling?.type.is(termsModule.BlankLine)) { + // Blank line(s) between previous content and an indented + // sectioning command: include all but the last newline in the + // fold (mirroring the BlankLine handling below). + return { from: content!.from, to: sibling.to - 1 } + } } if (lastChild.type.is(termsModule.BlankLine)) { // HACK: BlankLine can contain any number above 2 of \n's. diff --git a/services/web/test/frontend/features/source-editor/languages/latex/latex-folding.test.ts b/services/web/test/frontend/features/source-editor/languages/latex/latex-folding.test.ts index 8825164797..63e56a18da 100644 --- a/services/web/test/frontend/features/source-editor/languages/latex/latex-folding.test.ts +++ b/services/web/test/frontend/features/source-editor/languages/latex/latex-folding.test.ts @@ -121,6 +121,136 @@ describe('CodeMirror LaTeX-folding', function () { }) }) + describe('with a comment line before an indented sectioning command', function () { + let view: EditorView, content: string[] + + beforeEach(function () { + // Reproduces a bug where a comment line followed by an indented + // `\subsection` should not pull the next sectioning command's line + // into the previous fold. + content = [ + '\\section{1}', + ' Content', + ' \\subsection{1a}', + ' Content', + ' %', + ' \\subsection{1b}', + ' Content', + ] + view = makeView(content) + }) + + it('should fold subsection 1a up to the comment line, leaving subsection 1b on its own line', function () { + const folds = _getFolds(view) + const subsection1aFold = folds.find( + fold => view.state.doc.lineAt(fold.from).number === 3 + ) + expect(subsection1aFold).not.to.be.undefined + // The fold for subsection 1a should end on (or before) line 5 so that + // line 6 (subsection 1b) can be folded independently. + expect( + view.state.doc.lineAt(subsection1aFold!.to).number + ).to.be.at.most(5) + }) + + it('should still produce a separate fold for subsection 1b', function () { + const folds = _getFolds(view) + const subsection1bFold = folds.find( + fold => view.state.doc.lineAt(fold.from).number === 6 + ) + expect(subsection1bFold).not.to.be.undefined + }) + }) + + describe('with an indented sectioning command after a blank line', function () { + let view: EditorView, content: string[] + + beforeEach(function () { + // The folding algorithm should not consider whitespace on the line + // before an indented sectioning command as part of the previous + // section's fold. + content = ['\\section{1}', 'Content', '', ' \\section{2}', 'Content'] + view = makeView(content) + }) + + it('should not include the indented sectioning command line in the previous fold', function () { + const folds = _getFolds(view) + const firstFold = folds.find( + fold => view.state.doc.lineAt(fold.from).number === 1 + ) + expect(firstFold).not.to.be.undefined + expect(view.state.doc.lineAt(firstFold!.to).number).to.be.at.most(3) + }) + }) + + describe('with a complex mix of comments, blank lines and indented sectioning commands', function () { + let view: EditorView, content: string[] + + beforeEach(function () { + // Combines all the tricky cases: + // - trailing `%` comment lines before indented and non-indented + // sectioning commands + // - blank lines (with and without trailing whitespace) before indented + // sectioning commands + // - multiple levels of (sub)sectioning at varying indentation + content = [ + '\\section{1}', + ' Content', + ' \\subsection{1a}', + ' Content a', + ' %', + ' \\subsubsection{1a1a}', + ' Hello', + ' %', + '\\subsection{1b}', + 'Content b', + '', + ' \\subsection{1c}', + ' Content c', + '', + ' \\subsubsection{1c1a}', + ' World', + ' ', + ' \\subsubsection{1c1b}', + ' New ', + ' ', + ] + view = makeView(content) + }) + + it('should produce a separate fold for each sectioning command', function () { + const folds = _getFolds(view) + const fromLines = folds + .map(fold => view.state.doc.lineAt(fold.from).number) + .sort((a, b) => a - b) + // One fold per sectioning command line + expect(fromLines).to.deep.equal([1, 3, 6, 9, 12, 15, 18]) + }) + + it('should not pull a sibling sectioning command line into the previous fold', function () { + const folds = _getFolds(view) + const foldByFromLine = new Map() + for (const fold of folds) { + foldByFromLine.set(view.state.doc.lineAt(fold.from).number, fold) + } + // Each subsection 1a / 1b / 1c sibling fold must end before the next + // subsection's line so the next subsection can be folded independently. + const siblingPairs: Array<[number, number]> = [ + [3, 9], // \subsection{1a} fold must end before line 9 (\subsection{1b}) + [9, 12], // \subsection{1b} fold must end before line 12 (\subsection{1c}) + [15, 18], // \subsubsection{1c1a} fold must end before line 18 (\subsubsection{1c1b}) + ] + for (const [fromLine, nextFromLine] of siblingPairs) { + const fold = foldByFromLine.get(fromLine) + expect(fold, `fold starting on line ${fromLine}`).not.to.be.undefined + expect( + view.state.doc.lineAt(fold!.to).number, + `fold starting on line ${fromLine} should end before line ${nextFromLine}` + ).to.be.lessThan(nextFromLine) + } + }) + }) + describe('with realistic nesting', function () { let view: EditorView, content: string[]