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
This commit is contained in:
Malik Glossop
2026-05-04 12:56:35 +02:00
committed by Copybot
parent 47473bc5f4
commit e2de08ca86
2 changed files with 144 additions and 0 deletions

View File

@@ -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.

View File

@@ -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<number, Fold>()
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[]