From 0d8ea1a12a5cf962d1b4102cc8ff3c9a508c35b7 Mon Sep 17 00:00:00 2001
From: Amadeus Demarzi
Date: Thu, 28 May 2026 18:06:48 -0700
Subject: [PATCH 1/5] chore: empty commit for beta branch
From 93c85e96e69e443f786df5cb1c80f27d6ffba06c Mon Sep 17 00:00:00 2001
From: Je Xia
Date: Fri, 17 Jul 2026 01:17:54 +0800
Subject: [PATCH 2/5] [diffs/edit] add folding support
---
.../docs/app/(diffs)/docs/Editor/constants.ts | 4 +-
packages/diffs/src/components/File.ts | 30 +
.../diffs/src/components/VirtualizedFile.ts | 347 +++++++-
packages/diffs/src/editor/editor.css | 102 +++
packages/diffs/src/editor/editor.ts | 820 +++++++++++++++++-
packages/diffs/src/editor/folding.ts | 294 +++++++
packages/diffs/src/editor/selection.ts | 18 +
packages/diffs/src/editor/sprite.ts | 28 +-
packages/diffs/src/editor/stateStorage.ts | 1 +
packages/diffs/src/renderers/FileRenderer.ts | 31 +
packages/diffs/src/types.ts | 15 +
.../test/VirtualizedFile.folding.test.ts | 216 +++++
packages/diffs/test/e2e/e2e-globals.d.ts | 2 +
packages/diffs/test/e2e/fixtures/folding.html | 94 ++
packages/diffs/test/e2e/fixtures/index.html | 8 +
packages/diffs/test/e2e/folding.pw.ts | 105 +++
packages/diffs/test/editorFolding.test.ts | 650 ++++++++++++++
.../diffs/test/editorFoldingRanges.test.ts | 171 ++++
.../test/editorPersistStateLifecycle.test.ts | 120 ++-
packages/diffs/test/editorSelection.test.ts | 24 +
20 files changed, 3008 insertions(+), 72 deletions(-)
create mode 100644 packages/diffs/src/editor/folding.ts
create mode 100644 packages/diffs/test/VirtualizedFile.folding.test.ts
create mode 100644 packages/diffs/test/e2e/fixtures/folding.html
create mode 100644 packages/diffs/test/e2e/folding.pw.ts
create mode 100644 packages/diffs/test/editorFolding.test.ts
create mode 100644 packages/diffs/test/editorFoldingRanges.test.ts
diff --git a/apps/docs/app/(diffs)/docs/Editor/constants.ts b/apps/docs/app/(diffs)/docs/Editor/constants.ts
index d6b538b7d..2e7c93400 100644
--- a/apps/docs/app/(diffs)/docs/Editor/constants.ts
+++ b/apps/docs/app/(diffs)/docs/Editor/constants.ts
@@ -1053,10 +1053,12 @@ const file: FileContents | undefined = editor.getFile();
// Full document text, or '' when nothing is attached.
const text: string = editor.getText();
-// Snapshot selections and scroll position for persistence or remount restore.
+// Snapshot selections, active folds, and scroll position for persistence or
+// remount restore.
const state: EditorState = editor.getState();
// EditorState = {
// selections?: EditorSelection[];
+// foldRanges?: LineRange[]; // zero-based; standalone closers stay visible
// view?: { scrollLeft: number; scrollTop: number };
// }
diff --git a/packages/diffs/src/components/File.ts b/packages/diffs/src/components/File.ts
index 22bf92f01..95b2cfdeb 100644
--- a/packages/diffs/src/components/File.ts
+++ b/packages/diffs/src/components/File.ts
@@ -33,6 +33,7 @@ import type {
FileContents,
HighlightedToken,
LineAnnotation,
+ LineRange,
PostRenderPhase,
PrePropertiesConfig,
RenderFileMetadata,
@@ -176,6 +177,7 @@ export class File<
public file: FileContents | undefined;
protected renderRange: RenderRange | undefined;
protected enabled = true;
+ protected foldRanges: LineRange[] = [];
protected editor: DiffsEditor | undefined;
@@ -214,6 +216,33 @@ export class File<
return this.file;
}
+ public __setFoldRanges(ranges: LineRange[]): void {
+ if (!this.updateFoldRanges(ranges)) {
+ return;
+ }
+ if (this.enabled && this.file != null) {
+ this.rerender();
+ }
+ }
+
+ protected updateFoldRanges(ranges: LineRange[]): boolean {
+ if (
+ ranges.length === this.foldRanges.length &&
+ ranges.every((range, index) => {
+ const previous = this.foldRanges[index];
+ return (
+ previous?.startLine === range.startLine &&
+ previous.endLine === range.endLine
+ );
+ })
+ ) {
+ return false;
+ }
+ this.foldRanges = ranges.map((range) => ({ ...range }));
+ this.fileRenderer.setFoldRanges(this.foldRanges);
+ return true;
+ }
+
public onThemeChange(): void {
this.fileRenderer.clearRenderCache();
this.rerender();
@@ -373,6 +402,7 @@ export class File<
this.workerManager = undefined;
this.file = undefined;
}
+ this.foldRanges = [];
this.enabled = false;
diff --git a/packages/diffs/src/components/VirtualizedFile.ts b/packages/diffs/src/components/VirtualizedFile.ts
index e214dfb09..f820e301e 100644
--- a/packages/diffs/src/components/VirtualizedFile.ts
+++ b/packages/diffs/src/components/VirtualizedFile.ts
@@ -1,8 +1,10 @@
import { DEFAULT_VIRTUAL_FILE_METRICS } from '../constants';
+import { LineRangeIndex } from '../editor/folding';
import type {
DiffsTextDocument,
FileContents,
LineAnnotation,
+ LineRange,
NumericScrollLineAnchor,
PendingCodeViewLayoutReset,
RenderRange,
@@ -83,6 +85,7 @@ export class VirtualizedFile<
private layoutDirty = true;
private forceRenderOverride: true | undefined;
private currentCollapsed: boolean | undefined;
+ private editorFoldedLineIndex = new LineRangeIndex();
constructor(
options: FileOptions | undefined,
@@ -135,6 +138,18 @@ export class VirtualizedFile<
// If not cached and hasMetadataLine is true, adds lineHeight for the
// metadata.
public getLineHeight(lineIndex: number, hasMetadataLine = false): number {
+ if (this.editorFoldedLineIndex.isHidden(lineIndex)) {
+ return 0;
+ }
+ return this.getVisibleLineHeight(lineIndex, hasMetadataLine);
+ }
+
+ // Read a row height after the caller has already established that the line
+ // is visible, avoiding another folded-range lookup inside layout scans.
+ private getVisibleLineHeight(
+ lineIndex: number,
+ hasMetadataLine = false
+ ): number {
const cached = this.cache.heights.get(lineIndex);
if (cached != null) {
return cached;
@@ -180,6 +195,30 @@ export class VirtualizedFile<
super.setThemeType(themeType);
}
+ public override __setFoldRanges(ranges: LineRange[]): void {
+ if (!this.updateFoldRanges(ranges)) {
+ return;
+ }
+
+ this.editorFoldedLineIndex = new LineRangeIndex(this.foldRanges);
+ this.forceRenderOverride = true;
+ this.invalidateEditorFoldingLayout();
+ if (this.enabled && this.file != null) {
+ this.virtualizer.instanceChanged(this, true);
+ }
+ }
+
+ // Folding only changes which rows participate in layout. Preserve measured
+ // wrap/annotation heights and rebuild the derived total and checkpoints.
+ private invalidateEditorFoldingLayout(): void {
+ this.layoutDirty = true;
+ this.cache.checkpoints.length = 0;
+ this.renderRange = undefined;
+ if (this.isSimpleMode()) {
+ this.computeApproximateSize();
+ }
+ }
+
private resetLayoutCache(recompute = false, resetRenderRange = true): void {
this.layoutDirty = true;
this.cache.fileAnnotationHeight = 0;
@@ -371,6 +410,16 @@ export class VirtualizedFile<
top += this.cache.fileAnnotationHeight;
if (overflow === 'scroll' && !this.hasLineAnnotations()) {
+ if (this.foldRanges.length > 0) {
+ const hiddenBefore =
+ this.editorFoldedLineIndex.hiddenCountBefore(clampedLineIndex);
+ return {
+ top: top + (clampedLineIndex - hiddenBefore) * lineHeight,
+ height: this.editorFoldedLineIndex.isHidden(clampedLineIndex)
+ ? 0
+ : lineHeight,
+ };
+ }
return {
top: top + clampedLineIndex * lineHeight,
height: lineHeight,
@@ -380,12 +429,22 @@ export class VirtualizedFile<
const checkpoint =
this.getLayoutCheckpointBeforeLineIndex(clampedLineIndex);
top = checkpoint?.top ?? top;
- for (
- let lineIndex = checkpoint?.lineIndex ?? 0;
- lineIndex < clampedLineIndex;
- lineIndex++
- ) {
- top += this.getLineHeight(lineIndex, false);
+ let lineIndex = checkpoint?.lineIndex ?? 0;
+ const lineAfterStartingFold =
+ this.editorFoldedLineIndex.lineAfterHiddenRange(lineIndex);
+ if (lineAfterStartingFold != null) {
+ lineIndex = Math.min(lineAfterStartingFold, clampedLineIndex);
+ }
+ let foldedRangeIndex = this.getFoldRangeIndexAtOrAfter(lineIndex);
+ while (lineIndex < clampedLineIndex) {
+ const foldedRange = this.foldRanges[foldedRangeIndex];
+ if (foldedRange != null && lineIndex >= foldedRange.startLine) {
+ lineIndex = Math.min(foldedRange.endLine + 1, clampedLineIndex);
+ foldedRangeIndex++;
+ continue;
+ }
+ top += this.getVisibleLineHeight(lineIndex);
+ lineIndex++;
}
return {
@@ -441,6 +500,50 @@ export class VirtualizedFile<
// multiply our way to the the correct value
if (overflow === 'scroll' && !this.hasLineAnnotations()) {
const { lineHeight } = this.metrics;
+ if (this.foldRanges.length > 0) {
+ const firstVisibleLineIndex =
+ this.editorFoldedLineIndex.nearestVisibleLine(
+ firstRenderedLineIndex,
+ 'down',
+ lastLineIndex + 1
+ );
+ const lastVisibleLineIndex =
+ this.editorFoldedLineIndex.nearestVisibleLine(
+ lastRenderedLineIndex,
+ 'up',
+ lastLineIndex + 1
+ );
+ if (
+ firstVisibleLineIndex == null ||
+ lastVisibleLineIndex == null ||
+ firstVisibleLineIndex > lastVisibleLineIndex
+ ) {
+ return undefined;
+ }
+
+ const firstVisibleIndex =
+ firstVisibleLineIndex -
+ this.editorFoldedLineIndex.hiddenCountBefore(firstVisibleLineIndex);
+ const firstRenderedLineTop =
+ headerRegion + fileAnnotationHeight + firstVisibleIndex * lineHeight;
+ const deltaLineCount = Math.max(
+ Math.ceil((localViewportTop - firstRenderedLineTop) / lineHeight),
+ 0
+ );
+ const visibleIndex = firstVisibleIndex + deltaLineCount;
+ const lineIndex = this.editorFoldedLineIndex.lineAtVisibleIndex(
+ visibleIndex,
+ lastLineIndex + 1
+ );
+ if (lineIndex == null || lineIndex > lastVisibleLineIndex) {
+ return undefined;
+ }
+
+ return {
+ lineNumber: lineIndex + 1,
+ top: headerRegion + fileAnnotationHeight + visibleIndex * lineHeight,
+ };
+ }
const firstRenderedLineTop =
headerRegion +
(firstRenderedLineIndex === 0
@@ -467,18 +570,28 @@ export class VirtualizedFile<
(firstRenderedLineIndex === 0
? fileAnnotationHeight
: this.renderRange.bufferBefore);
- for (
- let lineIndex = firstRenderedLineIndex;
- lineIndex <= lastRenderedLineIndex;
- lineIndex++
- ) {
+ let lineIndex = firstRenderedLineIndex;
+ const lineAfterStartingFold =
+ this.editorFoldedLineIndex.lineAfterHiddenRange(lineIndex);
+ if (lineAfterStartingFold != null) {
+ lineIndex = lineAfterStartingFold;
+ }
+ let foldedRangeIndex = this.getFoldRangeIndexAtOrAfter(lineIndex);
+ while (lineIndex <= lastRenderedLineIndex) {
+ const foldedRange = this.foldRanges[foldedRangeIndex];
+ if (foldedRange != null && lineIndex >= foldedRange.startLine) {
+ lineIndex = foldedRange.endLine + 1;
+ foldedRangeIndex++;
+ continue;
+ }
if (top >= localViewportTop) {
return {
lineNumber: lineIndex + 1,
top,
};
}
- top += this.getLineHeight(lineIndex);
+ top += this.getVisibleLineHeight(lineIndex);
+ lineIndex++;
}
return undefined;
@@ -531,6 +644,7 @@ export class VirtualizedFile<
}
override cleanUp(recycle = false): void {
+ const recycledFoldedRanges = recycle ? this.foldRanges : [];
if (this.fileContainer != null && this.isSimpleMode()) {
this.getSimpleVirtualizer()?.disconnect(this.fileContainer);
}
@@ -539,6 +653,12 @@ export class VirtualizedFile<
}
this.isSetup = false;
super.cleanUp(recycle);
+ if (recycle && recycledFoldedRanges.length > 0) {
+ this.foldRanges = recycledFoldedRanges;
+ this.fileRenderer.setFoldRanges(recycledFoldedRanges);
+ } else {
+ this.editorFoldedLineIndex = new LineRangeIndex();
+ }
}
// Compute the approximate size of the file using cached line heights.
@@ -579,11 +699,52 @@ export class VirtualizedFile<
this.height += this.cache.fileAnnotationHeight;
if (overflow === 'scroll' && !this.hasLineAnnotations()) {
- this.height += lineCount * lineHeight;
+ this.height +=
+ this.editorFoldedLineIndex.visibleLineCount(lineCount) * lineHeight;
} else {
- for (let lineIndex = 0; lineIndex < lineCount; lineIndex++) {
- this.addLayoutCheckpoint(lineIndex, this.height);
- this.height += this.getLineHeight(lineIndex, false);
+ const codeRegionTop = this.height;
+ const measuredHeights = [...this.cache.heights]
+ .filter(
+ ([lineIndex]) =>
+ lineIndex >= 0 &&
+ lineIndex < lineCount &&
+ !this.editorFoldedLineIndex.isHidden(lineIndex)
+ )
+ .sort(([leftLineIndex], [rightLineIndex]) => {
+ return leftLineIndex - rightLineIndex;
+ });
+ let measuredHeightDelta = 0;
+ for (const [, measuredHeight] of measuredHeights) {
+ measuredHeightDelta += measuredHeight - lineHeight;
+ }
+
+ this.height +=
+ this.editorFoldedLineIndex.visibleLineCount(lineCount) * lineHeight +
+ measuredHeightDelta;
+
+ let measuredHeightIndex = 0;
+ let measuredHeightDeltaBefore = 0;
+ for (
+ let lineIndex = 0;
+ lineIndex < lineCount;
+ lineIndex += LAYOUT_CHECKPOINT_INTERVAL
+ ) {
+ let measuredHeight = measuredHeights[measuredHeightIndex];
+ while (measuredHeight != null && measuredHeight[0] < lineIndex) {
+ measuredHeightDeltaBefore += measuredHeight[1] - lineHeight;
+ measuredHeightIndex++;
+ measuredHeight = measuredHeights[measuredHeightIndex];
+ }
+
+ const visibleLinesBefore =
+ lineIndex - this.editorFoldedLineIndex.hiddenCountBefore(lineIndex);
+ this.cache.checkpoints.push({
+ lineIndex,
+ top:
+ codeRegionTop +
+ visibleLinesBefore * lineHeight +
+ measuredHeightDeltaBefore,
+ });
}
}
@@ -767,11 +928,24 @@ export class VirtualizedFile<
return this.virtualizer.type === 'advanced';
}
- private addLayoutCheckpoint(lineIndex: number, top: number): void {
- if (lineIndex % LAYOUT_CHECKPOINT_INTERVAL !== 0) {
- return;
+ // Locate the first ordered folded range that can contain or follow a raw
+ // line. Scans then advance through ranges once and jump collapsed bodies.
+ private getFoldRangeIndexAtOrAfter(lineIndex: number): number {
+ let low = 0;
+ let high = this.foldRanges.length;
+ while (low < high) {
+ const middle = low + ((high - low) >> 1);
+ const range = this.foldRanges[middle];
+ if (range == null) {
+ throw new Error('VirtualizedFile: invalid folded range index');
+ }
+ if (range.endLine < lineIndex) {
+ low = middle + 1;
+ } else {
+ high = middle;
+ }
}
- this.cache.checkpoints.push({ lineIndex, top });
+ return low;
}
// Find the nearest sparse layout checkpoint at or before a raw file line.
@@ -810,7 +984,8 @@ export class VirtualizedFile<
// Render-range scans start from this checkpoint so variable-height files
// only replay the nearby measured rows. When `hunkLineCount` is provided,
// step backward to a hunk boundary so hooks that depend on grouped lines
- // still see a complete hunk.
+ // still see a complete hunk. Folded files use visible-row hunk indexes, so
+ // they resume at the nearest raw checkpoint and derive the missing offset.
private getLayoutCheckpointBeforeTop(
top: number,
hunkLineCount?: number
@@ -833,7 +1008,7 @@ export class VirtualizedFile<
}
}
- if (hunkLineCount == null) {
+ if (hunkLineCount == null || this.foldRanges.length > 0) {
return resultIndex >= 0 ? this.cache.checkpoints[resultIndex] : undefined;
}
@@ -875,6 +1050,10 @@ export class VirtualizedFile<
const { disableFileHeader = false, overflow = 'scroll' } = this.options;
const { hunkLineCount, lineHeight } = this.metrics;
const lineCount = this.fileRenderer.getLineCount(file);
+ const hasEditorFolds = this.foldRanges.length > 0;
+ const visibleLineCount = hasEditorFolds
+ ? this.editorFoldedLineIndex.visibleLineCount(lineCount)
+ : lineCount;
const fileHeight = this.height;
const headerRegion = getVirtualFileHeaderRegion(
this.metrics,
@@ -951,21 +1130,61 @@ export class VirtualizedFile<
// Calculate ideal start centered around viewport
const idealStartHunk = centerHunk - Math.floor(totalHunks / 2);
- const totalHunksInFile = Math.ceil(lineCount / hunkLineCount);
- const startingLine =
+ const totalHunksInFile = Math.ceil(visibleLineCount / hunkLineCount);
+ const startingVisibleLine =
Math.max(0, Math.min(idealStartHunk, totalHunksInFile)) * hunkLineCount;
- const clampedTotalLines =
+ const clampedVisibleLines =
idealStartHunk < 0
? totalLines + idealStartHunk * hunkLineCount
: totalLines;
+ if (hasEditorFolds) {
+ const renderedVisibleLines = Math.max(
+ 0,
+ Math.min(clampedVisibleLines, visibleLineCount - startingVisibleLine)
+ );
+ const startingLine = this.editorFoldedLineIndex.lineAtVisibleIndex(
+ startingVisibleLine,
+ lineCount
+ );
+ const finalLine =
+ renderedVisibleLines > 0
+ ? this.editorFoldedLineIndex.lineAtVisibleIndex(
+ startingVisibleLine + renderedVisibleLines - 1,
+ lineCount
+ )
+ : undefined;
+ if (startingLine == null || finalLine == null) {
+ return {
+ startingLine: 0,
+ totalLines: 0,
+ bufferBefore: 0,
+ bufferAfter: codeRowsHeight,
+ };
+ }
+ return {
+ startingLine,
+ totalLines: finalLine - startingLine + 1,
+ bufferBefore:
+ startingVisibleLine === 0
+ ? 0
+ : fileAnnotationHeight + startingVisibleLine * lineHeight,
+ bufferAfter: Math.max(
+ 0,
+ (visibleLineCount - startingVisibleLine - renderedVisibleLines) *
+ lineHeight
+ ),
+ };
+ }
+
+ const startingLine = startingVisibleLine;
const bufferBefore =
startingLine === 0
? 0
: fileAnnotationHeight + startingLine * lineHeight;
const renderedLines = Math.min(
- clampedTotalLines,
+ clampedVisibleLines,
lineCount - startingLine
);
const bufferAfter = Math.max(
@@ -975,7 +1194,7 @@ export class VirtualizedFile<
return {
startingLine,
- totalLines: clampedTotalLines,
+ totalLines: clampedVisibleLines,
bufferBefore,
bufferAfter,
};
@@ -993,17 +1212,28 @@ export class VirtualizedFile<
);
let absoluteLineTop = fileTop + (checkpoint?.top ?? codeRegionTop);
- let currentLine = checkpoint?.lineIndex ?? 0;
+ let currentLine = hasEditorFolds
+ ? (checkpoint?.lineIndex ?? 0) -
+ this.editorFoldedLineIndex.hiddenCountBefore(checkpoint?.lineIndex ?? 0)
+ : (checkpoint?.lineIndex ?? 0);
let firstVisibleHunk: number | undefined;
let centerHunk: number | undefined;
let overflowCounter: number | undefined;
- const startingLineIndex = checkpoint?.lineIndex ?? 0;
- for (
- let lineIndex = startingLineIndex;
- lineIndex < lineCount;
- lineIndex++
- ) {
+ let lineIndex = checkpoint?.lineIndex ?? 0;
+ const lineAfterStartingFold =
+ this.editorFoldedLineIndex.lineAfterHiddenRange(lineIndex);
+ if (lineAfterStartingFold != null) {
+ lineIndex = lineAfterStartingFold;
+ }
+ let foldedRangeIndex = this.getFoldRangeIndexAtOrAfter(lineIndex);
+ while (lineIndex < lineCount) {
+ const foldedRange = this.foldRanges[foldedRangeIndex];
+ if (foldedRange != null && lineIndex >= foldedRange.startLine) {
+ lineIndex = foldedRange.endLine + 1;
+ foldedRangeIndex++;
+ continue;
+ }
const isAtHunkBoundary = currentLine % hunkLineCount === 0;
const currentHunk = Math.floor(currentLine / hunkLineCount);
@@ -1018,15 +1248,18 @@ export class VirtualizedFile<
}
}
- const lineHeight = this.getLineHeight(lineIndex, false);
+ const visibleLineHeight = this.getVisibleLineHeight(lineIndex);
// Track visible region
- if (absoluteLineTop > top - lineHeight && absoluteLineTop < bottom) {
+ if (
+ absoluteLineTop > top - visibleLineHeight &&
+ absoluteLineTop < bottom
+ ) {
firstVisibleHunk ??= currentHunk;
}
// Track which hunk contains the viewport center
- if (absoluteLineTop + lineHeight > viewportCenter) {
+ if (absoluteLineTop + visibleLineHeight > viewportCenter) {
centerHunk ??= currentHunk;
}
@@ -1040,7 +1273,8 @@ export class VirtualizedFile<
}
currentLine++;
- absoluteLineTop += lineHeight;
+ absoluteLineTop += visibleLineHeight;
+ lineIndex++;
}
// No visible lines found
@@ -1066,19 +1300,48 @@ export class VirtualizedFile<
// startHunk back
const maxStartHunk = Math.max(
0,
- Math.ceil(lineCount / hunkLineCount) - totalHunks
+ Math.ceil(visibleLineCount / hunkLineCount) - totalHunks
);
const startHunk = Math.max(0, Math.min(idealStartHunk, maxStartHunk));
- const startingLine = startHunk * hunkLineCount;
+ const startingVisibleLine = startHunk * hunkLineCount;
+ const startingLine = hasEditorFolds
+ ? (this.editorFoldedLineIndex.lineAtVisibleIndex(
+ startingVisibleLine,
+ lineCount
+ ) ?? lineCount)
+ : startingVisibleLine;
// If we wanted to start before 0, reduce totalLines by the clamped amount
const clampedTotalLines =
idealStartHunk < 0
? totalLines + idealStartHunk * hunkLineCount
: totalLines;
+ let rawTotalLines = clampedTotalLines;
+ if (hasEditorFolds) {
+ const renderedVisibleLines = Math.max(
+ 0,
+ Math.min(clampedTotalLines, visibleLineCount - startingVisibleLine)
+ );
+ const finalLine =
+ renderedVisibleLines > 0
+ ? this.editorFoldedLineIndex.lineAtVisibleIndex(
+ startingVisibleLine + renderedVisibleLines - 1,
+ lineCount
+ )
+ : undefined;
+ rawTotalLines = finalLine == null ? 0 : finalLine - startingLine + 1;
+ }
// Use hunkOffsets array for efficient buffer calculations
- const codeBufferBefore = hunkOffsets[startHunk] ?? 0;
+ const codeBufferBefore =
+ hunkOffsets[startHunk] ??
+ (hasEditorFolds && startingLine < lineCount
+ ? Math.max(
+ 0,
+ (this.getLinePosition(startingLine + 1)?.top ?? codeRegionTop) -
+ codeRegionTop
+ )
+ : 0);
const bufferBefore =
startingLine === 0 ? 0 : fileAnnotationHeight + codeBufferBefore;
@@ -1091,7 +1354,7 @@ export class VirtualizedFile<
return {
startingLine,
- totalLines: clampedTotalLines,
+ totalLines: rawTotalLines,
bufferBefore,
bufferAfter: Math.max(0, bufferAfter),
};
diff --git a/packages/diffs/src/editor/editor.css b/packages/diffs/src/editor/editor.css
index a65a8bf78..44101e007 100644
--- a/packages/diffs/src/editor/editor.css
+++ b/packages/diffs/src/editor/editor.css
@@ -65,6 +65,108 @@
box-shadow: inset 0 0 0 1px var(--diffs-editor-line-highlight-border);
}
+[data-code][data-editor-folding] {
+ --diffs-editor-fold-gap: 4px;
+ --diffs-editor-fold-width: 16px;
+}
+
+[data-code][data-editor-folding] [data-column-number] {
+ position: relative;
+ padding-right: calc(
+ var(--diffs-editor-fold-width) + var(--diffs-editor-fold-gap)
+ );
+}
+
+[data-file][data-disable-line-numbers]
+ [data-code][data-editor-folding]
+ [data-column-number] {
+ min-width: var(--diffs-editor-fold-width);
+ padding-right: var(--diffs-editor-fold-width);
+}
+
+[data-fold] {
+ position: absolute;
+ top: 0;
+ right: 0;
+ width: var(--diffs-editor-fold-width);
+ height: 1lh;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ pointer-events: none;
+ z-index: 2;
+}
+
+[data-fold-toggle] {
+ all: unset;
+ box-sizing: border-box;
+ width: var(--diffs-editor-fold-width);
+ height: 1lh;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--diffs-fg-number);
+ cursor: pointer;
+ opacity: 0;
+ pointer-events: auto;
+ transition: opacity 0.2s ease;
+}
+
+[data-fold-toggle][data-folded],
+[data-gutter]:hover [data-fold-toggle],
+[data-fold]:hover [data-fold-toggle],
+[data-fold-toggle]:focus-visible {
+ opacity: 0.5;
+}
+
+[data-fold] [data-fold-toggle]:hover,
+[data-fold] [data-fold-toggle]:focus-visible {
+ opacity: 0.75;
+}
+
+[data-fold] [data-fold-toggle]:focus-visible {
+ outline: 1px solid currentColor;
+ outline-offset: -2px;
+}
+
+[data-fold-indicator] {
+ display: inline-flex;
+ align-items: center;
+ height: 1lh;
+ margin-inline-start: 1ch;
+ vertical-align: top;
+ color: var(--diffs-fg);
+ user-select: none;
+}
+
+[data-fold-ellipsis] {
+ all: unset;
+ box-sizing: border-box;
+ width: 18px;
+ height: calc(1lh - 4px);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 4px;
+ color: var(--diffs-fg-number);
+ background-color: color-mix(in lab, var(--diffs-fg) 8%, transparent);
+ cursor: pointer;
+ transition:
+ color 80ms ease,
+ background-color 80ms ease;
+}
+
+[data-fold-ellipsis]:hover,
+[data-fold-ellipsis]:focus-visible {
+ color: var(--diffs-fg);
+ background-color: color-mix(in lab, var(--diffs-fg) 14%, transparent);
+}
+
+[data-fold-ellipsis]:focus-visible {
+ outline: 1px solid currentColor;
+ outline-offset: 1px;
+}
+
[data-editor-overlay] {
display: contents;
}
diff --git a/packages/diffs/src/editor/editor.ts b/packages/diffs/src/editor/editor.ts
index 64c2b4105..6ffd1ef81 100644
--- a/packages/diffs/src/editor/editor.ts
+++ b/packages/diffs/src/editor/editor.ts
@@ -14,6 +14,7 @@ import type {
FileDiffMetadata,
HighlightedToken,
LineAnnotation,
+ LineRange,
Position,
Range,
RenderRange,
@@ -29,6 +30,12 @@ import {
} from './command';
import editorCSS from './editor.css?inline';
import { EditStack } from './editStack';
+import {
+ computeIndentFoldingRanges,
+ isFoldingClosingDelimiter,
+ LineRangeIndex,
+ mergeHiddenLineRanges,
+} from './folding';
import {
type LanguageConfigMap,
resolveBlockCommentEdits,
@@ -103,7 +110,7 @@ import {
type SelectionActionContext,
SelectionActionWidget,
} from './selectionAction';
-import { createSpriteElement } from './sprite';
+import { createSpriteElement, getEditorIconSvg } from './sprite';
import {
cloneEditorState,
createStateStorage,
@@ -200,6 +207,8 @@ export interface EditorOptions {
roundedSelection?: boolean;
/** Highlight matching brackets near the caret, default is true. */
matchBrackets?: boolean;
+ /** Show code-fold controls for attached file components, default is true. */
+ folding?: boolean;
/**
* Controls auto-surround when typing quotes or brackets over a selection.
* Default is `"default"` (both quotes and brackets).
@@ -273,6 +282,7 @@ export class Editor implements DiffsEditor {
cacheKey: string;
textDocument: TextDocument;
documentVersion: number;
+ foldStateVersion: number;
selections: EditorSelection[] | undefined;
view: EditorState['view'];
completion: Promise;
@@ -313,6 +323,7 @@ export class Editor implements DiffsEditor {
#themeStyleElement?: HTMLStyleElement;
#spriteElement?: SVGSVGElement;
#fileContainer?: HTMLElement;
+ #codeElement?: HTMLElement;
#gutterElement?: HTMLElement;
#contentElement?: HTMLElement;
#overlayElement?: HTMLElement;
@@ -374,6 +385,23 @@ export class Editor implements DiffsEditor {
#retainSearchPanelFocus = false;
#fontRemeasureScheduled = false;
#themeSelectionRefreshFrame?: number;
+ #foldRanges: LineRange[] = [];
+ #foldRangesByStart = new Map();
+ #foldEndLines = new Set();
+ #foldClosingDelimiterLines = new Set();
+ #foldedStartLines = new Set();
+ #hiddenLineRanges: LineRange[] = [];
+ #hiddenLineIndex = new LineRangeIndex();
+ #hiddenLineRangeVersion = 0;
+ #foldStateVersion = 0;
+ #syncedFoldRangeHost?: DiffsEditableComponent;
+ #syncedFoldRangeVersion = -1;
+ #syncedFoldingEnabled?: boolean;
+ #foldRangeDocument?: TextDocument;
+ #foldRangeVersion = -1;
+ #renderViewGeneration = 0;
+ #pendingFoldFocus?: { line: number; generation: number };
+ #hasFoldIndicators = false;
#onDeferTokenize = (
lines: Map>,
@@ -395,7 +423,7 @@ export class Editor implements DiffsEditor {
if (line >= startingLine && line < endLine) {
const lineElement = this.#getLineElement(line);
if (lineElement !== undefined) {
- lineElement.replaceChildren(...renderLineTokens(tokens));
+ this.#replaceLineTokens(lineElement, tokens);
}
}
}
@@ -407,6 +435,7 @@ export class Editor implements DiffsEditor {
}
setOptions(options: EditorOptions): void {
+ const wasFoldingRequested = this.#options.folding !== false;
const previousStorageOption =
this.#options.persistStateStorage ?? 'inMemory';
const nextOptions = {
@@ -437,6 +466,9 @@ export class Editor implements DiffsEditor {
this.#stateStorageOption = undefined;
this.#pendingStateWrites.clear();
}
+ if (wasFoldingRequested !== (this.#options.folding !== false)) {
+ this.#handleFoldingOptionChange();
+ }
}
// Small typescript hack to prevent UnresolvedFile from being editable.
@@ -458,6 +490,12 @@ export class Editor implements DiffsEditor {
});
fileInstance.rerender();
}
+ if (
+ fileInstance.type !== 'file' ||
+ fileInstance.__setFoldRanges === undefined
+ ) {
+ this.#resetFoldingState();
+ }
this.#fileInstance = fileInstance;
this.#initialize();
this.#detach = fileInstance.attachEditor(this);
@@ -479,6 +517,7 @@ export class Editor implements DiffsEditor {
if (textDocument == null) {
throw new Error('Editor is not attached');
}
+ this.#unfoldFoldsIntersectingRanges(edits.map((edit) => edit.range));
// Only reposition focus and scroll when the editor already holds focus. A
// programmatic edit must not pull focus from another input the user is
// typing in; the selection state below is re-anchored either way.
@@ -580,8 +619,17 @@ export class Editor implements DiffsEditor {
getState(): EditorState {
const scrollContainer = this.#fileInstance?.getScrollContainer?.();
+ const foldRanges: LineRange[] = [];
+ if (this.#isFoldingEnabled && this.#foldedStartLines.size > 0) {
+ for (const range of this.#foldRanges) {
+ if (this.#foldedStartLines.has(range.startLine)) {
+ foldRanges.push({ ...range });
+ }
+ }
+ }
return {
selections: this.#selections,
+ foldRanges,
view:
scrollContainer !== undefined
? {
@@ -592,11 +640,16 @@ export class Editor implements DiffsEditor {
};
}
- setState({ selections, view }: EditorState): void {
+ setState({ selections, foldRanges, view }: EditorState): void {
if (this.#fileInstance === undefined || this.#textDocument === undefined) {
throw new Error('Editor is not attached');
}
this.#canMountSelectionAction = false;
+ this.#restoreFoldRanges(foldRanges ?? []);
+ const primarySelection = selections?.at(-1);
+ if (primarySelection !== undefined) {
+ this.#revealLineIfCollapsed(getCaretPosition(primarySelection).line);
+ }
this.#updateSelections(selections ?? []);
// When a saved view is present, honor its scroll offsets exactly. Scrolling
// the caret into view afterward would overwrite them whenever the caret
@@ -704,7 +757,8 @@ export class Editor implements DiffsEditor {
if (!recycle) {
this.#attachState.delivered = false;
}
- const hadFileInstance = this.#fileInstance != null;
+ const fileInstance = this.#fileInstance;
+ const hadFileInstance = fileInstance != null;
const shouldRestoreState = this.#isStatePersistenceEnabled;
this.#stateRestoreGeneration++;
this.#persistCurrentState();
@@ -737,8 +791,18 @@ export class Editor implements DiffsEditor {
this.#editorEventDisposes = undefined;
this.#selectEventDisposes?.forEach((dispose) => dispose());
this.#selectEventDisposes = undefined;
+ this.#removeFoldingControls();
this.#detach?.(recycle);
this.#detach = undefined;
+ if (!recycle) {
+ fileInstance?.__setFoldRanges?.([]);
+ this.#resetFoldingState();
+ }
+ this.#fileInstance = undefined;
+ this.#pendingFoldFocus = undefined;
+ this.#syncedFoldRangeHost = undefined;
+ this.#syncedFoldRangeVersion = -1;
+ this.#syncedFoldingEnabled = undefined;
// cache
this.#gutterWidthCache = undefined;
@@ -758,6 +822,7 @@ export class Editor implements DiffsEditor {
this.#spriteElement?.remove();
this.#spriteElement = undefined;
this.#fileContainer = undefined;
+ this.#codeElement = undefined;
this.#popoverManager?.cleanUp();
this.#popoverManager = undefined;
this.#gutterElement = undefined;
@@ -831,6 +896,7 @@ export class Editor implements DiffsEditor {
if (fileInstance == null) {
return;
}
+ this.#renderViewGeneration++;
const shadowRoot = fileContainer.shadowRoot;
if (shadowRoot == null) {
console.error('[editor] Could not find the shadow root.');
@@ -858,6 +924,7 @@ export class Editor implements DiffsEditor {
if (codeElement === undefined || contentEl === undefined) {
return;
}
+ this.#codeElement = codeElement;
this.#getPopoverManager().setViewportElements(fileContainer, codeElement);
@@ -921,6 +988,7 @@ export class Editor implements DiffsEditor {
new TextDocument(fileOrDiff.name, contents, languageId, 0, editStack);
this.#fileInfo = { name, lang, cacheKey };
this.#textDocument = textDocument;
+ this.#resetFoldingState();
if (persistedCacheKey !== undefined) {
this.#textDocumentCache.set(persistedCacheKey, textDocument);
persistedStateTarget = {
@@ -1032,6 +1100,9 @@ export class Editor implements DiffsEditor {
}
}
+ this.#refreshFoldingRanges();
+ this.#syncFoldedRangesToHost();
+ this.#renderFoldingControls();
this.#resetCache();
// The tokenizer is created once per attached document and reused across
@@ -1163,6 +1234,7 @@ export class Editor implements DiffsEditor {
this.#pendingStateRestore = undefined;
if (
textDocument.version === pendingRestore.documentVersion &&
+ this.#foldStateVersion === pendingRestore.foldStateVersion &&
this.#selections === pendingRestore.selections &&
state.view?.scrollLeft === pendingRestore.view?.scrollLeft &&
state.view?.scrollTop === pendingRestore.view?.scrollTop
@@ -1225,6 +1297,7 @@ export class Editor implements DiffsEditor {
): void {
const generation = ++this.#stateRestoreGeneration;
const documentVersion = textDocument.version;
+ const foldStateVersion = this.#foldStateVersion;
const selections = this.#selections;
const view = this.getState().view;
const applyState = (state: EditorState | undefined): void => {
@@ -1234,6 +1307,7 @@ export class Editor implements DiffsEditor {
generation !== this.#stateRestoreGeneration ||
this.#textDocument !== textDocument ||
textDocument.version !== documentVersion ||
+ this.#foldStateVersion !== foldStateVersion ||
this.#selections !== selections ||
currentView?.scrollLeft !== view?.scrollLeft ||
currentView?.scrollTop !== view?.scrollTop ||
@@ -1268,6 +1342,7 @@ export class Editor implements DiffsEditor {
cacheKey,
textDocument,
documentVersion,
+ foldStateVersion,
selections,
view,
completion: result.catch(() => {}),
@@ -1281,28 +1356,700 @@ export class Editor implements DiffsEditor {
}
}
- // Whether a zero-based document line has (or will have on scroll) a
- // rendered row. False only for lines hidden inside a collapsed unchanged
- // region of a diff host; hosts without collapsible regions treat every
- // line as renderable.
+ get #isFoldingEnabled(): boolean {
+ return (
+ this.#options.folding !== false &&
+ this.#fileInstance?.type === 'file' &&
+ this.#fileInstance.__setFoldRanges !== undefined
+ );
+ }
+
+ #resetFoldingState(): void {
+ this.#foldRanges = [];
+ this.#foldRangesByStart.clear();
+ this.#foldEndLines.clear();
+ this.#foldClosingDelimiterLines.clear();
+ this.#foldedStartLines.clear();
+ this.#hiddenLineRanges = [];
+ this.#hiddenLineIndex = new LineRangeIndex();
+ this.#hiddenLineRangeVersion++;
+ this.#foldStateVersion++;
+ this.#foldRangeDocument = undefined;
+ this.#foldRangeVersion = -1;
+ }
+
+ #setHiddenLineRanges(ranges: LineRange[]): boolean {
+ if (
+ ranges.length === this.#hiddenLineRanges.length &&
+ ranges.every((range, index) => {
+ const previous = this.#hiddenLineRanges[index];
+ return (
+ previous?.startLine === range.startLine &&
+ previous.endLine === range.endLine
+ );
+ })
+ ) {
+ return false;
+ }
+ this.#hiddenLineRanges = ranges;
+ this.#hiddenLineIndex = new LineRangeIndex(ranges);
+ this.#hiddenLineRangeVersion++;
+ return true;
+ }
+
+ // Fold candidates are cached by document version and computed in one pass.
+ // Active folded headers stay separate so nested state survives an outer
+ // fold being toggled off.
+ #refreshFoldingRanges(force = false): boolean {
+ const textDocument = this.#textDocument;
+ if (!this.#isFoldingEnabled || textDocument === undefined) {
+ return false;
+ }
+ if (
+ !force &&
+ this.#foldRangeDocument === textDocument &&
+ this.#foldRangeVersion === textDocument.version
+ ) {
+ return false;
+ }
+
+ const hadFoldedRanges = this.#foldedStartLines.size > 0;
+ this.#foldRanges = computeIndentFoldingRanges(
+ textDocument,
+ this.#metrics.tabSize
+ );
+ const rangesByStart = new Map();
+ const endLines = new Set();
+ const closingDelimiterLines = new Set();
+ for (const range of this.#foldRanges) {
+ rangesByStart.set(range.startLine, range);
+ endLines.add(range.endLine);
+ const closingLine = range.endLine + 1;
+ if (
+ closingLine < textDocument.lineCount &&
+ isFoldingClosingDelimiter(textDocument.getLineText(closingLine))
+ ) {
+ closingDelimiterLines.add(closingLine);
+ }
+ }
+ this.#foldRangesByStart = rangesByStart;
+ this.#foldEndLines = endLines;
+ this.#foldClosingDelimiterLines = closingDelimiterLines;
+ for (const startLine of this.#foldedStartLines) {
+ if (!this.#foldRangesByStart.has(startLine)) {
+ this.#foldedStartLines.delete(startLine);
+ }
+ }
+ if (hadFoldedRanges) {
+ this.#foldStateVersion++;
+ }
+ this.#foldRangeDocument = textDocument;
+ this.#foldRangeVersion = textDocument.version;
+ return this.#setHiddenLineRanges(
+ mergeHiddenLineRanges(this.#foldRanges, this.#foldedStartLines)
+ );
+ }
+
+ #restoreFoldRanges(foldRanges: readonly LineRange[]): void {
+ if (!this.#isFoldingEnabled) {
+ return;
+ }
+ this.#refreshFoldingRanges();
+
+ const nextFoldedStartLines = new Set();
+ for (const range of foldRanges) {
+ if (
+ range == null ||
+ !Number.isInteger(range.startLine) ||
+ !Number.isInteger(range.endLine)
+ ) {
+ continue;
+ }
+ const candidate = this.#foldRangesByStart.get(range.startLine);
+ if (candidate?.endLine === range.endLine) {
+ nextFoldedStartLines.add(range.startLine);
+ }
+ }
+
+ let changed = nextFoldedStartLines.size !== this.#foldedStartLines.size;
+ if (!changed) {
+ for (const startLine of nextFoldedStartLines) {
+ if (!this.#foldedStartLines.has(startLine)) {
+ changed = true;
+ break;
+ }
+ }
+ }
+ if (!changed) {
+ return;
+ }
+
+ this.#foldedStartLines = nextFoldedStartLines;
+ this.#foldStateVersion++;
+ this.#refreshFoldedView();
+ }
+
+ #syncFoldedRangesToHost(): void {
+ const host = this.#fileInstance;
+ if (host?.type !== 'file' || host.__setFoldRanges === undefined) {
+ return;
+ }
+ const foldingEnabled = this.#isFoldingEnabled;
+ if (
+ this.#syncedFoldRangeHost === host &&
+ this.#syncedFoldRangeVersion === this.#hiddenLineRangeVersion &&
+ this.#syncedFoldingEnabled === foldingEnabled
+ ) {
+ return;
+ }
+ host.__setFoldRanges(foldingEnabled ? this.#hiddenLineRanges : []);
+ this.#syncedFoldRangeHost = host;
+ this.#syncedFoldRangeVersion = this.#hiddenLineRangeVersion;
+ this.#syncedFoldingEnabled = foldingEnabled;
+ }
+
+ #removeFoldingControls(): void {
+ this.#codeElement?.removeAttribute('data-editor-folding');
+ this.#gutterElement
+ ?.querySelectorAll('[data-fold]')
+ .forEach((element) => element.remove());
+ if (this.#hasFoldIndicators) {
+ this.#contentElement
+ ?.querySelectorAll('[data-fold-indicator]')
+ .forEach((element) => element.remove());
+ this.#hasFoldIndicators = false;
+ }
+ }
+
+ #initializeFoldButton(button: HTMLButtonElement): void {
+ button.addEventListener('pointerdown', (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ });
+ button.addEventListener('keydown', (event) => {
+ if (event.key !== 'Escape') {
+ event.stopPropagation();
+ }
+ });
+ button.addEventListener('click', (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ const lineIndex = Number(
+ button.closest('[data-line-index]')?.dataset.lineIndex
+ );
+ if (Number.isInteger(lineIndex)) {
+ this.#toggleFold(lineIndex, event.detail === 0);
+ }
+ });
+ }
+
+ #renderFoldingControls(): void {
+ const codeElement = this.#codeElement;
+ const gutterElement = this.#gutterElement;
+ const contentElement = this.#contentElement;
+ const textDocument = this.#textDocument;
+ if (
+ !this.#isFoldingEnabled ||
+ codeElement === undefined ||
+ gutterElement === undefined ||
+ contentElement === undefined ||
+ textDocument === undefined
+ ) {
+ this.#removeFoldingControls();
+ return;
+ }
+
+ codeElement.setAttribute('data-editor-folding', '');
+ const foldedLineElements = new Map();
+ if (this.#foldedStartLines.size === 0) {
+ if (this.#hasFoldIndicators) {
+ contentElement
+ .querySelectorAll('[data-fold-indicator]')
+ .forEach((element) => element.remove());
+ this.#hasFoldIndicators = false;
+ }
+ } else {
+ let hasFoldIndicators = false;
+ for (const child of contentElement.children) {
+ if (!(child instanceof HTMLElement)) {
+ continue;
+ }
+ const lineIndex = Number(child.dataset.lineIndex);
+ const isFolded =
+ Number.isInteger(lineIndex) &&
+ this.#foldedStartLines.has(lineIndex) &&
+ this.#foldRangesByStart.has(lineIndex);
+ const indicator = child.querySelector(
+ ':scope > [data-fold-indicator]'
+ );
+ if (!isFolded) {
+ indicator?.remove();
+ continue;
+ }
+ foldedLineElements.set(lineIndex, child);
+ hasFoldIndicators ||= indicator !== null;
+ }
+ this.#hasFoldIndicators = hasFoldIndicators;
+ }
+
+ for (const child of gutterElement.children) {
+ if (!(child instanceof HTMLElement)) {
+ continue;
+ }
+ const lineIndex = Number(child.dataset.lineIndex);
+ const existingZone = child.querySelector(
+ ':scope > [data-fold]'
+ );
+ if (
+ child.dataset.columnNumber === undefined ||
+ !Number.isInteger(lineIndex)
+ ) {
+ existingZone?.remove();
+ continue;
+ }
+
+ const range = this.#foldRangesByStart.get(lineIndex);
+ if (range === undefined) {
+ existingZone?.remove();
+ continue;
+ }
+
+ const folded = this.#foldedStartLines.has(lineIndex);
+ const zone = existingZone ?? h('span', { dataset: 'fold' }, child);
+ let button = zone.querySelector(
+ ':scope > [data-fold-toggle]'
+ );
+ let buttonCreated = false;
+ if (button === null) {
+ button = h(
+ 'button',
+ {
+ dataset: 'foldToggle',
+ type: 'button',
+ },
+ zone
+ );
+ buttonCreated = true;
+ this.#initializeFoldButton(button);
+ }
+
+ const wasFolded = button.dataset.folded !== undefined;
+ button.ariaExpanded = folded ? 'false' : 'true';
+ button.ariaLabel = `${folded ? 'Unfold' : 'Fold'} line ${lineIndex + 1}`;
+ button.title = folded ? 'Unfold' : 'Fold';
+ button.toggleAttribute('data-folded', folded);
+ if (buttonCreated || wasFolded !== folded) {
+ button.innerHTML = getEditorIconSvg(
+ folded ? 'chevron-right' : 'chevron-down',
+ 14
+ );
+ }
+
+ if (!folded) {
+ continue;
+ }
+
+ const lineElement = foldedLineElements.get(lineIndex);
+ if (lineElement === undefined) {
+ continue;
+ }
+
+ const indicator =
+ lineElement.querySelector(
+ ':scope > [data-fold-indicator]'
+ ) ??
+ h(
+ 'span',
+ {
+ dataset: 'foldIndicator',
+ contentEditable: 'false',
+ spellcheck: false,
+ },
+ lineElement
+ );
+ this.#hasFoldIndicators = true;
+ indicator.setAttribute('contenteditable', 'false');
+ indicator.dataset.foldCharacter = textDocument
+ .getLineLength(lineIndex)
+ .toString();
+ let ellipsis = indicator.querySelector(
+ ':scope > [data-fold-ellipsis]'
+ );
+ if (ellipsis === null) {
+ ellipsis = h('button', {
+ dataset: 'foldEllipsis',
+ type: 'button',
+ innerHTML: getEditorIconSvg('ellipsis', 12),
+ });
+ indicator.prepend(ellipsis);
+ this.#initializeFoldButton(ellipsis);
+ }
+ ellipsis.ariaLabel = `Unfold line ${lineIndex + 1}`;
+ ellipsis.title = 'Unfold';
+ }
+ this.#restorePendingFoldFocus();
+ }
+
+ #refreshFoldedView(updateHiddenLineRanges = true): void {
+ if (updateHiddenLineRanges) {
+ this.#setHiddenLineRanges(
+ mergeHiddenLineRanges(this.#foldRanges, this.#foldedStartLines)
+ );
+ }
+ this.#syncFoldedRangesToHost();
+ this.#resetCache();
+ this.#renderFoldingControls();
+ if (this.#selections !== undefined) {
+ this.#updateSelections(this.#selections);
+ }
+ this.#markerRenderer?.removePopover();
+ }
+
+ #toggleFold(startLine: number, restoreFocus = false): void {
+ this.#refreshFoldingRanges();
+ const range = this.#foldRangesByStart.get(startLine);
+ const textDocument = this.#textDocument;
+ if (range === undefined || textDocument === undefined) {
+ return;
+ }
+ if (restoreFocus) {
+ this.#pendingFoldFocus = {
+ line: startLine,
+ generation: this.#renderViewGeneration + 1,
+ };
+ }
+
+ if (this.#foldedStartLines.has(startLine)) {
+ this.#foldedStartLines.delete(startLine);
+ } else {
+ this.#foldedStartLines.add(startLine);
+ }
+ this.#foldStateVersion++;
+ this.#setHiddenLineRanges(
+ mergeHiddenLineRanges(this.#foldRanges, this.#foldedStartLines)
+ );
+
+ // A selection whose caret is swallowed by the new fold maps to the end of
+ // its header, matching the display-map behavior of native editors.
+ if (this.#selections !== undefined) {
+ let didMoveCaret = false;
+ const nextSelections = this.#selections.map(
+ (selection) => {
+ if (
+ !this.#hiddenLineIndex.isHidden(getCaretPosition(selection).line)
+ ) {
+ return selection;
+ }
+ didMoveCaret = true;
+ const caret = {
+ line: startLine,
+ character: textDocument.getLineLength(startLine),
+ };
+ return { start: caret, end: caret, direction: DirectionNone };
+ }
+ );
+ if (didMoveCaret) {
+ this.#selections = nextSelections;
+ }
+ }
+
+ this.#refreshFoldedView(false);
+ }
+
+ #restorePendingFoldFocus(): void {
+ const pending = this.#pendingFoldFocus;
+ if (
+ pending === undefined ||
+ this.#renderViewGeneration < pending.generation
+ ) {
+ return;
+ }
+ const toggle = this.#gutterElement?.querySelector(
+ `[data-line-index="${pending.line}"] [data-fold-toggle]`
+ );
+ if (toggle?.isConnected !== true) {
+ return;
+ }
+ toggle.focus({ preventScroll: true });
+ this.#pendingFoldFocus = undefined;
+ }
+
+ #unfoldFoldsIntersectingRanges(ranges: readonly Range[]): void {
+ if (
+ !this.#isFoldingEnabled ||
+ this.#foldedStartLines.size === 0 ||
+ this.#textDocument === undefined
+ ) {
+ return;
+ }
+ const normalizedRanges = ranges.map((range) => {
+ const start = this.#textDocument!.normalizePosition(range.start);
+ const end = this.#textDocument!.normalizePosition(range.end);
+ return {
+ startLine: Math.min(start.line, end.line),
+ endLine: Math.max(start.line, end.line),
+ };
+ });
+ let changed = false;
+ for (const startLine of this.#foldedStartLines) {
+ const foldRange = this.#foldRangesByStart.get(startLine);
+ if (
+ foldRange !== undefined &&
+ normalizedRanges.some(
+ (range) =>
+ range.endLine >= foldRange.startLine + 1 &&
+ range.startLine <= foldRange.endLine
+ )
+ ) {
+ this.#foldedStartLines.delete(startLine);
+ changed = true;
+ }
+ }
+ if (changed) {
+ this.#foldStateVersion++;
+ this.#refreshFoldedView();
+ }
+ }
+
+ #unfoldLine(line: number): boolean {
+ if (!this.#isFoldingEnabled) {
+ return false;
+ }
+ let changed = false;
+ for (const startLine of this.#foldedStartLines) {
+ const range = this.#foldRangesByStart.get(startLine);
+ if (
+ range !== undefined &&
+ line > range.startLine &&
+ line <= range.endLine
+ ) {
+ this.#foldedStartLines.delete(startLine);
+ changed = true;
+ }
+ }
+ if (changed) {
+ this.#foldStateVersion++;
+ this.#refreshFoldedView();
+ }
+ return changed;
+ }
+
+ #remapFoldingAfterChange(change: TextDocumentChange): boolean {
+ if (!this.#isFoldingEnabled) {
+ return false;
+ }
+
+ const shouldRefreshRanges = this.#changeMayAffectFolding(change);
+ this.#remapFoldedRangesAfterChange(change);
+
+ if (shouldRefreshRanges) {
+ this.#foldRangeVersion = -1;
+ this.#refreshFoldingRanges(true);
+ } else if (this.#textDocument !== undefined) {
+ this.#foldRangeDocument = this.#textDocument;
+ this.#foldRangeVersion = this.#textDocument.version;
+ }
+ return shouldRefreshRanges;
+ }
+
+ // Remap active ranges through each line-changing edit in document order.
+ // Aggregate change bounds are insufficient for multi-edit batches because
+ // folds between edits can stay valid even when the batch has a net delta.
+ #remapFoldedRangesAfterChange(change: TextDocumentChange): void {
+ const lineChanges =
+ change.changedLineChanges ??
+ ([[change.startLine, change.endLine, change.lineDelta]] as const);
+ if (
+ this.#foldedStartLines.size === 0 ||
+ !lineChanges.some(([, , lineDelta]) => lineDelta !== 0)
+ ) {
+ return;
+ }
+
+ let activeRanges = [...this.#foldedStartLines]
+ .map((startLine) => this.#foldRangesByStart.get(startLine))
+ .filter((range): range is LineRange => range !== undefined)
+ .map((range) => ({ ...range }));
+
+ for (const [
+ startLine,
+ endLine,
+ lineDelta,
+ startCharacter,
+ endCharacter,
+ ] of lineChanges) {
+ if (lineDelta === 0) {
+ continue;
+ }
+ const oldEndLine = Math.max(startLine, endLine - lineDelta);
+ const insertsBeforeLine =
+ lineDelta > 0 && startCharacter === 0 && endCharacter === 0;
+ const deletesBeforeLine = lineDelta < 0 && endCharacter === 0;
+ const nextRanges: LineRange[] = [];
+ for (const range of activeRanges) {
+ if (range.endLine < startLine) {
+ nextRanges.push(range);
+ } else if (
+ range.startLine > oldEndLine ||
+ (insertsBeforeLine && range.startLine >= startLine) ||
+ (deletesBeforeLine && range.startLine === oldEndLine)
+ ) {
+ nextRanges.push({
+ startLine: range.startLine + lineDelta,
+ endLine: range.endLine + lineDelta,
+ });
+ }
+ }
+ activeRanges = nextRanges;
+ }
+
+ const nextFoldedStartLines = new Set(
+ activeRanges.map((range) => range.startLine)
+ );
+ let changed = nextFoldedStartLines.size !== this.#foldedStartLines.size;
+ if (!changed) {
+ for (const startLine of nextFoldedStartLines) {
+ if (!this.#foldedStartLines.has(startLine)) {
+ changed = true;
+ break;
+ }
+ }
+ }
+ if (changed) {
+ this.#foldedStartLines = nextFoldedStartLines;
+ this.#foldStateVersion++;
+ }
+ }
+
+ // Ordinary character edits cannot change indentation folds. Re-scan only
+ // when an edit touches indentation, blank headers/ends, closing delimiters,
+ // or the document's line structure.
+ #changeMayAffectFolding(change: TextDocumentChange): boolean {
+ const textDocument = this.#textDocument;
+ if (textDocument === undefined || change.lineDelta !== 0) {
+ return true;
+ }
+
+ const lineChanges =
+ change.changedLineChanges ??
+ ([
+ [
+ change.startLine,
+ change.endLine,
+ change.lineDelta,
+ change.startCharacter,
+ change.endCharacter,
+ ],
+ ] as const);
+ for (const [
+ startLine,
+ endLine,
+ lineDelta,
+ startCharacter = 0,
+ endCharacter = startCharacter,
+ ] of lineChanges) {
+ if (lineDelta !== 0) {
+ return true;
+ }
+ for (let line = startLine; line <= endLine; line++) {
+ const text = textDocument.getLineText(line);
+ const indentationLength = text.length - text.trimStart().length;
+ const trimmedText = text.trim();
+ if (
+ startCharacter <= indentationLength ||
+ endCharacter <= indentationLength ||
+ this.#foldClosingDelimiterLines.has(line) ||
+ isFoldingClosingDelimiter(trimmedText) ||
+ (trimmedText.length === 0 &&
+ (this.#foldRangesByStart.has(line) || this.#foldEndLines.has(line)))
+ ) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ #handleFoldingOptionChange(): void {
+ if (this.#isFoldingEnabled) {
+ this.#foldRangeVersion = -1;
+ this.#refreshFoldingRanges(true);
+ } else {
+ if (this.#foldedStartLines.size > 0) {
+ this.#foldStateVersion++;
+ }
+ this.#foldRanges = [];
+ this.#foldRangesByStart.clear();
+ this.#foldEndLines.clear();
+ this.#foldClosingDelimiterLines.clear();
+ this.#foldedStartLines.clear();
+ this.#setHiddenLineRanges([]);
+ this.#foldRangeDocument = undefined;
+ this.#foldRangeVersion = -1;
+ }
+ this.#syncFoldedRangesToHost();
+ this.#resetCache();
+ this.#gutterWidthCache = undefined;
+ this.#contentWidthCache = undefined;
+ this.#renderFoldingControls();
+ if (this.#selections !== undefined) {
+ this.#updateSelections(this.#selections);
+ }
+ }
+
+ // Whether a zero-based document line has (or will have on scroll) a rendered
+ // row. File folds and collapsed diff regions share this visibility gate.
#isLineRenderable(line: number): boolean {
- return this.#fileInstance?.isLineRenderable?.(line + 1) ?? true;
+ return (
+ (!this.#isFoldingEnabled || !this.#hiddenLineIndex.isHidden(line)) &&
+ (this.#fileInstance?.isLineRenderable?.(line + 1) ?? true)
+ );
}
// Fold-skip resolver for vertical caret motion (zero-based lines), or
- // undefined for hosts without collapsible regions so motion stays plain
- // line arithmetic.
+ // undefined when neither the editor nor its host hides document lines.
get #resolveRenderableLine(): ResolveRenderableLine | undefined {
const fileInstance = this.#fileInstance;
- if (fileInstance?.getNearestRenderableLine == null) {
+ const textDocument = this.#textDocument;
+ const hasEditorFolds =
+ this.#isFoldingEnabled && this.#hiddenLineRanges.length > 0;
+ if (!hasEditorFolds && fileInstance?.getNearestRenderableLine == null) {
return undefined;
}
return (line, direction) => {
- const nearest = fileInstance.getNearestRenderableLine!(
- line + 1,
- direction
- );
- return nearest == null ? undefined : nearest - 1;
+ let candidate = line;
+ for (let attempt = 0; attempt < 4; attempt++) {
+ if (hasEditorFolds) {
+ const nearest = this.#hiddenLineIndex.nearestVisibleLine(
+ candidate,
+ direction,
+ textDocument?.lineCount ?? 0
+ );
+ if (nearest === undefined) {
+ return undefined;
+ }
+ candidate = nearest;
+ }
+ if (fileInstance?.getNearestRenderableLine == null) {
+ return candidate;
+ }
+ const nearest = fileInstance.getNearestRenderableLine(
+ candidate + 1,
+ direction
+ );
+ if (nearest == null) {
+ return undefined;
+ }
+ const hostCandidate = nearest - 1;
+ if (
+ hostCandidate === candidate &&
+ !this.#hiddenLineIndex.isHidden(hostCandidate)
+ ) {
+ return hostCandidate;
+ }
+ candidate = hostCandidate;
+ }
+ return this.#isLineRenderable(candidate) ? candidate : undefined;
};
}
@@ -1310,6 +2057,7 @@ export class Editor implements DiffsEditor {
// moves) may land inside a collapsed region; expand it minimally so the
// caret's row can render before the scroll below retries toward it.
#revealLineIfCollapsed(line: number): void {
+ this.#unfoldLine(line);
if (!this.#isLineRenderable(line)) {
this.#fileInstance?.revealLine?.(line + 1);
}
@@ -2982,6 +3730,36 @@ export class Editor implements DiffsEditor {
});
}
+ // Retokenizing a folded header keeps its focused inline indicator attached.
+ #replaceLineTokens(
+ lineElement: HTMLElement,
+ tokens: Array
+ ): void {
+ const lastElement = lineElement.lastElementChild;
+ const foldIndicator =
+ lastElement instanceof HTMLElement &&
+ lastElement.dataset.foldIndicator !== undefined
+ ? lastElement
+ : undefined;
+ if (foldIndicator === undefined) {
+ lineElement.replaceChildren(...renderLineTokens(tokens));
+ return;
+ }
+
+ let child = lineElement.firstChild;
+ while (child !== null && child !== foldIndicator) {
+ const next = child.nextSibling;
+ child.remove();
+ child = next;
+ }
+ foldIndicator.before(...renderLineTokens(tokens));
+ const lineIndex = Number(lineElement.dataset.lineIndex);
+ if (Number.isInteger(lineIndex)) {
+ foldIndicator.dataset.foldCharacter =
+ this.#textDocument?.getLineLength(lineIndex).toString() ?? '0';
+ }
+ }
+
#rerender(
change: TextDocumentChange,
newLineAnnotations?: DiffLineAnnotation[],
@@ -3038,7 +3816,7 @@ export class Editor implements DiffsEditor {
const lineIndex = lineNumber - 1;
if (dirtyLineIndexes.has(lineIndex)) {
const tokens = dirtyLines.get(lineIndex)!;
- child.replaceChildren(...renderLineTokens(tokens));
+ this.#replaceLineTokens(child, tokens);
dirtyLineIndexes.delete(lineIndex);
if (dirtyLineIndexes.size === 0) {
break;
@@ -4820,6 +5598,8 @@ export class Editor implements DiffsEditor {
newLineAnnotations?: DiffLineAnnotation[],
options?: { skipSearchRefresh?: boolean; skipFocus?: boolean }
) {
+ const foldingControlsChanged = this.#remapFoldingAfterChange(change);
+
const fileRef = this.getFile();
const onChange = this.#options.onChange;
if (fileRef !== undefined && onChange !== undefined) {
@@ -4947,6 +5727,10 @@ export class Editor implements DiffsEditor {
}
}
this.#rerender(change, newLineAnnotations, renderRange, shouldUpdateBuffer);
+ this.#syncFoldedRangesToHost();
+ if (foldingControlsChanged) {
+ this.#renderFoldingControls();
+ }
if (
options?.skipSearchRefresh !== true &&
diff --git a/packages/diffs/src/editor/folding.ts b/packages/diffs/src/editor/folding.ts
new file mode 100644
index 000000000..fe6a399da
--- /dev/null
+++ b/packages/diffs/src/editor/folding.ts
@@ -0,0 +1,294 @@
+import type { LineRange } from '../types';
+import type { TextDocument } from './textDocument';
+
+const CLOSING_DELIMITER_ONLY = /^[}\])]+[;,]?$/;
+
+/**
+ * Stores hidden ranges as numeric arrays with prefix counts so visibility and
+ * document-to-visible-line mappings do not scan every folded range.
+ */
+export class LineRangeIndex {
+ readonly #starts: number[] = [];
+ readonly #ends: number[] = [];
+ readonly #prefixHiddenCounts: number[] = [0];
+ readonly #visibleStarts: number[] = [];
+
+ constructor(ranges: readonly LineRange[] = []) {
+ let previousStart = -Infinity;
+ let rangesAreOrdered = true;
+
+ for (const range of ranges) {
+ if (range.startLine < 0 || range.endLine < range.startLine) {
+ continue;
+ }
+ if (range.startLine < previousStart) {
+ rangesAreOrdered = false;
+ break;
+ }
+
+ previousStart = range.startLine;
+ this.#appendRange(range.startLine, range.endLine);
+ }
+
+ if (rangesAreOrdered) {
+ return;
+ }
+
+ this.#starts.length = 0;
+ this.#ends.length = 0;
+ this.#prefixHiddenCounts.length = 1;
+ this.#visibleStarts.length = 0;
+
+ const sortedRanges = ranges
+ .filter(
+ (range) => range.startLine >= 0 && range.endLine >= range.startLine
+ )
+ .sort((left, right) => {
+ const startDifference = left.startLine - right.startLine;
+ return startDifference === 0
+ ? right.endLine - left.endLine
+ : startDifference;
+ });
+
+ for (const range of sortedRanges) {
+ this.#appendRange(range.startLine, range.endLine);
+ }
+ }
+
+ #appendRange(startLine: number, endLine: number): void {
+ const previousIndex = this.#ends.length - 1;
+ if (previousIndex >= 0 && startLine <= this.#ends[previousIndex] + 1) {
+ const previousEnd = this.#ends[previousIndex];
+ if (endLine > previousEnd) {
+ this.#ends[previousIndex] = endLine;
+ this.#prefixHiddenCounts[previousIndex + 1] += endLine - previousEnd;
+ }
+ return;
+ }
+
+ const hiddenBefore =
+ this.#prefixHiddenCounts[this.#prefixHiddenCounts.length - 1];
+ this.#starts.push(startLine);
+ this.#ends.push(endLine);
+ this.#visibleStarts.push(startLine - hiddenBefore);
+ this.#prefixHiddenCounts.push(hiddenBefore + endLine - startLine + 1);
+ }
+
+ #containingRangeIndex(line: number): number {
+ const index = upperBound(this.#starts, line) - 1;
+ return index >= 0 && line <= this.#ends[index] ? index : -1;
+ }
+
+ isHidden(line: number): boolean {
+ return this.#containingRangeIndex(line) >= 0;
+ }
+
+ containingRange(line: number): LineRange | undefined {
+ const index = this.#containingRangeIndex(line);
+ if (index < 0) {
+ return undefined;
+ }
+ return {
+ startLine: this.#starts[index],
+ endLine: this.#ends[index],
+ };
+ }
+
+ lineAfterHiddenRange(line: number): number | undefined {
+ const index = this.#containingRangeIndex(line);
+ return index < 0 ? undefined : this.#ends[index] + 1;
+ }
+
+ hiddenCountBefore(lineExclusive: number): number {
+ const index = upperBound(this.#starts, lineExclusive - 1) - 1;
+ if (index < 0) {
+ return 0;
+ }
+
+ const hiddenBefore = this.#prefixHiddenCounts[index];
+ return (
+ hiddenBefore +
+ Math.min(
+ this.#ends[index] - this.#starts[index] + 1,
+ lineExclusive - this.#starts[index]
+ )
+ );
+ }
+
+ visibleLineCount(total: number): number {
+ const normalizedTotal = Math.max(0, Math.trunc(total));
+ return normalizedTotal - this.hiddenCountBefore(normalizedTotal);
+ }
+
+ lineAtVisibleIndex(index: number, total: number): number | undefined {
+ if (
+ !Number.isInteger(index) ||
+ index < 0 ||
+ index >= this.visibleLineCount(total)
+ ) {
+ return undefined;
+ }
+
+ const precedingRangeCount = upperBound(this.#visibleStarts, index);
+ return index + this.#prefixHiddenCounts[precedingRangeCount];
+ }
+
+ nearestVisibleLine(
+ line: number,
+ direction: 'up' | 'down',
+ total: number
+ ): number | undefined {
+ const normalizedTotal = Math.max(0, Math.trunc(total));
+ if (normalizedTotal === 0) {
+ return undefined;
+ }
+
+ let candidate = Math.trunc(line);
+ if (direction === 'down') {
+ if (candidate < 0) {
+ candidate = 0;
+ } else if (candidate >= normalizedTotal) {
+ return undefined;
+ }
+ } else if (candidate >= normalizedTotal) {
+ candidate = normalizedTotal - 1;
+ } else if (candidate < 0) {
+ return undefined;
+ }
+
+ const rangeIndex = this.#containingRangeIndex(candidate);
+ if (rangeIndex < 0) {
+ return candidate;
+ }
+
+ const nearest =
+ direction === 'down'
+ ? this.#ends[rangeIndex] + 1
+ : this.#starts[rangeIndex] - 1;
+ return nearest >= 0 && nearest < normalizedTotal ? nearest : undefined;
+ }
+}
+
+export function isFoldingClosingDelimiter(text: string): boolean {
+ return CLOSING_DELIMITER_ONLY.test(text.trim());
+}
+
+/**
+ * Finds indentation folds by comparing adjacent nonblank lines. Blank lines
+ * inside a block are included, while blank lines after its last content line
+ * and standalone closing delimiters are left visible.
+ */
+export function computeIndentFoldingRanges(
+ textDocument: Pick, 'lineCount' | 'getLineText'>,
+ tabSize = 2
+): LineRange[] {
+ const integerTabSize = Math.trunc(tabSize);
+ const normalizedTabSize =
+ Number.isFinite(integerTabSize) && integerTabSize > 0 ? integerTabSize : 2;
+ const ranges: Array<{ startLine: number; endLine: number }> = [];
+ const openRanges: Array<{
+ indent: number;
+ range: { startLine: number; endLine: number };
+ }> = [];
+ let previousLine = -1;
+ let previousIndent = 0;
+
+ for (let line = 0; line < textDocument.lineCount; line++) {
+ const text = textDocument.getLineText(line);
+ const trimmedText = text.trim();
+ if (trimmedText.length === 0) {
+ continue;
+ }
+
+ let indent = 0;
+ for (const character of text) {
+ if (character === ' ') {
+ indent++;
+ } else if (character === '\t') {
+ indent += normalizedTabSize - (indent % normalizedTabSize);
+ } else {
+ break;
+ }
+ }
+
+ if (previousLine >= 0) {
+ while (openRanges.length > 0) {
+ const openRange = openRanges[openRanges.length - 1];
+ if (openRange.indent < indent) {
+ break;
+ }
+
+ openRanges.pop();
+ openRange.range.endLine =
+ openRange.indent === indent &&
+ CLOSING_DELIMITER_ONLY.test(trimmedText)
+ ? line - 1
+ : previousLine;
+ }
+
+ if (indent > previousIndent) {
+ const range = {
+ startLine: previousLine,
+ endLine: line,
+ };
+ ranges.push(range);
+ openRanges.push({ indent: previousIndent, range });
+ }
+ }
+
+ previousLine = line;
+ previousIndent = indent;
+ }
+
+ if (previousLine >= 0) {
+ for (const openRange of openRanges) {
+ openRange.range.endLine = previousLine;
+ }
+ }
+
+ return ranges;
+}
+
+/**
+ * Converts folded headers to their hidden bodies and coalesces overlapping or
+ * adjacent bodies into ranges suitable for visibility queries.
+ */
+export function mergeHiddenLineRanges(
+ foldRanges: readonly LineRange[],
+ foldedStartLines: ReadonlySet
+): LineRange[] {
+ const merged: Array<{ startLine: number; endLine: number }> = [];
+
+ for (const foldRange of foldRanges) {
+ if (
+ !foldedStartLines.has(foldRange.startLine) ||
+ foldRange.endLine <= foldRange.startLine
+ ) {
+ continue;
+ }
+
+ const startLine = foldRange.startLine + 1;
+ const previous = merged[merged.length - 1];
+ if (previous != null && startLine <= previous.endLine + 1) {
+ previous.endLine = Math.max(previous.endLine, foldRange.endLine);
+ } else {
+ merged.push({ startLine, endLine: foldRange.endLine });
+ }
+ }
+
+ return merged;
+}
+
+function upperBound(values: readonly number[], target: number): number {
+ let low = 0;
+ let high = values.length;
+ while (low < high) {
+ const middle = low + ((high - low) >> 1);
+ if (values[middle] <= target) {
+ low = middle + 1;
+ } else {
+ high = middle;
+ }
+ }
+ return low;
+}
diff --git a/packages/diffs/src/editor/selection.ts b/packages/diffs/src/editor/selection.ts
index 126c22a95..74cbdca24 100644
--- a/packages/diffs/src/editor/selection.ts
+++ b/packages/diffs/src/editor/selection.ts
@@ -2409,6 +2409,20 @@ function boundaryToPosition(node: Node, offset: number): Position | null {
return null;
}
+ let foldIndicator = host;
+ while (
+ foldIndicator !== null &&
+ foldIndicator.dataset.foldIndicator === undefined
+ ) {
+ foldIndicator = foldIndicator.parentElement;
+ }
+ if (foldIndicator != null) {
+ const character = parseInt(foldIndicator.dataset.foldCharacter ?? '', 10);
+ if (!Number.isNaN(character)) {
+ return { line, character };
+ }
+ }
+
if (node.nodeType === 3) {
if (node.parentElement === null) {
return null;
@@ -2638,6 +2652,10 @@ function getLineChildEnd(
if (el.tagName !== 'SPAN' && el.tagName !== 'BR') {
return 0;
}
+ if (el.dataset.foldIndicator !== undefined) {
+ const character = parseInt(el.dataset.foldCharacter ?? '', 10);
+ return Number.isNaN(character) ? 0 : character;
+ }
const base = getCharacterIndex(el);
if (base !== undefined) {
return base + (el.textContent?.length ?? 0);
diff --git a/packages/diffs/src/editor/sprite.ts b/packages/diffs/src/editor/sprite.ts
index 71fc0471f..76b608f7d 100644
--- a/packages/diffs/src/editor/sprite.ts
+++ b/packages/diffs/src/editor/sprite.ts
@@ -2,19 +2,20 @@ export type SVGSpriteNames =
| 'arrow-down'
| 'arrow-up'
| 'case'
+ | 'chevron-down'
+ | 'chevron-right'
| 'close'
+ | 'ellipsis'
| 'regex'
| 'whole-word'
| 'replace'
| 'replace-all';
-// Icon artwork is sourced from `@pierre/icons` (IconSearch, IconX,
-// IconArrowRight, IconType, IconTypeWord, IconRegex, IconReplace,
-// IconReplaceAll) so the editor matches the rest of the product. The arrow glyph
-// is the full-size right arrow rotated to point up/down for the search
-// "previous"/"next" controls. `getEditorIconSvg` omits an outer viewBox so each
-// symbol scales to fill the requested size regardless of its intrinsic
-// coordinate system.
+// Icon artwork is sourced from `@pierre/icons` so the editor matches the rest
+// of the product. The arrow glyph is the full-size right arrow rotated to point
+// up/down for the search "previous"/"next" controls. `getEditorIconSvg` omits
+// an outer viewBox so each symbol scales to fill the requested size regardless
+// of its intrinsic coordinate system.
export const SVGSpriteSheet = `
@@ -44,6 +45,15 @@ export const SVGSpriteSheet = `
+
+
+
+
+
+
+
+
+
`;
export const createSpriteElement = (): SVGSVGElement => {
@@ -57,6 +67,4 @@ export const createSpriteElement = (): SVGSVGElement => {
};
export const getEditorIconSvg = (name: SVGSpriteNames, size = 16): string =>
- `
-
- `;
+ ` `;
diff --git a/packages/diffs/src/editor/stateStorage.ts b/packages/diffs/src/editor/stateStorage.ts
index 88006c6d2..de0163b46 100644
--- a/packages/diffs/src/editor/stateStorage.ts
+++ b/packages/diffs/src/editor/stateStorage.ts
@@ -18,6 +18,7 @@ export function cloneEditorState(state: EditorState): EditorState {
end: { ...selection.end },
direction: selection.direction,
})),
+ foldRanges: state.foldRanges?.map((range) => ({ ...range })),
view: state.view === undefined ? undefined : { ...state.view },
};
}
diff --git a/packages/diffs/src/renderers/FileRenderer.ts b/packages/diffs/src/renderers/FileRenderer.ts
index 7e01e342d..268b8ec3d 100644
--- a/packages/diffs/src/renderers/FileRenderer.ts
+++ b/packages/diffs/src/renderers/FileRenderer.ts
@@ -21,6 +21,7 @@ import type {
FileHeaderRenderMode,
HighlightedToken,
LineAnnotation,
+ LineRange,
RenderedFileASTCache,
RenderFileOptions,
RenderFileResult,
@@ -110,6 +111,7 @@ export class FileRenderer {
private renderCache: RenderedFileASTCache | undefined;
private computedLang: SupportedLanguages = 'text';
private lineAnnotations: AnnotationLineMap = {};
+ private foldRanges: LineRange[] = [];
private lineCache: LineCache | undefined;
private textDocumentCache = new WeakMap();
@@ -144,6 +146,10 @@ export class FileRenderer {
}
}
+ public setFoldRanges(ranges: LineRange[]): void {
+ this.foldRanges = ranges;
+ }
+
public cleanUp(): void {
this.recycle();
this.workerManager = undefined;
@@ -154,6 +160,7 @@ export class FileRenderer {
this.clearRenderCache();
this.highlighter = undefined;
this.workerManager?.cleanUpTasks(this);
+ this.foldRanges = [];
this.lineCache = undefined;
// The edited-document cache is only coherent alongside the render cache
// it patched. Keeping it across a recycle would let getLineCount report
@@ -635,11 +642,35 @@ export class FileRenderer {
rowCount++;
}
+ let low = 0;
+ let high = this.foldRanges.length;
+ while (low < high) {
+ const middle = low + ((high - low) >> 1);
+ if (this.foldRanges[middle].endLine < renderRange.startingLine) {
+ low = middle + 1;
+ } else {
+ high = middle;
+ }
+ }
+ let foldedRangeIndex = low;
for (
let lineIndex = renderRange.startingLine;
lineIndex < endLine;
lineIndex++
) {
+ while (this.foldRanges[foldedRangeIndex]?.endLine < lineIndex) {
+ foldedRangeIndex++;
+ }
+ const foldedRange = this.foldRanges[foldedRangeIndex];
+ if (
+ foldedRange !== undefined &&
+ lineIndex >= foldedRange.startLine &&
+ lineIndex <= foldedRange.endLine
+ ) {
+ lineIndex = foldedRange.endLine;
+ continue;
+ }
+
const lineNumber = lineIndex + 1;
// Sparse array - directly indexed by lineIndex
diff --git a/packages/diffs/src/types.ts b/packages/diffs/src/types.ts
index 47cb5d959..f71a97878 100644
--- a/packages/diffs/src/types.ts
+++ b/packages/diffs/src/types.ts
@@ -1033,6 +1033,11 @@ export interface DiffsEditableComponent<
lineNumber: number | null,
options?: EditorActiveLineOptions
) => void;
+ /**
+ * @internal Hide inclusive zero-based document-line ranges while an editor
+ * fold is active. FileDiff intentionally leaves this unimplemented.
+ */
+ __setFoldRanges?: (ranges: LineRange[]) => void;
/**
* Return the position and height of a one-based line relative to this component.
* The host uses it to scroll to virtualized lines before their DOM nodes exist.
@@ -1212,6 +1217,11 @@ export interface EditorSelection extends Range {
export interface EditorState {
selections?: EditorSelection[];
+ /**
+ * Active indentation folds. Lines are zero-based; `endLine` is the last
+ * collapsed body line. A standalone closing delimiter remains visible.
+ */
+ foldRanges?: LineRange[];
view?: {
scrollLeft: number;
scrollTop: number;
@@ -1224,6 +1234,11 @@ export interface DiffsTextDocument {
getText: () => string;
}
+export interface LineRange {
+ readonly startLine: number;
+ readonly endLine: number;
+}
+
/**
* Options CodeView passes to its `createEditor` factory. A structural subset
* of `EditorOptions` from `@pierre/diffs/editor`, so factories can spread
diff --git a/packages/diffs/test/VirtualizedFile.folding.test.ts b/packages/diffs/test/VirtualizedFile.folding.test.ts
new file mode 100644
index 000000000..ecfd7e84e
--- /dev/null
+++ b/packages/diffs/test/VirtualizedFile.folding.test.ts
@@ -0,0 +1,216 @@
+import { describe, expect, test } from 'bun:test';
+
+import { VirtualizedFile } from '../src/components/VirtualizedFile';
+import { DEFAULT_VIRTUAL_FILE_METRICS } from '../src/constants';
+import type {
+ FileContents,
+ RenderRange,
+ RenderWindow,
+ VirtualFileMetrics,
+} from '../src/types';
+
+const metrics: VirtualFileMetrics = {
+ ...DEFAULT_VIRTUAL_FILE_METRICS,
+ hunkLineCount: 2,
+ lineHeight: 10,
+ diffHeaderHeight: 30,
+ spacing: 4,
+};
+
+interface InspectableVirtualizedFile {
+ cache: {
+ heights: Map;
+ checkpoints: unknown[];
+ fileAnnotationHeight: number;
+ };
+ editorFoldedLineIndex: {
+ isHidden(lineIndex: number): boolean;
+ };
+ renderRange: RenderRange | undefined;
+ computeApproximateSize(force?: boolean): void;
+ computeRenderRangeFromWindow(
+ file: FileContents,
+ fileTop: number,
+ window: RenderWindow
+ ): RenderRange;
+}
+
+function inspect(instance: VirtualizedFile): InspectableVirtualizedFile {
+ return instance as unknown as InspectableVirtualizedFile;
+}
+
+function createFile(lineCount: number): FileContents {
+ return {
+ name: 'folded.ts',
+ contents: Array.from(
+ { length: lineCount },
+ (_, lineIndex) => `line ${lineIndex + 1}`
+ ).join('\n'),
+ };
+}
+
+function createVirtualizer(layoutChanges: boolean[]) {
+ return {
+ type: 'simple',
+ config: {},
+ connect() {},
+ disconnect() {},
+ getWindowSpecs() {
+ return { top: 0, bottom: 1_000 };
+ },
+ getOffsetInScrollContainer() {
+ return 0;
+ },
+ instanceChanged(_instance: unknown, layoutChanged: boolean) {
+ layoutChanges.push(layoutChanged);
+ },
+ isInstanceVisible() {
+ return true;
+ },
+ } as never;
+}
+
+describe('VirtualizedFile editor folding', () => {
+ test('removes hidden rows from geometry and invalidates layout on toggles', () => {
+ const layoutChanges: boolean[] = [];
+ const file = createFile(20);
+ const instance = new VirtualizedFile(
+ {},
+ createVirtualizer(layoutChanges),
+ metrics
+ );
+ instance.prepareCodeViewItem(file, 0);
+
+ expect(instance.getVirtualizedHeight()).toBe(234);
+
+ instance.__setFoldRanges([{ startLine: 3, endLine: 7 }]);
+
+ expect(instance.getVirtualizedHeight()).toBe(184);
+ expect(instance.getLineHeight(3)).toBe(0);
+ expect(instance.getLinePosition(4)).toEqual({ top: 60, height: 0 });
+ expect(instance.getLinePosition(9)).toEqual({ top: 60, height: 10 });
+ expect(layoutChanges).toEqual([true]);
+
+ instance.__setFoldRanges([]);
+
+ expect(instance.getVirtualizedHeight()).toBe(234);
+ expect(instance.getLinePosition(9)).toEqual({ top: 110, height: 10 });
+ expect(layoutChanges).toEqual([true, true]);
+
+ instance.__setFoldRanges([]);
+ expect(layoutChanges).toEqual([true, true]);
+ });
+
+ test('maps uniform-height windows through visible indexes to raw lines', () => {
+ const file = createFile(20);
+ const instance = new VirtualizedFile({}, createVirtualizer([]), metrics);
+ instance.prepareCodeViewItem(file, 0);
+ instance.__setFoldRanges([{ startLine: 2, endLine: 11 }]);
+
+ const range = inspect(instance).computeRenderRangeFromWindow(file, 0, {
+ top: 80,
+ bottom: 90,
+ });
+
+ expect(range).toEqual({
+ startingLine: 12,
+ totalLines: 6,
+ bufferBefore: 20,
+ bufferAfter: 20,
+ });
+
+ inspect(instance).renderRange = range;
+ expect(instance.getNumericScrollAnchor(51)).toEqual({
+ lineNumber: 14,
+ top: 60,
+ });
+ });
+
+ test('gives folded rows zero height in variable-height layout', () => {
+ const file = createFile(20);
+ const instance = new VirtualizedFile(
+ { overflow: 'wrap' },
+ createVirtualizer([]),
+ metrics
+ );
+ instance.prepareCodeViewItem(file, 0);
+ instance.__setFoldRanges([{ startLine: 3, endLine: 7 }]);
+
+ expect(instance.getVirtualizedHeight()).toBe(184);
+ expect(instance.getLinePosition(6)).toEqual({ top: 60, height: 0 });
+ expect(instance.getLinePosition(9)).toEqual({ top: 60, height: 10 });
+ });
+
+ test('preserves measured heights while rebuilding folded layout', () => {
+ const file = createFile(20);
+ const instance = new VirtualizedFile(
+ { overflow: 'wrap' },
+ createVirtualizer([]),
+ metrics
+ );
+ instance.prepareCodeViewItem(file, 0);
+ const layout = inspect(instance);
+ layout.cache.heights.set(8, 25);
+ layout.cache.fileAnnotationHeight = 12;
+
+ instance.__setFoldRanges([{ startLine: 3, endLine: 7 }]);
+
+ expect(layout.cache.heights.get(8)).toBe(25);
+ expect(layout.cache.fileAnnotationHeight).toBe(12);
+ expect(instance.getVirtualizedHeight()).toBe(211);
+ });
+
+ test('jumps large folded bodies in variable-height layout scans', () => {
+ const lineCount = 20_000;
+ const file = createFile(lineCount);
+ const instance = new VirtualizedFile(
+ { overflow: 'wrap' },
+ createVirtualizer([]),
+ metrics
+ );
+ instance.prepareCodeViewItem(file, 0);
+ instance.__setFoldRanges([{ startLine: 1, endLine: 19_990 }]);
+
+ const layout = inspect(instance);
+ const originalIsHidden = layout.editorFoldedLineIndex.isHidden.bind(
+ layout.editorFoldedLineIndex
+ );
+ let hiddenChecks = 0;
+ layout.editorFoldedLineIndex.isHidden = (lineIndex) => {
+ hiddenChecks++;
+ return originalIsHidden(lineIndex);
+ };
+
+ layout.computeApproximateSize(true);
+
+ expect(instance.getVirtualizedHeight()).toBe(134);
+ expect(hiddenChecks).toBe(0);
+
+ expect(instance.getLinePosition(15_000)).toEqual({
+ top: 40,
+ height: 0,
+ });
+ expect(hiddenChecks).toBe(1);
+
+ hiddenChecks = 0;
+ const range = layout.computeRenderRangeFromWindow(file, 0, {
+ top: 40,
+ bottom: 60,
+ });
+ expect(range.startingLine).toBe(0);
+ expect(hiddenChecks).toBeLessThan(10);
+
+ hiddenChecks = 0;
+ layout.renderRange = {
+ startingLine: 0,
+ totalLines: lineCount,
+ bufferBefore: 0,
+ bufferAfter: 0,
+ };
+ expect(instance.getNumericScrollAnchor(50)).toEqual({
+ lineNumber: 19_993,
+ top: 50,
+ });
+ expect(hiddenChecks).toBe(0);
+ });
+});
diff --git a/packages/diffs/test/e2e/e2e-globals.d.ts b/packages/diffs/test/e2e/e2e-globals.d.ts
index adb6aac07..759e4bda7 100644
--- a/packages/diffs/test/e2e/e2e-globals.d.ts
+++ b/packages/diffs/test/e2e/e2e-globals.d.ts
@@ -62,6 +62,7 @@ interface Window {
__lineSelectReady?: boolean;
__annotationsReady?: boolean;
__themeReady?: boolean;
+ __foldingReady?: boolean;
__selectionActionReady?: boolean;
__selectionActionEdgesReady?: boolean;
@@ -77,6 +78,7 @@ interface Window {
// Editor handle exposed by the editable fixtures.
__editor?: E2EEditor;
+ __setFoldingTheme?: () => void;
// edit-collapsed.html helpers: rendered new-file line numbers in the
// editable column, and the primary caret's zero-based line.
diff --git a/packages/diffs/test/e2e/fixtures/folding.html b/packages/diffs/test/e2e/fixtures/folding.html
new file mode 100644
index 000000000..658aeed28
--- /dev/null
+++ b/packages/diffs/test/e2e/fixtures/folding.html
@@ -0,0 +1,94 @@
+
+
+
+
+
+ diffs editor-folding fixture
+
+
+
+ ← All fixtures
+
+
+
+
+
diff --git a/packages/diffs/test/e2e/fixtures/index.html b/packages/diffs/test/e2e/fixtures/index.html
index 1ce22bd6a..8eae18871 100644
--- a/packages/diffs/test/e2e/fixtures/index.html
+++ b/packages/diffs/test/e2e/fixtures/index.html
@@ -116,6 +116,14 @@ @pierre/diffs — E2E fixtures
window.__editableReady.
+
markers.html
diff --git a/packages/diffs/test/e2e/folding.pw.ts b/packages/diffs/test/e2e/folding.pw.ts
new file mode 100644
index 000000000..3af2cec48
--- /dev/null
+++ b/packages/diffs/test/e2e/folding.pw.ts
@@ -0,0 +1,105 @@
+import { expect, type Locator, type Page, test } from '@playwright/test';
+
+const GUTTER = '[data-code][data-editor-folding] [data-gutter]';
+const CONTENT_ROWS = '[data-content] > [data-line]';
+const OUTER_TOGGLE = '[data-column-number="1"] [data-fold-toggle]';
+const OUTER_INDICATOR =
+ '[data-content] > [data-line="1"] > [data-fold-indicator]';
+
+async function openFixture(page: Page): Promise {
+ await page.goto('/test/e2e/fixtures/folding.html');
+ await page.waitForFunction(() => window.__foldingReady === true);
+}
+
+const renderedLineNumbers = (page: Page): Promise =>
+ page
+ .locator(CONTENT_ROWS)
+ .evaluateAll((rows) =>
+ rows.map((row) => Number((row as HTMLElement).dataset.line))
+ );
+
+const color = (toggle: Locator): Promise =>
+ toggle.evaluate((element) => getComputedStyle(element).color);
+
+const firstTokenColor = (page: Page): Promise =>
+ page
+ .locator('[data-content] > [data-line="1"] [data-char]')
+ .first()
+ .evaluate((element) => getComputedStyle(element).color);
+
+test.describe('editor folding controls', () => {
+ test('reveal on hover and fold or unfold the outer block', async ({
+ page,
+ }) => {
+ await openFixture(page);
+
+ const gutter = page.locator(GUTTER);
+ let toggle = page.locator(OUTER_TOGGLE);
+ await expect(toggle.locator('use')).toHaveAttribute(
+ 'href',
+ '#diffs-editor-icon-chevron-down'
+ );
+ await expect(toggle).toHaveCSS('opacity', '0');
+
+ await gutter.hover();
+ await expect(toggle).toHaveCSS('opacity', '0.72');
+ const gutterHoverColor = await color(toggle);
+
+ await toggle.hover();
+ await expect(toggle).toHaveCSS('opacity', '1');
+ await expect.poll(() => color(toggle)).not.toBe(gutterHoverColor);
+
+ await toggle.click();
+ await expect.poll(() => renderedLineNumbers(page)).toEqual([1, 7, 8]);
+
+ await page.mouse.move(1150, 780);
+ toggle = page.locator(OUTER_TOGGLE);
+ await expect(toggle).toHaveAttribute('data-folded', '');
+ await expect(toggle.locator('use')).toHaveAttribute(
+ 'href',
+ '#diffs-editor-icon-chevron-right'
+ );
+ await expect(toggle).toHaveCSS('opacity', '0.72');
+
+ const indicator = page.locator(OUTER_INDICATOR);
+ const ellipsis = indicator.locator('[data-fold-ellipsis]');
+ await expect(indicator).toBeVisible();
+ await expect(ellipsis.locator('use')).toHaveAttribute(
+ 'href',
+ '#diffs-editor-icon-ellipsis'
+ );
+ expect(await indicator.getAttribute('data-fold-end-text')).toBeNull();
+ await expect(indicator).toHaveText('');
+ await expect(page.locator('[data-content] > [data-line="7"]')).toHaveText(
+ '}'
+ );
+
+ const ellipsisBox = await ellipsis.boundingBox();
+ const indicatorBox = await indicator.boundingBox();
+ expect(ellipsisBox).not.toBeNull();
+ expect(indicatorBox).not.toBeNull();
+ expect(indicatorBox!.width).toBeCloseTo(ellipsisBox!.width, 5);
+
+ const darkTokenColor = await firstTokenColor(page);
+ await ellipsis.focus();
+ await expect(ellipsis).toBeFocused();
+ await page.evaluate(() => window.__setFoldingTheme?.());
+ await expect.poll(() => firstTokenColor(page)).not.toBe(darkTokenColor);
+ await expect(ellipsis).toBeFocused();
+
+ await ellipsis.click();
+ await expect
+ .poll(() => renderedLineNumbers(page))
+ .toEqual([1, 2, 3, 4, 5, 6, 7, 8]);
+ await expect(indicator).toHaveCount(0);
+
+ await page.mouse.move(1150, 780);
+ toggle = page.locator(OUTER_TOGGLE);
+ await expect(toggle).not.toHaveAttribute('data-folded', '');
+ await expect(toggle.locator('use')).toHaveAttribute(
+ 'href',
+ '#diffs-editor-icon-chevron-down'
+ );
+ await expect(toggle).toHaveCSS('opacity', '0');
+ });
+});
diff --git a/packages/diffs/test/editorFolding.test.ts b/packages/diffs/test/editorFolding.test.ts
new file mode 100644
index 000000000..5cf65e2df
--- /dev/null
+++ b/packages/diffs/test/editorFolding.test.ts
@@ -0,0 +1,650 @@
+import { afterAll, describe, expect, test } from 'bun:test';
+
+import { File } from '../src/components/File';
+import { FileDiff } from '../src/components/FileDiff';
+import { DEFAULT_THEMES } from '../src/constants';
+import { Editor, type EditorOptions } from '../src/editor/editor';
+import { disposeHighlighter } from '../src/highlighter/shared_highlighter';
+import type { FileContents, LineRange } from '../src/types';
+import { installDom, wait, waitFor } from './domHarness';
+
+afterAll(async () => {
+ await disposeHighlighter();
+});
+
+const FOLDABLE_CONTENTS = [
+ 'function outer() {',
+ ' const before = 1;',
+ ' if (before) {',
+ ' console.log(before);',
+ ' }',
+ ' return before;',
+ '}',
+ 'const after = true;',
+].join('\n');
+
+interface FileEditorFixture {
+ cleanup(): void;
+ container: HTMLElement;
+ editor: Editor;
+ file: File;
+}
+
+async function waitForEditableContent(container: HTMLElement): Promise {
+ const hasEditableContent = (): boolean =>
+ [
+ ...(container.shadowRoot?.querySelectorAll(
+ '[data-content]'
+ ) ?? []),
+ ].some(
+ (content) =>
+ content.contentEditable === 'true' ||
+ content.getAttribute('contenteditable') === 'true'
+ );
+
+ await waitFor(hasEditableContent, { timeout: 3000 });
+
+ expect(hasEditableContent()).toBe(true);
+}
+
+async function createFileEditorFixture(
+ editorOptions?: EditorOptions,
+ contents = FOLDABLE_CONTENTS
+): Promise {
+ const dom = installDom();
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+
+ const file = new File({
+ disableFileHeader: true,
+ theme: DEFAULT_THEMES,
+ });
+ const editor = new Editor(editorOptions);
+ const fileContents: FileContents = {
+ name: 'foldable.ts',
+ contents,
+ };
+
+ file.render({
+ file: fileContents,
+ fileContainer: container,
+ forceRender: true,
+ });
+ editor.edit(file);
+ await waitForEditableContent(container);
+
+ return {
+ cleanup() {
+ editor.cleanUp();
+ file.cleanUp();
+ dom.cleanup();
+ },
+ container,
+ editor,
+ file,
+ };
+}
+
+function shadowRoot(container: HTMLElement): ShadowRoot {
+ const shadow = container.shadowRoot;
+ if (shadow == null) {
+ throw new Error('file container has no shadow root');
+ }
+ return shadow;
+}
+
+function renderedLineNumbers(container: HTMLElement): number[] {
+ return [
+ ...shadowRoot(container).querySelectorAll(
+ '[data-content] > [data-line]'
+ ),
+ ].map((line) => Number(line.dataset.line));
+}
+
+function gutterRow(
+ container: HTMLElement,
+ oneIndexedLine: number
+): HTMLElement {
+ const row = shadowRoot(container).querySelector(
+ `[data-column-number="${oneIndexedLine}"]`
+ );
+ if (row == null) {
+ throw new Error(`no gutter row found for line ${oneIndexedLine}`);
+ }
+ return row;
+}
+
+function foldToggle(
+ container: HTMLElement,
+ oneIndexedLine: number
+): HTMLButtonElement {
+ const toggle = gutterRow(container, oneIndexedLine).querySelector(
+ '[data-fold-toggle]'
+ );
+ if (!(toggle instanceof HTMLButtonElement)) {
+ throw new Error(`no fold toggle found for line ${oneIndexedLine}`);
+ }
+ return toggle;
+}
+
+function foldIconHref(toggle: HTMLButtonElement): string | null {
+ return toggle.querySelector('use')?.getAttribute('href') ?? null;
+}
+
+function foldIndicator(
+ container: HTMLElement,
+ oneIndexedLine: number
+): HTMLElement {
+ const indicator = shadowRoot(container).querySelector(
+ `[data-content] > [data-line="${oneIndexedLine}"] > [data-fold-indicator]`
+ );
+ if (indicator == null) {
+ throw new Error(`no fold indicator found for line ${oneIndexedLine}`);
+ }
+ return indicator;
+}
+
+function foldEllipsis(
+ container: HTMLElement,
+ oneIndexedLine: number
+): HTMLButtonElement {
+ const ellipsis = foldIndicator(container, oneIndexedLine).querySelector(
+ '[data-fold-ellipsis]'
+ );
+ if (!(ellipsis instanceof HTMLButtonElement)) {
+ throw new Error(`no fold ellipsis found for line ${oneIndexedLine}`);
+ }
+ return ellipsis;
+}
+
+function recordFoldRangeUpdates(file: File): LineRange[][] {
+ const updates: LineRange[][] = [];
+ const setFoldRanges = file.__setFoldRanges.bind(file);
+ file.__setFoldRanges = (ranges) => {
+ updates.push(ranges.map((range) => ({ ...range })));
+ setFoldRanges(ranges);
+ };
+ return updates;
+}
+
+async function waitForLines(
+ container: HTMLElement,
+ expected: number[]
+): Promise {
+ await waitFor(
+ () =>
+ JSON.stringify(renderedLineNumbers(container)) ===
+ JSON.stringify(expected),
+ { timeout: 3000 }
+ );
+ expect(renderedLineNumbers(container)).toEqual(expected);
+}
+
+describe('editor folding on File', () => {
+ test('is enabled by default and folds or unfolds a block from the gutter', async () => {
+ const { cleanup, container } = await createFileEditorFixture();
+ try {
+ const shadow = shadowRoot(container);
+ const firstGutterRow = gutterRow(container, 1);
+ const foldZone = firstGutterRow.querySelector(':scope > [data-fold]');
+ const initialToggle = foldToggle(container, 1);
+
+ expect(shadow.querySelector('[data-code][data-editor-folding]')).not.toBe(
+ null
+ );
+ expect(foldZone?.parentElement).toBe(firstGutterRow);
+ expect(firstGutterRow.closest('[data-gutter]')).not.toBe(null);
+ expect(foldZone?.contains(initialToggle)).toBe(true);
+ expect(initialToggle.getAttribute('aria-expanded')).toBe('true');
+ expect(foldIconHref(initialToggle)).toBe(
+ '#diffs-editor-icon-chevron-down'
+ );
+
+ initialToggle.focus();
+ initialToggle.click();
+ await waitForLines(container, [1, 7, 8]);
+
+ const foldedToggle = foldToggle(container, 1);
+ expect(shadow.activeElement).toBe(foldedToggle);
+ expect(foldedToggle.hasAttribute('data-folded')).toBe(true);
+ expect(foldedToggle.getAttribute('aria-expanded')).toBe('false');
+ expect(foldIconHref(foldedToggle)).toBe(
+ '#diffs-editor-icon-chevron-right'
+ );
+
+ const indicator = foldIndicator(container, 1);
+ const ellipsis = foldEllipsis(container, 1);
+ expect(indicator.parentElement?.dataset.line).toBe('1');
+ expect(indicator.getAttribute('contenteditable')).toBe('false');
+ expect(ellipsis.ariaLabel).toBe('Unfold line 1');
+ expect(foldIconHref(ellipsis)).toBe('#diffs-editor-icon-ellipsis');
+ expect(indicator.dataset.foldEndText).toBeUndefined();
+ expect(indicator.children.length).toBe(1);
+ expect(indicator.firstElementChild).toBe(ellipsis);
+ expect(indicator.textContent).toBe('');
+
+ ellipsis.click();
+ await waitForLines(container, [1, 2, 3, 4, 5, 6, 7, 8]);
+ expect(shadow.querySelector('[data-fold-indicator]')).toBe(null);
+
+ const unfoldedToggle = foldToggle(container, 1);
+ expect(shadow.activeElement).toBe(unfoldedToggle);
+ expect(unfoldedToggle.hasAttribute('data-folded')).toBe(false);
+ expect(foldIconHref(unfoldedToggle)).toBe(
+ '#diffs-editor-icon-chevron-down'
+ );
+ } finally {
+ cleanup();
+ }
+ });
+
+ test('can be disabled at construction', async () => {
+ const { cleanup, container } = await createFileEditorFixture({
+ folding: false,
+ });
+ try {
+ const shadow = shadowRoot(container);
+ expect(shadow.querySelector('[data-code][data-editor-folding]')).toBe(
+ null
+ );
+ expect(shadow.querySelector('[data-fold]')).toBe(null);
+ expect(shadow.querySelector('[data-fold-toggle]')).toBe(null);
+ expect(renderedLineNumbers(container)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]);
+ } finally {
+ cleanup();
+ }
+ });
+
+ test('responds to folding option changes at runtime', async () => {
+ const { cleanup, container, editor } = await createFileEditorFixture({
+ folding: false,
+ });
+ try {
+ editor.setOptions({ folding: true });
+ await waitFor(() => foldToggle(container, 1) != null);
+
+ foldToggle(container, 1).click();
+ await waitForLines(container, [1, 7, 8]);
+
+ editor.setOptions({ folding: false });
+ await waitForLines(container, [1, 2, 3, 4, 5, 6, 7, 8]);
+ expect(shadowRoot(container).querySelector('[data-fold-toggle]')).toBe(
+ null
+ );
+
+ editor.setOptions({ folding: true });
+ expect(foldToggle(container, 1)).toBeInstanceOf(HTMLButtonElement);
+ } finally {
+ cleanup();
+ }
+ });
+
+ test('preserves a nested fold while its outer fold is toggled', async () => {
+ const { cleanup, container } = await createFileEditorFixture();
+ try {
+ foldToggle(container, 3).click();
+ await waitForLines(container, [1, 2, 3, 5, 6, 7, 8]);
+
+ foldToggle(container, 1).click();
+ await waitForLines(container, [1, 7, 8]);
+
+ foldToggle(container, 1).click();
+ await waitForLines(container, [1, 2, 3, 5, 6, 7, 8]);
+
+ const nestedToggle = foldToggle(container, 3);
+ expect(nestedToggle.hasAttribute('data-folded')).toBe(true);
+ expect(foldIconHref(nestedToggle)).toBe(
+ '#diffs-editor-icon-chevron-right'
+ );
+
+ nestedToggle.click();
+ await waitForLines(container, [1, 2, 3, 4, 5, 6, 7, 8]);
+ } finally {
+ cleanup();
+ }
+ });
+
+ test('round-trips nested fold state and ignores stale ranges', async () => {
+ const { cleanup, container, editor } = await createFileEditorFixture();
+ try {
+ foldToggle(container, 3).click();
+ await waitForLines(container, [1, 2, 3, 5, 6, 7, 8]);
+ foldToggle(container, 1).click();
+ await waitForLines(container, [1, 7, 8]);
+
+ expect(editor.getState().foldRanges).toEqual([
+ { startLine: 0, endLine: 5 },
+ { startLine: 2, endLine: 3 },
+ ]);
+
+ editor.setState({ foldRanges: [] });
+ await waitForLines(container, [1, 2, 3, 4, 5, 6, 7, 8]);
+
+ editor.setState({
+ foldRanges: [
+ { startLine: 0, endLine: 6 },
+ { startLine: 2, endLine: 3 },
+ { startLine: 0, endLine: 5 },
+ { startLine: 2, endLine: 4 },
+ { startLine: 2, endLine: 3 },
+ { startLine: -1, endLine: 5 },
+ { startLine: 7, endLine: 9 },
+ ],
+ });
+ await waitForLines(container, [1, 7, 8]);
+ expect(editor.getState().foldRanges).toEqual([
+ { startLine: 0, endLine: 5 },
+ { startLine: 2, endLine: 3 },
+ ]);
+
+ foldToggle(container, 1).click();
+ await waitForLines(container, [1, 2, 3, 5, 6, 7, 8]);
+ expect(foldToggle(container, 3).hasAttribute('data-folded')).toBe(true);
+ } finally {
+ cleanup();
+ }
+ });
+
+ test('reveals a folded caret restored through setState', async () => {
+ const { cleanup, container, editor } = await createFileEditorFixture();
+ try {
+ foldToggle(container, 1).click();
+ await waitForLines(container, [1, 7, 8]);
+
+ editor.setState({
+ selections: [
+ {
+ start: { line: 3, character: 4 },
+ end: { line: 3, character: 4 },
+ direction: 0,
+ },
+ ],
+ });
+
+ await waitForLines(container, [1, 2, 3, 4, 5, 6, 7, 8]);
+ expect(editor.getState().selections?.at(-1)?.start).toEqual({
+ line: 3,
+ character: 4,
+ });
+ } finally {
+ cleanup();
+ }
+ });
+
+ test('keeps a fold when restoring a caret on its visible delimiter', async () => {
+ const { cleanup, container, editor } = await createFileEditorFixture();
+ try {
+ foldToggle(container, 1).click();
+ await waitForLines(container, [1, 7, 8]);
+
+ editor.setState({
+ foldRanges: [{ startLine: 0, endLine: 5 }],
+ selections: [
+ {
+ start: { line: 6, character: 0 },
+ end: { line: 6, character: 0 },
+ direction: 0,
+ },
+ ],
+ });
+
+ await waitForLines(container, [1, 7, 8]);
+ expect(editor.getState().foldRanges).toEqual([
+ { startLine: 0, endLine: 5 },
+ ]);
+ expect(editor.getState().selections?.at(-1)?.start).toEqual({
+ line: 6,
+ character: 0,
+ });
+ } finally {
+ cleanup();
+ }
+ });
+
+ test('keeps a fold between line-changing edits in a net-zero batch', async () => {
+ const contents = [
+ 'const first = {',
+ ' value: 1,',
+ '};',
+ 'const middle = true;',
+ 'const second = {',
+ ' value: 2,',
+ '};',
+ 'removeMe();',
+ 'keepMe();',
+ ].join('\n');
+ const { cleanup, container, editor } = await createFileEditorFixture(
+ undefined,
+ contents
+ );
+ try {
+ foldToggle(container, 5).click();
+ await waitForLines(container, [1, 2, 3, 4, 5, 7, 8, 9]);
+
+ editor.applyEdits([
+ {
+ range: {
+ start: { line: 3, character: 0 },
+ end: { line: 3, character: 0 },
+ },
+ newText: 'inserted();\n',
+ },
+ {
+ range: {
+ start: { line: 7, character: 0 },
+ end: { line: 8, character: 0 },
+ },
+ newText: '',
+ },
+ ]);
+
+ await waitForLines(container, [1, 2, 3, 4, 5, 6, 8, 9]);
+ const shiftedToggle = foldToggle(container, 6);
+ expect(shiftedToggle.hasAttribute('data-folded')).toBe(true);
+ expect(foldIconHref(shiftedToggle)).toBe(
+ '#diffs-editor-icon-chevron-right'
+ );
+ } finally {
+ cleanup();
+ }
+ });
+
+ test('shifts a fold when a newline is inserted before its header', async () => {
+ const { cleanup, container, editor } = await createFileEditorFixture();
+ try {
+ foldToggle(container, 1).click();
+ await waitForLines(container, [1, 7, 8]);
+ let foldRangesDuringChange: LineRange[] | undefined;
+ editor.setOptions({
+ onChange: () => {
+ foldRangesDuringChange = editor.getState().foldRanges;
+ },
+ });
+
+ editor.applyEdits([
+ {
+ range: {
+ start: { line: 0, character: 0 },
+ end: { line: 0, character: 0 },
+ },
+ newText: '\n',
+ },
+ ]);
+
+ await waitForLines(container, [1, 2, 8, 9]);
+ const shiftedToggle = foldToggle(container, 2);
+ expect(shiftedToggle.hasAttribute('data-folded')).toBe(true);
+ expect(foldIconHref(shiftedToggle)).toBe(
+ '#diffs-editor-icon-chevron-right'
+ );
+ expect(foldRangesDuringChange).toEqual([{ startLine: 1, endLine: 6 }]);
+ expect(editor.getState().foldRanges).toEqual([
+ { startLine: 1, endLine: 6 },
+ ]);
+ } finally {
+ cleanup();
+ }
+ });
+
+ test('shifts a fold when whole lines are deleted before its header', async () => {
+ const { cleanup, container, editor } = await createFileEditorFixture(
+ undefined,
+ `removeMe();\n${FOLDABLE_CONTENTS}`
+ );
+ try {
+ foldToggle(container, 2).click();
+ await waitForLines(container, [1, 2, 8, 9]);
+
+ editor.applyEdits([
+ {
+ range: {
+ start: { line: 0, character: 0 },
+ end: { line: 1, character: 0 },
+ },
+ newText: '',
+ },
+ ]);
+
+ await waitForLines(container, [1, 7, 8]);
+ expect(foldToggle(container, 1).hasAttribute('data-folded')).toBe(true);
+ expect(editor.getState().foldRanges).toEqual([
+ { startLine: 0, endLine: 5 },
+ ]);
+ } finally {
+ cleanup();
+ }
+ });
+
+ test('retains active fold controls across ordinary character edits', async () => {
+ const { cleanup, container, editor, file } =
+ await createFileEditorFixture();
+ try {
+ foldToggle(container, 1).click();
+ await waitForLines(container, [1, 7, 8]);
+ await wait(0);
+
+ const outerToggle = foldToggle(container, 1);
+ const outerIndicator = foldIndicator(container, 1);
+ const foldRangeUpdates = recordFoldRangeUpdates(file);
+
+ editor.applyEdits([
+ {
+ range: {
+ start: { line: 0, character: 9 },
+ end: { line: 0, character: 14 },
+ },
+ newText: 'inner',
+ },
+ ]);
+
+ await waitFor(() => editor.getText().includes('function inner() {'));
+ expect(foldToggle(container, 1)).toBe(outerToggle);
+ expect(foldIndicator(container, 1)).toBe(outerIndicator);
+ expect(outerToggle.isConnected).toBe(true);
+ expect(outerToggle.hasAttribute('data-folded')).toBe(true);
+ expect(foldRangeUpdates).toEqual([]);
+ } finally {
+ cleanup();
+ }
+ });
+
+ test('recomputes a fold when its closing delimiter gains a suffix', async () => {
+ const { cleanup, container, editor } = await createFileEditorFixture(
+ undefined,
+ ['section {', ' child', '', '}', 'after'].join('\n')
+ );
+ try {
+ foldToggle(container, 1).click();
+ await waitForLines(container, [1, 4, 5]);
+
+ editor.applyEdits([
+ {
+ range: {
+ start: { line: 3, character: 1 },
+ end: { line: 3, character: 1 },
+ },
+ newText: ' // comment',
+ },
+ ]);
+
+ await waitForLines(container, [1, 3, 4, 5]);
+ expect(editor.getState().foldRanges).toEqual([
+ { startLine: 0, endLine: 1 },
+ ]);
+ } finally {
+ cleanup();
+ }
+ });
+
+ test('syncs each fold toggle to the host once', async () => {
+ const { cleanup, container, file } = await createFileEditorFixture();
+ try {
+ const foldRangeUpdates = recordFoldRangeUpdates(file);
+
+ foldToggle(container, 1).click();
+ await waitForLines(container, [1, 7, 8]);
+ await wait(0);
+ expect(foldRangeUpdates).toEqual([[{ startLine: 1, endLine: 5 }]]);
+
+ foldRangeUpdates.length = 0;
+ foldToggle(container, 1).click();
+ await waitForLines(container, [1, 2, 3, 4, 5, 6, 7, 8]);
+ await wait(0);
+ expect(foldRangeUpdates).toEqual([[]]);
+ } finally {
+ cleanup();
+ }
+ });
+});
+
+describe('editor folding on FileDiff', () => {
+ test('does not render fold controls even when folding is enabled', async () => {
+ const dom = installDom();
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+ const fileDiff = new FileDiff({
+ disableFileHeader: true,
+ diffStyle: 'split',
+ theme: DEFAULT_THEMES,
+ });
+ const editor = new Editor({ folding: true });
+ const oldFile: FileContents = {
+ name: 'foldable.ts',
+ contents: FOLDABLE_CONTENTS,
+ };
+ const newFile: FileContents = {
+ name: 'foldable.ts',
+ contents: FOLDABLE_CONTENTS.replace(
+ ' return before;',
+ ' return before + 1;'
+ ),
+ };
+
+ try {
+ fileDiff.render({
+ oldFile,
+ newFile,
+ fileContainer: container,
+ forceRender: true,
+ });
+ editor.edit(fileDiff);
+ await waitForEditableContent(container);
+
+ const shadow = shadowRoot(container);
+ expect(shadow.querySelector('[data-code][data-editor-folding]')).toBe(
+ null
+ );
+ expect(shadow.querySelector('[data-fold]')).toBe(null);
+ expect(shadow.querySelector('[data-fold-toggle]')).toBe(null);
+
+ editor.setOptions({ folding: false });
+ editor.setOptions({ folding: true });
+ expect(shadow.querySelector('[data-fold-toggle]')).toBe(null);
+ } finally {
+ await wait(10);
+ editor.cleanUp();
+ fileDiff.cleanUp();
+ dom.cleanup();
+ }
+ });
+});
diff --git a/packages/diffs/test/editorFoldingRanges.test.ts b/packages/diffs/test/editorFoldingRanges.test.ts
new file mode 100644
index 000000000..d0881aa1a
--- /dev/null
+++ b/packages/diffs/test/editorFoldingRanges.test.ts
@@ -0,0 +1,171 @@
+import { describe, expect, test } from 'bun:test';
+
+import {
+ computeIndentFoldingRanges,
+ LineRangeIndex,
+ mergeHiddenLineRanges,
+} from '../src/editor/folding';
+import { TextDocument } from '../src/editor/textDocument';
+
+function document(text: string) {
+ return new TextDocument('inmemory://folding', text);
+}
+
+describe('computeIndentFoldingRanges', () => {
+ test('returns nested ranges in start-line order and excludes standalone closing delimiters', () => {
+ const ranges = computeIndentFoldingRanges(
+ document(
+ ['const value = {', ' nested: {', ' ok: true,', ' },', '};'].join(
+ '\n'
+ )
+ )
+ );
+
+ expect(ranges).toEqual([
+ { startLine: 0, endLine: 3 },
+ { startLine: 1, endLine: 2 },
+ ]);
+ });
+
+ test('uses the next nonblank line and excludes trailing blank lines', () => {
+ const ranges = computeIndentFoldingRanges(
+ document(['section', '', ' child', '', 'sibling', '', ''].join('\n'))
+ );
+
+ expect(ranges).toEqual([{ startLine: 0, endLine: 2 }]);
+ });
+
+ test('includes blank lines before a visible closing delimiter', () => {
+ const ranges = computeIndentFoldingRanges(
+ document(['section {', ' child', '', '}'].join('\n'))
+ );
+
+ expect(ranges).toEqual([{ startLine: 0, endLine: 2 }]);
+ });
+
+ test('uses tab stops when comparing indentation', () => {
+ const ranges = computeIndentFoldingRanges(
+ document(['root', '\tchild', ' sibling', 'next'].join('\n')),
+ 4
+ );
+
+ expect(ranges).toEqual([{ startLine: 0, endLine: 2 }]);
+ });
+
+ test('returns no range when nonblank indentation never increases', () => {
+ expect(
+ computeIndentFoldingRanges(document(['first', '', 'second'].join('\n')))
+ ).toEqual([]);
+ });
+
+ test('only emits ranges that contain at least one child line', () => {
+ const ranges = computeIndentFoldingRanges(
+ document(['root', ' child', 'next', ' final child'].join('\n'))
+ );
+
+ expect(ranges).toEqual([
+ { startLine: 0, endLine: 1 },
+ { startLine: 2, endLine: 3 },
+ ]);
+ expect(ranges.every((range) => range.endLine > range.startLine)).toBeTrue();
+ });
+});
+
+describe('mergeHiddenLineRanges', () => {
+ test('merges the bodies of selected overlapping and adjacent folds', () => {
+ const hiddenRanges = mergeHiddenLineRanges(
+ [
+ { startLine: 0, endLine: 4 },
+ { startLine: 2, endLine: 6 },
+ { startLine: 6, endLine: 8 },
+ { startLine: 10, endLine: 10 },
+ { startLine: 12, endLine: 14 },
+ ],
+ new Set([0, 2, 6, 10])
+ );
+
+ expect(hiddenRanges).toEqual([{ startLine: 1, endLine: 8 }]);
+ });
+});
+
+describe('LineRangeIndex', () => {
+ const index = new LineRangeIndex([
+ { startLine: 7, endLine: 8 },
+ { startLine: 3, endLine: 4 },
+ { startLine: 2, endLine: 3 },
+ ]);
+
+ test('finds hidden lines and their merged containing range', () => {
+ expect(index.isHidden(1)).toBe(false);
+ expect(index.isHidden(2)).toBe(true);
+ expect(index.isHidden(4)).toBe(true);
+ expect(index.isHidden(5)).toBe(false);
+ expect(index.containingRange(3)).toEqual({ startLine: 2, endLine: 4 });
+ expect(index.containingRange(6)).toBeUndefined();
+ });
+
+ test('jumps past a hidden range without creating a range object', () => {
+ expect(index.lineAfterHiddenRange(1)).toBeUndefined();
+ expect(index.lineAfterHiddenRange(2)).toBe(5);
+ expect(index.lineAfterHiddenRange(4)).toBe(5);
+ expect(index.lineAfterHiddenRange(7)).toBe(9);
+ expect(index.lineAfterHiddenRange(9)).toBeUndefined();
+ });
+
+ test('normalizes ranges that are already ordered', () => {
+ const orderedIndex = new LineRangeIndex([
+ { startLine: 2, endLine: 3 },
+ { startLine: 3, endLine: 5 },
+ { startLine: 8, endLine: 9 },
+ ]);
+
+ expect(orderedIndex.containingRange(4)).toEqual({
+ startLine: 2,
+ endLine: 5,
+ });
+ expect(orderedIndex.lineAtVisibleIndex(2, 12)).toBe(6);
+ });
+
+ test('counts hidden and visible lines at boundaries', () => {
+ expect(index.hiddenCountBefore(0)).toBe(0);
+ expect(index.hiddenCountBefore(2)).toBe(0);
+ expect(index.hiddenCountBefore(3)).toBe(1);
+ expect(index.hiddenCountBefore(5)).toBe(3);
+ expect(index.hiddenCountBefore(7)).toBe(3);
+ expect(index.hiddenCountBefore(8)).toBe(4);
+ expect(index.hiddenCountBefore(9)).toBe(5);
+ expect(index.visibleLineCount(12)).toBe(7);
+ });
+
+ test('maps visible indexes back to document lines', () => {
+ expect(
+ Array.from({ length: index.visibleLineCount(12) }, (_, visibleIndex) =>
+ index.lineAtVisibleIndex(visibleIndex, 12)
+ )
+ ).toEqual([0, 1, 5, 6, 9, 10, 11]);
+ expect(index.lineAtVisibleIndex(-1, 12)).toBeUndefined();
+ expect(index.lineAtVisibleIndex(7, 12)).toBeUndefined();
+ });
+
+ test('finds the nearest visible line in the requested direction', () => {
+ expect(index.nearestVisibleLine(3, 'up', 12)).toBe(1);
+ expect(index.nearestVisibleLine(3, 'down', 12)).toBe(5);
+ expect(index.nearestVisibleLine(5, 'up', 12)).toBe(5);
+ expect(index.nearestVisibleLine(7, 'up', 12)).toBe(6);
+ expect(index.nearestVisibleLine(7, 'down', 12)).toBe(9);
+ expect(index.nearestVisibleLine(-1, 'down', 12)).toBe(0);
+ expect(index.nearestVisibleLine(-1, 'up', 12)).toBeUndefined();
+ expect(index.nearestVisibleLine(12, 'up', 12)).toBe(11);
+ expect(index.nearestVisibleLine(12, 'down', 12)).toBeUndefined();
+ });
+
+ test('returns undefined when a hidden edge has no visible line beyond it', () => {
+ const hiddenStart = new LineRangeIndex([{ startLine: 0, endLine: 2 }]);
+ const hiddenEnd = new LineRangeIndex([{ startLine: 9, endLine: 20 }]);
+
+ expect(hiddenStart.nearestVisibleLine(1, 'up', 12)).toBeUndefined();
+ expect(hiddenStart.nearestVisibleLine(1, 'down', 12)).toBe(3);
+ expect(hiddenEnd.nearestVisibleLine(10, 'up', 12)).toBe(8);
+ expect(hiddenEnd.nearestVisibleLine(10, 'down', 12)).toBeUndefined();
+ });
+});
diff --git a/packages/diffs/test/editorPersistStateLifecycle.test.ts b/packages/diffs/test/editorPersistStateLifecycle.test.ts
index ae2a13bae..63adb764c 100644
--- a/packages/diffs/test/editorPersistStateLifecycle.test.ts
+++ b/packages/diffs/test/editorPersistStateLifecycle.test.ts
@@ -4,7 +4,7 @@ import { File } from '../src/components/File';
import { DEFAULT_THEMES } from '../src/constants';
import { Editor, type IStateStorage } from '../src/editor';
import { disposeHighlighter } from '../src/highlighter/shared_highlighter';
-import type { EditorState, FileContents } from '../src/types';
+import type { EditorState, FileContents, LineRange } from '../src/types';
import { installDom, wait, waitFor } from './domHarness';
afterAll(async () => {
@@ -17,6 +17,21 @@ const ORIGINAL_FILE: FileContents = {
cacheKey: 'persisted.ts',
};
+const FOLDABLE_FILE: FileContents = {
+ name: 'foldable.ts',
+ contents: [
+ 'function outer() {',
+ ' const before = 1;',
+ ' if (before) {',
+ ' console.log(before);',
+ ' }',
+ ' return before;',
+ '}',
+ 'const after = true;',
+ ].join('\n'),
+ cacheKey: 'foldable.ts',
+};
+
interface AttachedFile {
container: HTMLElement;
file: File;
@@ -96,7 +111,110 @@ function savedCaret(character: number): EditorState {
};
}
+function foldToggle(
+ attached: AttachedFile,
+ oneIndexedLine: number
+): HTMLButtonElement {
+ const toggle = attached.container.shadowRoot?.querySelector(
+ `[data-column-number="${oneIndexedLine}"] [data-fold-toggle]`
+ );
+ if (!(toggle instanceof HTMLButtonElement)) {
+ throw new Error(`no fold toggle found for line ${oneIndexedLine}`);
+ }
+ return toggle;
+}
+
+function renderedLineNumbers(attached: AttachedFile): number[] {
+ return [
+ ...(attached.container.shadowRoot?.querySelectorAll(
+ '[data-content] > [data-line]'
+ ) ?? []),
+ ].map((line) => Number(line.dataset.line));
+}
+
+async function waitForFoldRanges(
+ editor: Editor,
+ expected: LineRange[]
+): Promise {
+ await waitFor(
+ () =>
+ JSON.stringify(editor.getState().foldRanges) === JSON.stringify(expected)
+ );
+ expect(editor.getState().foldRanges).toEqual(expected);
+}
+
describe('Editor persisted state lifecycle', () => {
+ test('restores nested folds after switching files', async () => {
+ const dom = installDom();
+ const editor = new Editor({ persistState: true });
+ let attached: AttachedFile | undefined;
+ const nestedFold = { startLine: 2, endLine: 3 };
+ const outerFold = { startLine: 0, endLine: 5 };
+
+ try {
+ attached = await attachFile(editor, { ...FOLDABLE_FILE });
+
+ foldToggle(attached, 3).click();
+ await waitForFoldRanges(editor, [nestedFold]);
+ foldToggle(attached, 1).click();
+ await waitForFoldRanges(editor, [outerFold, nestedFold]);
+
+ await renderFile(editor, attached, {
+ name: 'next.ts',
+ contents: 'next\n',
+ cacheKey: 'next.ts',
+ });
+ await renderFile(editor, attached, { ...FOLDABLE_FILE });
+
+ await waitForFoldRanges(editor, [outerFold, nestedFold]);
+ expect(renderedLineNumbers(attached)).toEqual([1, 7, 8]);
+
+ foldToggle(attached, 1).click();
+ await waitForFoldRanges(editor, [nestedFold]);
+ expect(renderedLineNumbers(attached)).toEqual([1, 2, 3, 5, 6, 7, 8]);
+ } finally {
+ editor.cleanUp();
+ attached?.file.cleanUp();
+ dom.cleanup();
+ }
+ });
+
+ test('a pending async restore cannot overwrite a newer fold toggle', async () => {
+ const dom = installDom();
+ const pendingState = createDeferred();
+ const storage: IStateStorage = {
+ get() {
+ return pendingState.promise;
+ },
+ set() {},
+ };
+ const editor = new Editor({
+ persistState: true,
+ persistStateStorage: storage,
+ });
+ let attached: AttachedFile | undefined;
+ const nestedFold = { startLine: 2, endLine: 3 };
+
+ try {
+ attached = await attachFile(editor, { ...FOLDABLE_FILE });
+
+ foldToggle(attached, 3).click();
+ await waitForFoldRanges(editor, [nestedFold]);
+
+ pendingState.resolve({
+ foldRanges: [{ startLine: 0, endLine: 5 }],
+ });
+ await wait(0);
+
+ expect(editor.getState().foldRanges).toEqual([nestedFold]);
+ expect(renderedLineNumbers(attached)).toEqual([1, 2, 3, 5, 6, 7, 8]);
+ } finally {
+ editor.cleanUp();
+ attached?.file.cleanUp();
+ dom.cleanup();
+ }
+ });
+
test('an async restore survives an unchanged file rerender', async () => {
const dom = installDom();
const pendingState = createDeferred();
diff --git a/packages/diffs/test/editorSelection.test.ts b/packages/diffs/test/editorSelection.test.ts
index 9b51f65f4..089fa3e26 100644
--- a/packages/diffs/test/editorSelection.test.ts
+++ b/packages/diffs/test/editorSelection.test.ts
@@ -375,6 +375,30 @@ describe('convertSelection', () => {
);
});
+ test('maps an inline fold indicator to the folded header end', () => {
+ const icon = element('SVG', [element('USE')]);
+ const ellipsis = element('BUTTON', [icon]);
+ const indicator = element('SPAN', [ellipsis]);
+ indicator.dataset.foldIndicator = '';
+ indicator.dataset.foldCharacter = '18';
+ const renderedLine = pre(6, [span('function outer() {', 0), indicator]);
+
+ expect(
+ convertSelection(composedRange(renderedLine as unknown as Node, 2))
+ ).toEqual({
+ start: { line: 6, character: 18 },
+ end: { line: 6, character: 18 },
+ direction: DirectionNone,
+ });
+ expect(
+ convertSelection(composedRange(ellipsis as unknown as Node, 0))
+ ).toEqual({
+ start: { line: 6, character: 18 },
+ end: { line: 6, character: 18 },
+ direction: DirectionNone,
+ });
+ });
+
test('maps a text node inside a nested diff-span token', () => {
const diffToken = span('_diff', 15);
const diff = diffSpan(diffToken, span(':', 20), span(' FileMetadata', 22));
From 5aaaa3751c7689ad4c33ca776c44fd63809b3d5f Mon Sep 17 00:00:00 2001
From: Je Xia
Date: Fri, 17 Jul 2026 01:59:28 +0800
Subject: [PATCH 3/5] fix e2e testing
---
packages/diffs/test/e2e/folding.pw.ts | 13 ++++---------
1 file changed, 4 insertions(+), 9 deletions(-)
diff --git a/packages/diffs/test/e2e/folding.pw.ts b/packages/diffs/test/e2e/folding.pw.ts
index 3af2cec48..f1e4072e7 100644
--- a/packages/diffs/test/e2e/folding.pw.ts
+++ b/packages/diffs/test/e2e/folding.pw.ts
@@ -1,4 +1,4 @@
-import { expect, type Locator, type Page, test } from '@playwright/test';
+import { expect, type Page, test } from '@playwright/test';
const GUTTER = '[data-code][data-editor-folding] [data-gutter]';
const CONTENT_ROWS = '[data-content] > [data-line]';
@@ -18,9 +18,6 @@ const renderedLineNumbers = (page: Page): Promise =>
rows.map((row) => Number((row as HTMLElement).dataset.line))
);
-const color = (toggle: Locator): Promise =>
- toggle.evaluate((element) => getComputedStyle(element).color);
-
const firstTokenColor = (page: Page): Promise =>
page
.locator('[data-content] > [data-line="1"] [data-char]')
@@ -42,12 +39,10 @@ test.describe('editor folding controls', () => {
await expect(toggle).toHaveCSS('opacity', '0');
await gutter.hover();
- await expect(toggle).toHaveCSS('opacity', '0.72');
- const gutterHoverColor = await color(toggle);
+ await expect(toggle).toHaveCSS('opacity', '0.5');
await toggle.hover();
- await expect(toggle).toHaveCSS('opacity', '1');
- await expect.poll(() => color(toggle)).not.toBe(gutterHoverColor);
+ await expect(toggle).toHaveCSS('opacity', '0.75');
await toggle.click();
await expect.poll(() => renderedLineNumbers(page)).toEqual([1, 7, 8]);
@@ -59,7 +54,7 @@ test.describe('editor folding controls', () => {
'href',
'#diffs-editor-icon-chevron-right'
);
- await expect(toggle).toHaveCSS('opacity', '0.72');
+ await expect(toggle).toHaveCSS('opacity', '0.5');
const indicator = page.locator(OUTER_INDICATOR);
const ellipsis = indicator.locator('[data-fold-ellipsis]');
From c8fab1d2e65eeed448ad5460b5c3c5f35b50cb80 Mon Sep 17 00:00:00 2001
From: Je Xia
Date: Wed, 22 Jul 2026 10:18:49 +0800
Subject: [PATCH 4/5] fix codex
---
packages/diffs/src/renderers/FileRenderer.ts | 66 ++++++++++----
packages/diffs/src/types.ts | 1 +
.../src/utils/renderFileWithHighlighter.ts | 91 +++++++++++++------
.../diffs/src/worker/WorkerPoolManager.ts | 12 ++-
.../test/VirtualizedFile.folding.test.ts | 70 ++++++++++++--
5 files changed, 184 insertions(+), 56 deletions(-)
diff --git a/packages/diffs/src/renderers/FileRenderer.ts b/packages/diffs/src/renderers/FileRenderer.ts
index 268b8ec3d..075787c2e 100644
--- a/packages/diffs/src/renderers/FileRenderer.ts
+++ b/packages/diffs/src/renderers/FileRenderer.ts
@@ -148,6 +148,10 @@ export class FileRenderer {
public setFoldRanges(ranges: LineRange[]): void {
this.foldRanges = ranges;
+ if (this.renderCache?.highlighted === false) {
+ this.renderCache.result = undefined;
+ this.renderCache.renderRange = undefined;
+ }
}
public cleanUp(): void {
@@ -491,7 +495,8 @@ export class FileRenderer {
file,
renderRange.startingLine,
renderRange.totalLines,
- lines
+ lines,
+ this.foldRanges
);
}
this.renderCache.renderRange = renderRange;
@@ -521,20 +526,23 @@ export class FileRenderer {
hasThemes &&
(forceHighlight ||
forcePlainText ||
+ (!this.renderCache.highlighted && newRenderRange) ||
(!this.renderCache.highlighted && canHighlight) ||
this.renderCache.result == null)
) {
+ const renderedPlainText = forcePlainText || !hasLangs;
const { result, options } = this.renderFileWithHighlighter(
file,
this.highlighter,
- forcePlainText || !hasLangs
+ renderedPlainText,
+ renderedPlainText ? renderRange : undefined
);
this.renderCache = {
file,
options,
highlighted: canHighlight,
result,
- renderRange: undefined,
+ renderRange: renderedPlainText ? renderRange : undefined,
};
}
@@ -542,14 +550,22 @@ export class FileRenderer {
// process which will involve initializing the highlighter with new themes
// and languages
if (!hasThemes || (!forcePlainText && !hasLangs)) {
- void this.asyncHighlight(file).then(({ result, options }) => {
- // In this case we need to force a re-render, so we can do that by
- // reaching into renderCache
- if (this.renderCache != null) {
- this.renderCache.highlighted = false;
+ void this.asyncHighlight(file, renderRange).then(
+ ({ result, options }) => {
+ // In this case we need to force a re-render, so we can do that by
+ // reaching into renderCache
+ if (this.renderCache != null) {
+ this.renderCache.highlighted = false;
+ }
+ this.onHighlightSuccess(
+ file,
+ result,
+ options,
+ !forcePlainText,
+ renderRange
+ );
}
- this.onHighlightSuccess(file, result, options, !forcePlainText);
- });
+ );
}
}
@@ -566,16 +582,19 @@ export class FileRenderer {
file: FileContents,
renderRange: RenderRange = DEFAULT_RENDER_RANGE
): Promise {
- const { result } = await this.asyncHighlight(file);
+ const { result } = await this.asyncHighlight(file, renderRange);
return this.processFileResult(file, renderRange, result);
}
- private async asyncHighlight(file: FileContents): Promise {
+ private async asyncHighlight(
+ file: FileContents,
+ renderRange?: RenderRange
+ ): Promise {
const lines = this.getOrCreateLineCache(file);
- const forcePlainText = isFileMassive(
- lines.length,
- this.getTokenizeMaxLength()
- );
+ const forcePlainText =
+ file.contents.length === 0 ||
+ isFilePlainText(file) ||
+ isFileMassive(lines.length, this.getTokenizeMaxLength());
this.computedLang = forcePlainText
? 'text'
: (file.lang ?? getFiletypeFromFileName(file.name));
@@ -593,18 +612,24 @@ export class FileRenderer {
return this.renderFileWithHighlighter(
file,
this.highlighter,
- forcePlainText
+ forcePlainText,
+ forcePlainText ? renderRange : undefined
);
}
private renderFileWithHighlighter(
file: FileContents,
highlighter: DiffsHighlighter,
- forcePlainText = false
+ forcePlainText = false,
+ renderRange?: RenderRange
): RenderFileResult {
const { options } = this.getRenderOptions(file);
const result = renderFileWithHighlighter(file, highlighter, options, {
forcePlainText,
+ startingLine: renderRange?.startingLine,
+ totalLines: renderRange?.totalLines,
+ lines: renderRange == null ? undefined : this.getOrCreateLineCache(file),
+ hiddenLineRanges: renderRange == null ? undefined : this.foldRanges,
});
return { result, options };
}
@@ -792,7 +817,8 @@ export class FileRenderer {
file: FileContents,
result: ThemedFileResult,
options: RenderFileOptions,
- highlighted = true
+ highlighted = true,
+ renderRange?: RenderRange
): void {
if (this.renderCache == null) {
return;
@@ -807,7 +833,7 @@ export class FileRenderer {
options,
highlighted,
result,
- renderRange: undefined,
+ renderRange: highlighted ? undefined : renderRange,
};
if (triggerRenderUpdate) {
diff --git a/packages/diffs/src/types.ts b/packages/diffs/src/types.ts
index f71a97878..08ce4ce13 100644
--- a/packages/diffs/src/types.ts
+++ b/packages/diffs/src/types.ts
@@ -793,6 +793,7 @@ export interface ForceFilePlainTextOptions {
totalLines?: number;
// Pre-split lines for caching in windowing scenarios
lines?: string[];
+ hiddenLineRanges?: readonly LineRange[];
}
export interface RenderFileOptions {
diff --git a/packages/diffs/src/utils/renderFileWithHighlighter.ts b/packages/diffs/src/utils/renderFileWithHighlighter.ts
index cfcd23c7f..b50195063 100644
--- a/packages/diffs/src/utils/renderFileWithHighlighter.ts
+++ b/packages/diffs/src/utils/renderFileWithHighlighter.ts
@@ -32,6 +32,7 @@ export function renderFileWithHighlighter(
startingLine,
totalLines,
lines,
+ hiddenLineRanges,
}: ForceFilePlainTextOptions = DEFAULT_PLAIN_TEXT_OPTIONS
): ThemedFileResult {
if (forcePlainText) {
@@ -46,6 +47,45 @@ export function renderFileWithHighlighter(
totalLines = Infinity;
}
const isWindowedHighlight = startingLine > 0 || totalLines < Infinity;
+ let windowEndLine = startingLine;
+ let renderedLineIndexes: number[] | undefined;
+ let contents = file.contents;
+ if (isWindowedHighlight) {
+ const sourceLines = lines ?? linesFromFileContents(file.contents);
+ windowEndLine = Math.min(startingLine + totalLines, sourceLines.length);
+ if (hiddenLineRanges != null && hiddenLineRanges.length > 0) {
+ renderedLineIndexes = [];
+ const renderedLines: string[] = [];
+ let low = 0;
+ let high = hiddenLineRanges.length;
+ while (low < high) {
+ const middle = low + ((high - low) >> 1);
+ if (hiddenLineRanges[middle].endLine < startingLine) {
+ low = middle + 1;
+ } else {
+ high = middle;
+ }
+ }
+ let foldedRangeIndex = low;
+ for (let lineIndex = startingLine; lineIndex < windowEndLine; ) {
+ while (hiddenLineRanges[foldedRangeIndex]?.endLine < lineIndex) {
+ foldedRangeIndex++;
+ }
+ const foldedRange = hiddenLineRanges[foldedRangeIndex];
+ if (foldedRange != null && lineIndex >= foldedRange.startLine) {
+ lineIndex = foldedRange.endLine + 1;
+ foldedRangeIndex++;
+ continue;
+ }
+ renderedLineIndexes.push(lineIndex);
+ renderedLines.push(sourceLines[lineIndex] ?? '');
+ lineIndex++;
+ }
+ contents = renderedLines.join('');
+ } else {
+ contents = sourceLines.slice(startingLine, windowEndLine).join('');
+ }
+ }
const { state, transformers } =
createTransformerWithState(useTokenTransformer);
const lang = forcePlainText
@@ -57,11 +97,18 @@ export function renderFileWithHighlighter(
theme,
highlighter,
});
- state.lineInfo = (shikiLineNumber: number) => ({
- type: 'context',
- lineIndex: shikiLineNumber - 1 + startingLine,
- lineNumber: shikiLineNumber + startingLine,
- });
+ state.lineInfo = (shikiLineNumber: number) => {
+ const lineIndex =
+ renderedLineIndexes?.[shikiLineNumber - 1] ??
+ (renderedLineIndexes == null
+ ? shikiLineNumber - 1 + startingLine
+ : windowEndLine);
+ return {
+ type: 'context',
+ lineIndex,
+ lineNumber: lineIndex + 1,
+ };
+ };
// tokenizeTimeLimit: 0 disables shiki's silent 500ms-per-line tokenization
// abort. When it trips (slow devices, cold JS-regex-engine compile), the
// rest of the line collapses to the enclosing scope's color — and since
@@ -91,35 +138,23 @@ export function renderFileWithHighlighter(
};
})();
const highlightedLines = getLineNodes(
- highlighter.codeToHast(
- isWindowedHighlight
- ? extractWindowedFileContent(
- lines ?? linesFromFileContents(file.contents),
- startingLine,
- totalLines
- )
- : file.contents,
- hastConfig
- )
+ highlighter.codeToHast(contents, hastConfig)
);
// Create sparse array for windowed rendering
const code = isWindowedHighlight ? new Array(startingLine) : highlightedLines;
if (isWindowedHighlight) {
- code.push(...highlightedLines);
+ if (renderedLineIndexes == null) {
+ code.push(...highlightedLines);
+ } else {
+ for (let index = 0; index < renderedLineIndexes.length; index++) {
+ const line = highlightedLines[index];
+ if (line != null) {
+ code[renderedLineIndexes[index]] = line;
+ }
+ }
+ }
}
return { code, themeStyles, baseThemeType };
}
-
-function extractWindowedFileContent(
- lines: string[],
- startingLine: number,
- totalLines: number
-): string {
- if (lines.length === 0) {
- return '';
- }
- const endLine = Math.min(startingLine + totalLines, lines.length);
- return lines.slice(startingLine, endLine).join('');
-}
diff --git a/packages/diffs/src/worker/WorkerPoolManager.ts b/packages/diffs/src/worker/WorkerPoolManager.ts
index 5cc968fef..de24ca5bf 100644
--- a/packages/diffs/src/worker/WorkerPoolManager.ts
+++ b/packages/diffs/src/worker/WorkerPoolManager.ts
@@ -15,6 +15,7 @@ import type {
FileDiffMetadata,
HighlighterTypes,
HunkExpansionRegion,
+ LineRange,
RenderDiffOptions,
RenderDiffResult,
RenderFileOptions,
@@ -633,7 +634,8 @@ export class WorkerPoolManager {
file: FileContents,
startingLine: number,
totalLines: number,
- lines?: string[]
+ lines?: string[],
+ hiddenLineRanges?: readonly LineRange[]
): ThemedFileResult | undefined {
if (this.highlighter == null) {
this.queueInitialization();
@@ -643,7 +645,13 @@ export class WorkerPoolManager {
file,
this.highlighter,
this.renderOptions,
- { forcePlainText: true, startingLine, totalLines, lines }
+ {
+ forcePlainText: true,
+ startingLine,
+ totalLines,
+ lines,
+ hiddenLineRanges,
+ }
);
}
diff --git a/packages/diffs/test/VirtualizedFile.folding.test.ts b/packages/diffs/test/VirtualizedFile.folding.test.ts
index ecfd7e84e..0ce88739f 100644
--- a/packages/diffs/test/VirtualizedFile.folding.test.ts
+++ b/packages/diffs/test/VirtualizedFile.folding.test.ts
@@ -1,13 +1,18 @@
-import { describe, expect, test } from 'bun:test';
+import { afterAll, describe, expect, test } from 'bun:test';
import { VirtualizedFile } from '../src/components/VirtualizedFile';
-import { DEFAULT_VIRTUAL_FILE_METRICS } from '../src/constants';
+import { DEFAULT_THEMES, DEFAULT_VIRTUAL_FILE_METRICS } from '../src/constants';
+import {
+ disposeHighlighter,
+ getSharedHighlighter,
+} from '../src/highlighter/shared_highlighter';
import type {
FileContents,
RenderRange,
RenderWindow,
VirtualFileMetrics,
} from '../src/types';
+import { WorkerPoolManager } from '../src/worker/WorkerPoolManager';
const metrics: VirtualFileMetrics = {
...DEFAULT_VIRTUAL_FILE_METRICS,
@@ -26,6 +31,13 @@ interface InspectableVirtualizedFile {
editorFoldedLineIndex: {
isHidden(lineIndex: number): boolean;
};
+ fileRenderer: {
+ renderCache?: { result?: { code: unknown[] } };
+ renderFile(
+ file: FileContents,
+ renderRange: RenderRange
+ ): { rowCount: number } | undefined;
+ };
renderRange: RenderRange | undefined;
computeApproximateSize(force?: boolean): void;
computeRenderRangeFromWindow(
@@ -35,6 +47,10 @@ interface InspectableVirtualizedFile {
): RenderRange;
}
+afterAll(async () => {
+ await disposeHighlighter();
+});
+
function inspect(instance: VirtualizedFile): InspectableVirtualizedFile {
return instance as unknown as InspectableVirtualizedFile;
}
@@ -160,13 +176,31 @@ describe('VirtualizedFile editor folding', () => {
expect(instance.getVirtualizedHeight()).toBe(211);
});
- test('jumps large folded bodies in variable-height layout scans', () => {
+ test('jumps large folded bodies in layout and plain render windows', async () => {
const lineCount = 20_000;
const file = createFile(lineCount);
+ const renderOptions = {
+ theme: DEFAULT_THEMES,
+ useTokenTransformer: false,
+ tokenizeMaxLineLength: 1_000,
+ };
+ const workerManager = {
+ highlighter: await getSharedHighlighter({
+ themes: Object.values(DEFAULT_THEMES),
+ langs: ['text'],
+ }),
+ renderOptions,
+ getPlainFileAST: WorkerPoolManager.prototype.getPlainFileAST,
+ getFileRenderOptions: () => renderOptions,
+ getFileResultCache: () => undefined,
+ isWorkingPool: () => true,
+ subscribeToThemeChanges() {},
+ } as unknown as WorkerPoolManager;
const instance = new VirtualizedFile(
- { overflow: 'wrap' },
+ { overflow: 'wrap', tokenizeMaxLength: 1 },
createVirtualizer([]),
- metrics
+ metrics,
+ workerManager
);
instance.prepareCodeViewItem(file, 0);
instance.__setFoldRanges([{ startLine: 1, endLine: 19_990 }]);
@@ -197,9 +231,26 @@ describe('VirtualizedFile editor folding', () => {
top: 40,
bottom: 60,
});
- expect(range.startingLine).toBe(0);
+ expect(range).toEqual({
+ startingLine: 0,
+ totalLines: 19_996,
+ bufferBefore: 0,
+ bufferAfter: 40,
+ });
expect(hiddenChecks).toBeLessThan(10);
+ const result = layout.fileRenderer.renderFile(file, range);
+ const code = layout.fileRenderer.renderCache?.result?.code;
+ expect(result?.rowCount).toBe(6);
+ expect(Object.keys(code ?? [])).toEqual([
+ '0',
+ '19991',
+ '19992',
+ '19993',
+ '19994',
+ '19995',
+ ]);
+
hiddenChecks = 0;
layout.renderRange = {
startingLine: 0,
@@ -212,5 +263,12 @@ describe('VirtualizedFile editor folding', () => {
top: 50,
});
expect(hiddenChecks).toBe(0);
+
+ instance.__setFoldRanges([]);
+ const unfoldedResult = layout.fileRenderer.renderFile(file, range);
+ const unfoldedCode = layout.fileRenderer.renderCache?.result?.code;
+ expect(unfoldedResult?.rowCount).toBe(19_996);
+ expect(unfoldedCode?.[1]).toBeDefined();
+ expect(unfoldedCode?.[19_995]).toBeDefined();
});
});
From 37d9faee4350ed6101038eab1ba07c9dafbbfad2 Mon Sep 17 00:00:00 2001
From: Je Xia
Date: Fri, 24 Jul 2026 00:45:24 +0800
Subject: [PATCH 5/5] Add FoldingManager
---
apps/demo/src/codeViewDemo.ts | 1 +
apps/demo/src/main.ts | 2 +
.../app/(diffs)/_examples/Folding/Folding.tsx | 92 +++
.../(diffs)/_examples/Folding/constants.ts | 87 +++
apps/docs/app/(diffs)/_home/Home.tsx | 20 +
.../app/(diffs)/docs/CodeView/content.mdx | 2 +
apps/docs/app/(diffs)/docs/Editor/content.mdx | 4 +
.../app/(diffs)/docs/ReactAPI/constants.ts | 11 +
.../app/(diffs)/docs/ReactAPI/content.mdx | 7 +
.../app/(diffs)/docs/VanillaAPI/constants.ts | 11 +
.../app/(diffs)/docs/VanillaAPI/content.mdx | 7 +
packages/diffs/README.md | 1 +
packages/diffs/src/components/CodeView.ts | 3 +
packages/diffs/src/components/File.ts | 60 ++
packages/diffs/src/components/FileDiff.ts | 219 ++++++-
.../diffs/src/components/UnresolvedFile.ts | 2 +-
.../diffs/src/components/VirtualizedFile.ts | 33 +-
.../src/components/VirtualizedFileDiff.ts | 482 +++++++++++++--
packages/diffs/src/editor/editor.css | 102 ---
packages/diffs/src/editor/editor.ts | 62 +-
packages/diffs/src/managers/FoldingManager.ts | 509 +++++++++++++++
.../diffs/src/renderers/DiffHunksRenderer.ts | 133 +++-
packages/diffs/src/sprite.ts | 12 +
packages/diffs/src/style.css | 102 +++
packages/diffs/src/types.ts | 8 +-
.../test/VirtualizedFile.folding.test.ts | 45 ++
packages/diffs/test/e2e/fixtures/folding.html | 125 +++-
packages/diffs/test/e2e/fixtures/index.html | 7 +-
packages/diffs/test/e2e/folding.pw.ts | 155 ++++-
packages/diffs/test/editorFolding.test.ts | 584 ++++++++++++++++--
...irtualizedFileDiffEstimatedHeights.test.ts | 90 +++
31 files changed, 2625 insertions(+), 353 deletions(-)
create mode 100644 apps/docs/app/(diffs)/_examples/Folding/Folding.tsx
create mode 100644 apps/docs/app/(diffs)/_examples/Folding/constants.ts
create mode 100644 packages/diffs/src/managers/FoldingManager.ts
diff --git a/apps/demo/src/codeViewDemo.ts b/apps/demo/src/codeViewDemo.ts
index 27ba2d8e7..382625dbc 100644
--- a/apps/demo/src/codeViewDemo.ts
+++ b/apps/demo/src/codeViewDemo.ts
@@ -98,6 +98,7 @@ export function renderDemoCodeView(
theme,
themeType,
diffStyle,
+ folding: true,
overflow,
loadDiffFiles,
renderAnnotation(annotation) {
diff --git a/apps/demo/src/main.ts b/apps/demo/src/main.ts
index df8115565..ff053ef46 100644
--- a/apps/demo/src/main.ts
+++ b/apps/demo/src/main.ts
@@ -421,6 +421,7 @@ function renderDiff(parsedPatches: ParsedPatch[], manager?: WorkerPoolManager) {
theme: DEMO_THEME,
themeType,
diffStyle: unified ? 'unified' : 'split',
+ folding: true,
overflow: wrap ? 'wrap' : 'scroll',
renderAnnotation: renderDiffAnnotation,
...(RENDER_FILENAME_SUFFIX
@@ -1064,6 +1065,7 @@ if (renderFileButton != null) {
overflow: wrap ? 'wrap' : 'scroll',
theme: DEMO_THEME,
themeType: getThemeType(),
+ folding: true,
renderAnnotation,
...(RENDER_FILENAME_SUFFIX
? {
diff --git a/apps/docs/app/(diffs)/_examples/Folding/Folding.tsx b/apps/docs/app/(diffs)/_examples/Folding/Folding.tsx
new file mode 100644
index 000000000..f34123546
--- /dev/null
+++ b/apps/docs/app/(diffs)/_examples/Folding/Folding.tsx
@@ -0,0 +1,92 @@
+'use client';
+
+import { File, FileDiff } from '@pierre/diffs/react';
+import type {
+ PreloadedFileResult,
+ PreloadFileDiffResult,
+} from '@pierre/diffs/ssr';
+import { IconDiffSplit, IconDiffUnified } from '@pierre/icons';
+import { useState } from 'react';
+
+import { FeatureHeader } from '@/components/FeatureHeader';
+import { ButtonGroup, ButtonGroupItem } from '@/components/ui/button-group';
+
+interface FoldingProps {
+ prerenderedFile: PreloadedFileResult;
+ prerenderedDiff: PreloadFileDiffResult;
+}
+
+type Surface = 'file' | 'diff';
+type DiffLayout = 'unified' | 'split';
+
+export function Folding({ prerenderedFile, prerenderedDiff }: FoldingProps) {
+ const [surface, setSurface] = useState('file');
+ const [diffLayout, setDiffLayout] = useState(
+ prerenderedDiff.options?.diffStyle === 'split' ? 'split' : 'unified'
+ );
+
+ return (
+
+
+ Enable folding on a read-only File or{' '}
+ FileDiff. Hover the gutter and use the chevrons to
+ collapse nested blocks in either unified or split layouts.
+ >
+ }
+ />
+
+
+ setSurface(value as Surface)}
+ aria-label="Surface"
+ >
+
+ File
+
+
+ FileDiff
+
+
+
+ setDiffLayout(value as DiffLayout)}
+ aria-label="Diff layout"
+ size="icon"
+ >
+
+
+
+
+
+
+
+
+
+ {surface === 'file' ? (
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/apps/docs/app/(diffs)/_examples/Folding/constants.ts b/apps/docs/app/(diffs)/_examples/Folding/constants.ts
new file mode 100644
index 000000000..879574b94
--- /dev/null
+++ b/apps/docs/app/(diffs)/_examples/Folding/constants.ts
@@ -0,0 +1,87 @@
+import {
+ DEFAULT_THEMES,
+ type FileContents,
+ parseDiffFromFile,
+} from '@pierre/diffs';
+import type {
+ PreloadFileDiffOptions,
+ PreloadFileOptions,
+} from '@pierre/diffs/ssr';
+
+import { CustomScrollbarCSS } from '@/components/CustomScrollbarCSS';
+
+const FOLDING_OLD_FILE: FileContents = {
+ name: 'tasks.ts',
+ contents: `export interface Task {
+ project: string;
+ title: string;
+ done: boolean;
+}
+
+export function groupOpenTasks(tasks: Task[]) {
+ const groups = new Map();
+
+ for (const task of tasks) {
+ if (task.done) {
+ continue;
+ }
+
+ const titles = groups.get(task.project) ?? [];
+ titles.push(task.title);
+ groups.set(task.project, titles);
+ }
+
+ return groups;
+}
+`,
+};
+
+const FOLDING_NEW_FILE: FileContents = {
+ name: 'tasks.ts',
+ contents: `export interface Task {
+ project: string;
+ title: string;
+ done: boolean;
+ priority: number;
+}
+
+export function groupOpenTasks(tasks: Task[]) {
+ const groups = new Map();
+
+ for (const task of tasks) {
+ if (task.done || task.priority < 1) {
+ continue;
+ }
+
+ const group = groups.get(task.project) ?? [];
+ group.push(task);
+ groups.set(task.project, group);
+ }
+
+ return groups;
+}
+`,
+};
+
+const FOLDING_OPTIONS = {
+ theme: DEFAULT_THEMES,
+ themeType: 'dark',
+ folding: true,
+ unsafeCSS: CustomScrollbarCSS,
+ useTokenTransformer: true,
+} as const;
+
+export const FOLDING_FILE_EXAMPLE: PreloadFileOptions = {
+ file: FOLDING_NEW_FILE,
+ options: FOLDING_OPTIONS,
+};
+
+export const FOLDING_DIFF_EXAMPLE: PreloadFileDiffOptions = {
+ fileDiff: parseDiffFromFile(FOLDING_OLD_FILE, FOLDING_NEW_FILE, {
+ context: 20,
+ }),
+ options: {
+ ...FOLDING_OPTIONS,
+ diffStyle: 'unified',
+ },
+};
diff --git a/apps/docs/app/(diffs)/_home/Home.tsx b/apps/docs/app/(diffs)/_home/Home.tsx
index 83efd95dc..8b1754715 100644
--- a/apps/docs/app/(diffs)/_home/Home.tsx
+++ b/apps/docs/app/(diffs)/_home/Home.tsx
@@ -1,4 +1,5 @@
import {
+ preloadFile,
preloadFileDiff,
preloadMultiFileDiff,
preloadUnresolvedFile,
@@ -17,6 +18,11 @@ import { CUSTOM_HUNK_SEPARATORS_EXAMPLE } from '../_examples/CustomHunkSeparator
import { CustomHunkSeparators } from '../_examples/CustomHunkSeparators/CustomHunkSeparators';
import { DIFF_STYLES } from '../_examples/DiffStyles/constants';
import { DiffStyles } from '../_examples/DiffStyles/DiffStyles';
+import {
+ FOLDING_DIFF_EXAMPLE,
+ FOLDING_FILE_EXAMPLE,
+} from '../_examples/Folding/constants';
+import { Folding } from '../_examples/Folding/Folding';
import { FONT_STYLES } from '../_examples/FontStyles/constants';
import { FontStyles } from '../_examples/FontStyles/FontStyles';
import { LINE_SELECTION_EXAMPLE } from '../_examples/LineSelection/constants';
@@ -55,6 +61,7 @@ export default function Home() {
+
@@ -86,6 +93,19 @@ async function SplitUnifiedSection() {
);
}
+async function FoldingSection() {
+ const [prerenderedFile, prerenderedDiff] = await Promise.all([
+ preloadFile(FOLDING_FILE_EXAMPLE),
+ preloadFileDiff(FOLDING_DIFF_EXAMPLE),
+ ]);
+ return (
+
+ );
+}
+
async function ShikiThemesSection() {
return (
diff --git a/apps/docs/app/(diffs)/docs/CodeView/content.mdx b/apps/docs/app/(diffs)/docs/CodeView/content.mdx
index fe54c5a54..f4774b2c5 100644
--- a/apps/docs/app/(diffs)/docs/CodeView/content.mdx
+++ b/apps/docs/app/(diffs)/docs/CodeView/content.mdx
@@ -55,6 +55,8 @@ immutability or deep equality checks, which can quickly become expensive.
- CodeView-level options such as `layout`, `itemMetrics`, `stickyHeaders`,
`pointerEventsOnScroll`, and `smoothScrollSettings` allow you to configure the
scroll view. All other options are shared between all files and diffs.
+- `folding` is one of those shared options. Set it to `true` to enable
+ indentation-based folding for read-only file and diff items.
- `loadDiffFiles` is one of those shared diff options. It applies to diff items
rendered inside `CodeView`, which is useful for large patch-driven review UIs
where full file contents should be fetched only when users expand unchanged
diff --git a/apps/docs/app/(diffs)/docs/Editor/content.mdx b/apps/docs/app/(diffs)/docs/Editor/content.mdx
index b0b781cb4..dd3556d56 100644
--- a/apps/docs/app/(diffs)/docs/Editor/content.mdx
+++ b/apps/docs/app/(diffs)/docs/Editor/content.mdx
@@ -260,6 +260,10 @@ independent editor instances.
Pass these when constructing `new Editor({ ... })`, or update them later with
[`setOptions`](#edit-mode-api-reference).
+Code folding is a `File` or `FileDiff` surface option, not an `Editor` option.
+Editable surfaces enable folding by default; set `folding: false` on the surface
+to disable it. Read-only surfaces require `folding: true`.
+
#### Persisting file state
diff --git a/apps/docs/app/(diffs)/docs/ReactAPI/constants.ts b/apps/docs/app/(diffs)/docs/ReactAPI/constants.ts
index d827448a4..6b5e26377 100644
--- a/apps/docs/app/(diffs)/docs/ReactAPI/constants.ts
+++ b/apps/docs/app/(diffs)/docs/ReactAPI/constants.ts
@@ -152,6 +152,13 @@ interface DiffOptions {
// Show colored backgrounds on changed lines (default: false)
disableBackground: false,
+ // Enable indentation-based code folding. Read-only diffs opt in with true;
+ // editable diffs fold by default unless this surface option is false.
+ // Split and unified layouts are supported. Partial changed/renamed patch
+ // diffs need loadDiffFiles to supply complete lines, and expanded unchanged
+ // context remains visible instead of being hidden by a fold.
+ folding: true,
+
// ─────────────────────────────────────────────────────────────
// HUNK SEPARATORS
// ─────────────────────────────────────────────────────────────
@@ -948,6 +955,10 @@ interface FileOptions {
// Hide the file header with filename
disableFileHeader: false,
+ // Enable indentation-based code folding. Read-only files opt in with true;
+ // editable files fold by default unless this surface option is false.
+ folding: true,
+
// Rethrow rendering errors instead of catching and displaying them
// in the DOM. Useful for testing or custom error handling.
// (default: false)
diff --git a/apps/docs/app/(diffs)/docs/ReactAPI/content.mdx b/apps/docs/app/(diffs)/docs/ReactAPI/content.mdx
index 957a3abdb..6035be583 100644
--- a/apps/docs/app/(diffs)/docs/ReactAPI/content.mdx
+++ b/apps/docs/app/(diffs)/docs/ReactAPI/content.mdx
@@ -75,6 +75,13 @@ common set of props for configuration, annotations, and styling. The `File`
component has similar props, but uses `LineAnnotation` instead of
`DiffLineAnnotation` (no `side` property).
+Code folding belongs to the `File` or diff component's `options`, not
+`editOptions`. Set `options.folding: true` to enable indentation-based folding
+on a read-only surface. Editable surfaces enable folding by default; set the
+same surface option to `false` to disable it. `FileDiff` supports both split and
+unified layouts, and folding does not hide rows revealed by expanded unchanged
+context.
+
When one of these components is attached to an `Editor`, keep its annotations in
application-owned state and replace them with the current collection emitted by
`Editor.onChange`. See
diff --git a/apps/docs/app/(diffs)/docs/VanillaAPI/constants.ts b/apps/docs/app/(diffs)/docs/VanillaAPI/constants.ts
index 69db1906f..2b1d46cdf 100644
--- a/apps/docs/app/(diffs)/docs/VanillaAPI/constants.ts
+++ b/apps/docs/app/(diffs)/docs/VanillaAPI/constants.ts
@@ -378,6 +378,13 @@ const instance = new FileDiff({
// Show colored backgrounds on changed lines (default: false)
disableBackground: false,
+ // Enable indentation-based code folding. Read-only diffs opt in with true;
+ // editable diffs fold by default unless this surface option is false.
+ // Split and unified layouts are supported. Partial changed/renamed patch
+ // diffs need loadDiffFiles to supply complete lines, and expanded unchanged
+ // context remains visible instead of being hidden by a fold.
+ folding: true,
+
// ─────────────────────────────────────────────────────────────
// HUNK SEPARATORS
// ─────────────────────────────────────────────────────────────
@@ -729,6 +736,10 @@ const instance = new File({
// Hide the file header with filename
disableFileHeader: false,
+ // Enable indentation-based code folding. Read-only files opt in with true;
+ // editable files fold by default unless this surface option is false.
+ folding: true,
+
// Rethrow rendering errors instead of catching and displaying them
// in the DOM. Useful for testing or custom error handling.
// (default: false)
diff --git a/apps/docs/app/(diffs)/docs/VanillaAPI/content.mdx b/apps/docs/app/(diffs)/docs/VanillaAPI/content.mdx
index d102a0c4b..e63742b1f 100644
--- a/apps/docs/app/(diffs)/docs/VanillaAPI/content.mdx
+++ b/apps/docs/app/(diffs)/docs/VanillaAPI/content.mdx
@@ -40,6 +40,13 @@ Both `FileDiff` and `File` accept an options object in their constructor. The
`File` component has similar options, but excludes diff-specific settings and
uses `LineAnnotation` instead of `DiffLineAnnotation` (no `side` property).
+Code folding belongs to the `File` or `FileDiff` constructor options, not
+`new Editor(...)`. Set `folding: true` to enable indentation-based folding on a
+read-only surface. Editable surfaces enable folding by default; set the same
+surface option to `false` to disable it. `FileDiff` supports both split and
+unified layouts, and folding does not hide rows revealed by expanded unchanged
+context.
+
When one of these components is attached to an `Editor`, keep its annotations in
an external variable or store and replace them with the current collection
emitted by `Editor.onChange`. See
diff --git a/packages/diffs/README.md b/packages/diffs/README.md
index cc61b819c..aa5456fb3 100644
--- a/packages/diffs/README.md
+++ b/packages/diffs/README.md
@@ -20,6 +20,7 @@ JavaScript and React components.
- Flexible annotation framework for injecting comments, annotations, and more
- Add your own accept/reject changes UI
- Select and highlight lines
+- Fold indentation blocks in read-only or editable files and diffs
## Install
diff --git a/packages/diffs/src/components/CodeView.ts b/packages/diffs/src/components/CodeView.ts
index ce1dafc9b..c2d185e52 100644
--- a/packages/diffs/src/components/CodeView.ts
+++ b/packages/diffs/src/components/CodeView.ts
@@ -278,6 +278,7 @@ export const CODE_VIEW_DIFF_OPTION_KEYS = [
'tokenizeMaxLineLength',
'tokenizeMaxLength',
'unsafeCSS',
+ 'folding',
'diffStyle',
'diffIndicators',
'disableBackground',
@@ -311,6 +312,7 @@ export const CODE_VIEW_FILE_OPTION_KEYS = [
'tokenizeMaxLineLength',
'tokenizeMaxLength',
'unsafeCSS',
+ 'folding',
'lineHoverHighlight',
'enableTokenInteractionsOnWhitespace',
'enableGutterUtility',
@@ -4074,6 +4076,7 @@ function hasItemLayoutOptionChanged(
(previousOptions.disableFileHeader ?? false) !==
(nextOptions.disableFileHeader ?? false) ||
previousOptions.unsafeCSS !== nextOptions.unsafeCSS ||
+ (previousOptions.folding ?? false) !== (nextOptions.folding ?? false) ||
(previousOptions.diffStyle ?? 'split') !==
(nextOptions.diffStyle ?? 'split') ||
(previousOptions.diffIndicators ?? 'bars') !==
diff --git a/packages/diffs/src/components/File.ts b/packages/diffs/src/components/File.ts
index 95b2cfdeb..47b7941aa 100644
--- a/packages/diffs/src/components/File.ts
+++ b/packages/diffs/src/components/File.ts
@@ -13,6 +13,7 @@ import {
THEME_CSS_ATTRIBUTE,
UNSAFE_CSS_ATTRIBUTE,
} from '../constants';
+import { FoldingManager } from '../managers/FoldingManager';
import {
type GetHoveredLineResult,
InteractionManager,
@@ -89,6 +90,11 @@ export interface FileHydrateProps extends Omit<
export interface FileOptions
extends BaseCodeOptions, InteractionManagerBaseOptions<'file'> {
+ /**
+ * Enable indentation-based code folding. Read-only files require `true`;
+ * attached editors remain enabled by default unless set to `false`.
+ */
+ folding?: boolean;
disableFileHeader?: boolean;
renderHeaderPrefix?: RenderFileMetadata;
renderHeaderFilenameSuffix?: RenderFileMetadata;
@@ -178,6 +184,9 @@ export class File<
protected renderRange: RenderRange | undefined;
protected enabled = true;
protected foldRanges: LineRange[] = [];
+ protected foldingManager: FoldingManager = new FoldingManager({
+ onChange: (ranges) => this.__setFoldRanges(ranges),
+ });
protected editor: DiffsEditor | undefined;
@@ -240,9 +249,12 @@ export class File<
}
this.foldRanges = ranges.map((range) => ({ ...range }));
this.fileRenderer.setFoldRanges(this.foldRanges);
+ this.handleFoldRangesChanged();
return true;
}
+ protected handleFoldRangesChanged(): void {}
+
public onThemeChange(): void {
this.fileRenderer.clearRenderCache();
this.rerender();
@@ -403,6 +415,7 @@ export class File<
this.file = undefined;
}
this.foldRanges = [];
+ this.foldingManager.cleanUp();
this.enabled = false;
@@ -511,6 +524,7 @@ export class File<
}: HydrationSetup): void {
this.lineAnnotations = lineAnnotations ?? this.lineAnnotations;
this.file = file;
+ this.syncReadOnlyFolding(file, true);
this.fileRenderer.setOptions(getFileRendererOptions(this.options));
this.syncInteractionOptions();
if (this.pre == null) {
@@ -522,6 +536,7 @@ export class File<
this.injectUnsafeCSS();
this.managersDirty = true;
this.flushManagers();
+ this.setupReadOnlyFolding();
}
public getOrCreateLineCache(
@@ -567,6 +582,9 @@ export class File<
public attachEditor(editor: DiffsEditor): () => void {
this.editor?.cleanUp();
+ this.foldingManager.cleanUp();
+ const clearedReadOnlyFolds =
+ this.foldRanges.length > 0 && this.updateFoldRanges([]);
this.editor = editor;
const preparedFile =
this.file == null ? undefined : editor.__prepareFile?.(this.file);
@@ -577,11 +595,16 @@ export class File<
preventEmit: true,
renderRange: this.renderRange,
});
+ } else if (clearedReadOnlyFolds) {
+ this.rerender();
} else {
this.syncRenderViewToEditor();
}
return () => {
this.editor = undefined;
+ if (this.options.folding === true) {
+ this.rerender();
+ }
};
}
@@ -666,6 +689,7 @@ export class File<
this.cachedHeaderHTML = undefined;
}
this.file = file;
+ this.syncReadOnlyFolding(file, didFileChange);
this.fileRenderer.setOptions(getFileRendererOptions(this.options));
this.syncInteractionOptions();
if (lineAnnotations != null) {
@@ -766,6 +790,8 @@ export class File<
this.renderGutterUtility();
if (this.editor != null) {
this.syncRenderViewToEditor();
+ } else {
+ this.setupReadOnlyFolding();
}
} catch (error: unknown) {
if (disableErrorHandling) {
@@ -782,6 +808,40 @@ export class File<
return true;
}
+ protected syncReadOnlyFolding(
+ file: FileContents,
+ fileChanged: boolean
+ ): void {
+ if (this.editor != null) {
+ this.foldingManager.cleanUp();
+ return;
+ }
+ if (this.options.folding !== true) {
+ this.foldingManager.cleanUp();
+ if (this.foldRanges.length > 0) {
+ this.updateFoldRanges([]);
+ }
+ return;
+ }
+ if (fileChanged && this.foldRanges.length > 0) {
+ this.updateFoldRanges([]);
+ this.foldingManager.cleanUp();
+ }
+ this.foldingManager.setLines(
+ this.fileRenderer.getOrCreateLineCache(file),
+ 2,
+ fileChanged
+ );
+ }
+
+ private setupReadOnlyFolding(): void {
+ this.foldingManager.setup(
+ this.options.folding === true && this.editor == null
+ ? this.code
+ : undefined
+ );
+ }
+
private emitPostRender(unmount = false) {
const {
fileContainer,
diff --git a/packages/diffs/src/components/FileDiff.ts b/packages/diffs/src/components/FileDiff.ts
index 7a0cdb8f4..c938e4477 100644
--- a/packages/diffs/src/components/FileDiff.ts
+++ b/packages/diffs/src/components/FileDiff.ts
@@ -14,6 +14,7 @@ import {
THEME_CSS_ATTRIBUTE,
UNSAFE_CSS_ATTRIBUTE,
} from '../constants';
+import { FoldingManager } from '../managers/FoldingManager';
import {
type GetHoveredLineResult,
type GetLineIndexUtility,
@@ -49,6 +50,7 @@ import type {
HighlightedToken,
HunkData,
HunkSeparators,
+ LineRange,
MaybeDiffFileInput,
PostRenderPhase,
PrePropertiesConfig,
@@ -128,6 +130,16 @@ function canHydrateDiff(fileDiff: FileDiffMetadata): boolean {
);
}
+// Added/deleted patches already contain their only file side; changed partial
+// patches need hydration before indentation ranges cover the whole file.
+function hasCompleteFoldingLines(fileDiff: FileDiffMetadata): boolean {
+ return (
+ !fileDiff.isPartial ||
+ fileDiff.type === 'new' ||
+ fileDiff.type === 'deleted'
+ );
+}
+
export interface FileDiffRenderBaseProps {
fileDiff?: FileDiffMetadata;
deferManagers?: boolean;
@@ -158,6 +170,11 @@ export interface FileDiffOptions
extends
Omit,
InteractionManagerBaseOptions<'diff'> {
+ /**
+ * Enable indentation-based code folding. Read-only diffs require `true`;
+ * attached editors remain enabled by default unless set to `false`.
+ */
+ folding?: boolean;
hunkSeparators?:
| Exclude /**
* @deprecated Custom hunk separator functions are deprecated and will be
@@ -292,6 +309,28 @@ export class FileDiff<
private mounted = false;
protected enabled = true;
+ protected additionFoldRanges: LineRange[] = [];
+ protected deletionFoldRanges: LineRange[] = [];
+ protected additionFoldingManager: FoldingManager = new FoldingManager({
+ side: 'additions',
+ onChange: (ranges) => this.__setFoldRanges(ranges, 'additions'),
+ onToggle: (startLine, counterpartStartLine) =>
+ this.handleReadOnlyFoldToggle(
+ 'additions',
+ startLine,
+ counterpartStartLine
+ ),
+ });
+ protected deletionFoldingManager: FoldingManager = new FoldingManager({
+ side: 'deletions',
+ onChange: (ranges) => this.__setFoldRanges(ranges, 'deletions'),
+ onToggle: (startLine, counterpartStartLine) =>
+ this.handleReadOnlyFoldToggle(
+ 'deletions',
+ startLine,
+ counterpartStartLine
+ ),
+ });
protected editor: DiffsEditor | undefined;
protected refreshViewTimeout: ReturnType | undefined;
@@ -506,6 +545,88 @@ export class FileDiff<
return this.interactionManager.getHoveredLine();
};
+ public __setFoldRanges(
+ ranges: LineRange[],
+ side: SelectionSide = 'additions'
+ ): void {
+ if (!this.updateFoldRanges(ranges, side)) {
+ return;
+ }
+ if (this.enabled && this.fileDiff != null) {
+ this.rerender();
+ }
+ }
+
+ private handleReadOnlyFoldToggle(
+ side: SelectionSide,
+ startLine: number,
+ counterpartStartLine: number | undefined
+ ): boolean {
+ const manager =
+ side === 'additions'
+ ? this.additionFoldingManager
+ : this.deletionFoldingManager;
+ const counterpartManager =
+ side === 'additions'
+ ? this.deletionFoldingManager
+ : this.additionFoldingManager;
+ const nextFolded = !(
+ manager.isFolded(startLine) ||
+ (counterpartStartLine !== undefined &&
+ counterpartManager.isFolded(counterpartStartLine))
+ );
+
+ manager.setFolded(startLine, nextFolded, false);
+ if (counterpartStartLine !== undefined) {
+ counterpartManager.setFolded(counterpartStartLine, nextFolded, false);
+ }
+
+ const changed = this.updateFoldRanges(manager.getHiddenLineRanges(), side);
+ const counterpartChanged = this.updateFoldRanges(
+ counterpartManager.getHiddenLineRanges(),
+ side === 'additions' ? 'deletions' : 'additions'
+ );
+ if (
+ (changed || counterpartChanged) &&
+ this.enabled &&
+ this.fileDiff != null
+ ) {
+ this.rerender();
+ }
+ return true;
+ }
+
+ protected updateFoldRanges(
+ ranges: LineRange[],
+ side: SelectionSide
+ ): boolean {
+ const previous =
+ side === 'additions' ? this.additionFoldRanges : this.deletionFoldRanges;
+ if (
+ ranges.length === previous.length &&
+ ranges.every((range, index) => {
+ const previousRange = previous[index];
+ return (
+ previousRange?.startLine === range.startLine &&
+ previousRange.endLine === range.endLine
+ );
+ })
+ ) {
+ return false;
+ }
+ const nextRanges = ranges.map((range) => ({ ...range }));
+ if (side === 'additions') {
+ this.additionFoldRanges = nextRanges;
+ } else {
+ this.deletionFoldRanges = nextRanges;
+ }
+ this.hunksRenderer.setFoldRanges(nextRanges, side);
+ this.handleFoldRangesChanged();
+ return true;
+ }
+
+ protected handleFoldRangesChanged(): void {}
+
public setLineAnnotations(
lineAnnotations: DiffLineAnnotation[]
): void {
@@ -521,6 +642,7 @@ export class FileDiff<
forceRender ||
annotationsChanged ||
didContentChange ||
+ this.hunksRenderer.hasFoldRanges() ||
typeof this.options.hunkSeparators === 'function'
) {
return false;
@@ -671,6 +793,10 @@ export class FileDiff<
this.deletionFile = undefined;
this.additionFile = undefined;
}
+ this.additionFoldRanges = [];
+ this.deletionFoldRanges = [];
+ this.additionFoldingManager.cleanUp();
+ this.deletionFoldingManager.cleanUp();
this.enabled = false;
@@ -833,6 +959,9 @@ export class FileDiff<
this.syncInteractionOptions();
this.hunksRenderer.hydrate(this.fileDiff);
+ if (this.fileDiff != null) {
+ this.syncReadOnlyFolding(this.fileDiff, true);
+ }
// FIXME(amadeus): not sure how to handle this yet...
// this.renderSeparators();
this.renderAnnotations();
@@ -840,6 +969,7 @@ export class FileDiff<
this.injectUnsafeCSS();
this.managersDirty = true;
this.flushManagers();
+ this.setupReadOnlyFolding();
}
public rerender(): void {
@@ -1055,6 +1185,7 @@ export class FileDiff<
if (this.fileDiff == null) {
return false;
}
+ this.syncReadOnlyFolding(this.fileDiff, diffDidChange || filesDidChange);
// Backstop for sessions that ended without their exit hook running (e.g.
// session-shaped metadata reused after a host teardown): restore
// recompute-shaped hunks before rendering.
@@ -1065,7 +1196,10 @@ export class FileDiff<
finishEditSessionForDiff(this.fileDiff, this.options.parseDiffOptions);
void this.hunksRenderer.refreshHighlightedResult();
}
- if (expandUnchanged) {
+ if (
+ expandUnchanged ||
+ (this.options.folding === true && !hasCompleteFoldingLines(this.fileDiff))
+ ) {
this.loadFilesIfNecessary();
}
this.hunksRenderer.setOptions(this.getHunksRendererOptions(this.options));
@@ -1192,6 +1326,8 @@ export class FileDiff<
if (this.editor != null) {
this.syncRenderViewToEditor();
+ } else {
+ this.setupReadOnlyFolding();
}
} catch (error: unknown) {
if (disableErrorHandling) {
@@ -1208,6 +1344,70 @@ export class FileDiff<
return true;
}
+ protected syncReadOnlyFolding(
+ fileDiff: FileDiffMetadata,
+ targetChanged: boolean
+ ): void {
+ if (this.editor != null) {
+ this.additionFoldingManager.cleanUp();
+ this.deletionFoldingManager.cleanUp();
+ return;
+ }
+ if (this.options.folding !== true || !hasCompleteFoldingLines(fileDiff)) {
+ this.additionFoldingManager.cleanUp();
+ this.deletionFoldingManager.cleanUp();
+ if (this.additionFoldRanges.length > 0) {
+ this.updateFoldRanges([], 'additions');
+ }
+ if (this.deletionFoldRanges.length > 0) {
+ this.updateFoldRanges([], 'deletions');
+ }
+ return;
+ }
+ if (targetChanged) {
+ if (this.additionFoldRanges.length > 0) {
+ this.updateFoldRanges([], 'additions');
+ }
+ if (this.deletionFoldRanges.length > 0) {
+ this.updateFoldRanges([], 'deletions');
+ }
+ this.additionFoldingManager.cleanUp();
+ this.deletionFoldingManager.cleanUp();
+ }
+ this.additionFoldingManager.setLines(
+ fileDiff.additionLines,
+ 2,
+ targetChanged
+ );
+ this.deletionFoldingManager.setLines(
+ fileDiff.deletionLines,
+ 2,
+ targetChanged
+ );
+ }
+
+ private setupReadOnlyFolding(): void {
+ const fileDiff = this.fileDiffCache;
+ if (
+ this.options.folding !== true ||
+ this.editor != null ||
+ fileDiff == null ||
+ !hasCompleteFoldingLines(fileDiff)
+ ) {
+ this.additionFoldingManager.setup(undefined);
+ this.deletionFoldingManager.setup(undefined);
+ return;
+ }
+ const diffStyle = this.options.diffStyle ?? 'split';
+ if (diffStyle === 'split') {
+ this.additionFoldingManager.setup(this.codeAdditions, { diffStyle });
+ this.deletionFoldingManager.setup(this.codeDeletions, { diffStyle });
+ } else {
+ this.additionFoldingManager.setup(this.codeUnified, { diffStyle });
+ this.deletionFoldingManager.setup(this.codeUnified, { diffStyle });
+ }
+ }
+
protected emitPostRender(unmount = false): void {
const {
fileContainer,
@@ -1333,6 +1533,14 @@ export class FileDiff<
);
}
this.editor?.cleanUp();
+ this.additionFoldingManager.cleanUp();
+ this.deletionFoldingManager.cleanUp();
+ const clearedAdditionFolds =
+ this.additionFoldRanges.length > 0 &&
+ this.updateFoldRanges([], 'additions');
+ const clearedDeletionFolds =
+ this.deletionFoldRanges.length > 0 &&
+ this.updateFoldRanges([], 'deletions');
this.editor = editor;
this.hunksRenderer.beginEditSession();
// The editor sync below refuses partial diffs (it needs the full file
@@ -1340,7 +1548,11 @@ export class FileDiff<
if (this.fileDiff?.isPartial === true) {
this.loadFilesIfNecessary();
}
- this.syncRenderViewToEditor();
+ if (clearedAdditionFolds || clearedDeletionFolds) {
+ this.rerender();
+ } else {
+ this.syncRenderViewToEditor();
+ }
return (recycle?: boolean) => {
this.editor = undefined;
// A recycle detach is a virtualized unmount mid-session: the session
@@ -1349,6 +1561,9 @@ export class FileDiff<
if (recycle !== true) {
this.finishEditSession();
}
+ if (this.options.folding === true && recycle !== true) {
+ this.rerender();
+ }
};
}
diff --git a/packages/diffs/src/components/UnresolvedFile.ts b/packages/diffs/src/components/UnresolvedFile.ts
index 30fba4741..2757b8cc3 100644
--- a/packages/diffs/src/components/UnresolvedFile.ts
+++ b/packages/diffs/src/components/UnresolvedFile.ts
@@ -46,7 +46,7 @@ export type MergeConflictActionsTypeOption =
export interface UnresolvedFileOptions extends Omit<
FileDiffOptions,
- 'diffStyle' | 'onPostRender'
+ 'diffStyle' | 'folding' | 'onPostRender'
> {
onPostRender?(
node: HTMLElement,
diff --git a/packages/diffs/src/components/VirtualizedFile.ts b/packages/diffs/src/components/VirtualizedFile.ts
index f820e301e..dc61c857b 100644
--- a/packages/diffs/src/components/VirtualizedFile.ts
+++ b/packages/diffs/src/components/VirtualizedFile.ts
@@ -64,6 +64,7 @@ function hasFileLayoutOptionChanged(
(nextOptions.disableLineNumbers ?? false) ||
(previousOptions.disableFileHeader ?? false) !==
(nextOptions.disableFileHeader ?? false) ||
+ (previousOptions.folding ?? false) !== (nextOptions.folding ?? false) ||
previousOptions.unsafeCSS !== nextOptions.unsafeCSS
);
}
@@ -200,14 +201,17 @@ export class VirtualizedFile<
return;
}
- this.editorFoldedLineIndex = new LineRangeIndex(this.foldRanges);
- this.forceRenderOverride = true;
- this.invalidateEditorFoldingLayout();
if (this.enabled && this.file != null) {
this.virtualizer.instanceChanged(this, true);
}
}
+ protected override handleFoldRangesChanged(): void {
+ this.editorFoldedLineIndex = new LineRangeIndex(this.foldRanges);
+ this.forceRenderOverride = true;
+ this.invalidateEditorFoldingLayout();
+ }
+
// Folding only changes which rows participate in layout. Preserve measured
// wrap/annotation heights and rebuild the derived total and checkpoints.
private invalidateEditorFoldingLayout(): void {
@@ -355,13 +359,15 @@ export class VirtualizedFile<
reset?: PendingCodeViewLayoutReset,
lineAnnotations?: LineAnnotation[]
): number {
- const annotationsChanged = this.syncLineAnnotations(lineAnnotations);
- const unkeyedContentsChanged =
+ const targetChanged =
+ this.file == null ||
+ !areFilesEqual(this.file, file) ||
this.fileRenderer.hasUnkeyedFileContentsChanged(file);
+ const annotationsChanged = this.syncLineAnnotations(lineAnnotations);
let shouldResetLayoutCache =
reset?.resetFileLayoutCache === true ||
annotationsChanged ||
- unkeyedContentsChanged;
+ targetChanged;
if (reset?.metrics != null) {
this.metrics = reset.metrics;
shouldResetLayoutCache = true;
@@ -377,10 +383,11 @@ export class VirtualizedFile<
this.resetLayoutCache();
}
- if (this.file !== file) {
+ if (targetChanged) {
this.layoutDirty = true;
}
this.file = file;
+ this.syncReadOnlyFolding(file, targetChanged);
this.top = top;
this.computeApproximateSize();
return this.height;
@@ -645,6 +652,9 @@ export class VirtualizedFile<
override cleanUp(recycle = false): void {
const recycledFoldedRanges = recycle ? this.foldRanges : [];
+ const recycledReadOnlyFoldRanges = recycle
+ ? this.foldingManager.getFoldRanges()
+ : [];
if (this.fileContainer != null && this.isSimpleMode()) {
this.getSimpleVirtualizer()?.disconnect(this.fileContainer);
}
@@ -659,6 +669,14 @@ export class VirtualizedFile<
} else {
this.editorFoldedLineIndex = new LineRangeIndex();
}
+ if (recycle && recycledReadOnlyFoldRanges.length > 0 && this.file != null) {
+ this.foldingManager.setLines(
+ this.fileRenderer.getOrCreateLineCache(this.file),
+ 2,
+ true
+ );
+ this.foldingManager.setFoldRanges(recycledReadOnlyFoldRanges);
+ }
}
// Compute the approximate size of the file using cached line heights.
@@ -845,6 +863,7 @@ export class VirtualizedFile<
}
this.file = file;
+ this.syncReadOnlyFolding(file, didFileChange);
fileContainer = this.getOrCreateFileContainerNode(fileContainer);
diff --git a/packages/diffs/src/components/VirtualizedFileDiff.ts b/packages/diffs/src/components/VirtualizedFileDiff.ts
index 05282ed9f..450b5828b 100644
--- a/packages/diffs/src/components/VirtualizedFileDiff.ts
+++ b/packages/diffs/src/components/VirtualizedFileDiff.ts
@@ -8,6 +8,7 @@ import type {
FileDiffMetadata,
Hunk,
HunkSeparators,
+ LineRange,
NumericScrollLineAnchor,
PendingCodeViewLayoutReset,
RenderRange,
@@ -64,6 +65,14 @@ interface DiffLayoutCheckpoint {
top: number;
}
+interface FoldedDiffLayout {
+ hunkOffsets: number[];
+ rawHunkStarts: number[];
+ rawLineCount: number;
+ totalHeight: number;
+ visibleLineCount: number;
+}
+
interface DiffLayoutCache {
// Sparse map: view-specific line index -> measured height delta from the
// baseline line height. Only stores lines that differ from the estimate.
@@ -81,6 +90,8 @@ interface DiffLayoutCache {
checkpoints: DiffLayoutCheckpoint[];
// Total renderable diff rows for the current diff style and expansion state.
totalLines: number;
+ // Fold-aware visible hunk boundaries mapped back to raw render rows.
+ foldedLayout: FoldedDiffLayout | undefined;
// Measured height for the file annotation row. Starts at 0 so
// unmeasured annotations behave like all other unmeasured annotations.
fileAnnotationHeight: number;
@@ -123,6 +134,7 @@ export class VirtualizedFileDiff<
estimatedUnifiedHeight: undefined,
checkpoints: [],
totalLines: 0,
+ foldedLayout: undefined,
fileAnnotationHeight: 0,
};
private isVisible: boolean = false;
@@ -168,6 +180,25 @@ export class VirtualizedFileDiff<
}
}
+ public override __setFoldRanges(
+ ranges: LineRange[],
+ side: SelectionSide = 'additions'
+ ): void {
+ if (!this.updateFoldRanges(ranges, side)) {
+ return;
+ }
+ this.virtualizer.instanceChanged(this, true);
+ }
+
+ protected override handleFoldRangesChanged(): void {
+ this.getSimpleVirtualizer()?.markDOMDirty();
+ this.invalidateDerivedLayoutCache(false);
+ if (this.isSimpleMode()) {
+ this.computeApproximateSize();
+ }
+ this.forceRenderOverride = true;
+ }
+
private syncLineAnnotations(
lineAnnotations: DiffLineAnnotation[] | undefined
): boolean {
@@ -307,6 +338,7 @@ export class VirtualizedFileDiff<
if (this.cache.totalLines !== 0) {
this.cache.totalLines = 0;
}
+ this.cache.foldedLayout = undefined;
if (includeEstimatedHeights) {
this.cache.estimatedSplitHeight = undefined;
this.cache.estimatedUnifiedHeight = undefined;
@@ -408,6 +440,7 @@ export class VirtualizedFileDiff<
}
if (hasHeightChange || this.isResizeDebuggingEnabled()) {
+ this.cache.foldedLayout = undefined;
this.computeApproximateSize(true);
}
return hasHeightChange;
@@ -481,6 +514,7 @@ export class VirtualizedFileDiff<
this.resetLayoutCache({ includeEstimatedHeights });
}
this.fileDiff = fileDiff;
+ this.syncReadOnlyFolding(fileDiff, targetChanged);
this.top = top;
this.computeApproximateSize();
return this.height;
@@ -531,6 +565,7 @@ export class VirtualizedFileDiff<
: this.hunksRenderer.getExpandedHunksMap(),
collapsedContextThreshold,
callback: ({
+ type,
hunkIndex,
hunk,
collapsedBefore,
@@ -572,18 +607,31 @@ export class VirtualizedFileDiff<
}
}
- const lineHeight = this.getLineHeight(
- lineIndex,
- (additionLine?.noEOFCR ?? false) || (deletionLine?.noEOFCR ?? false)
- );
- if (lineIndex === targetLineIndex) {
- position = {
- top,
- height: lineHeight,
- };
- return true;
+ const folded = this.hunksRenderer.isLineFolded({
+ type,
+ additionLine,
+ deletionLine,
+ });
+ if (folded) {
+ const targetLine = side === 'deletions' ? deletionLine : additionLine;
+ if (targetLine?.lineNumber === lineNumber) {
+ position = { top, height: 0 };
+ return true;
+ }
+ } else {
+ const lineHeight = this.getLineHeight(
+ lineIndex,
+ (additionLine?.noEOFCR ?? false) || (deletionLine?.noEOFCR ?? false)
+ );
+ if (lineIndex === targetLineIndex) {
+ position = {
+ top,
+ height: lineHeight,
+ };
+ return true;
+ }
+ top += lineHeight;
}
- top += lineHeight;
if (collapsedAfter > 0) {
const separator = getTrailingHunkSeparatorLayout({
@@ -638,11 +686,30 @@ export class VirtualizedFileDiff<
const hunkSeparators = this.getHunkSeparatorType();
this.approximateLayoutCheckpoints();
- const checkpoint = this.getLayoutCheckpointBeforeTop(localViewportTop);
+ const foldedLayout = this.hunksRenderer.hasFoldRanges()
+ ? this.getFoldedLayout(this.fileDiff)
+ : undefined;
+ const foldedHunk =
+ foldedLayout == null
+ ? undefined
+ : this.getFoldedHunkBeforeOffset(
+ foldedLayout,
+ localViewportTop -
+ getVirtualFileHeaderRegion(this.metrics, disableFileHeader) -
+ this.cache.fileAnnotationHeight
+ );
+ const checkpoint =
+ foldedHunk == null
+ ? this.getLayoutCheckpointBeforeTop(localViewportTop)
+ : undefined;
let top =
- checkpoint?.top ??
- getVirtualFileHeaderRegion(this.metrics, disableFileHeader) +
- this.cache.fileAnnotationHeight;
+ foldedHunk == null || foldedLayout == null
+ ? (checkpoint?.top ??
+ getVirtualFileHeaderRegion(this.metrics, disableFileHeader) +
+ this.cache.fileAnnotationHeight)
+ : getVirtualFileHeaderRegion(this.metrics, disableFileHeader) +
+ this.cache.fileAnnotationHeight +
+ (foldedLayout.hunkOffsets[foldedHunk] ?? 0);
let anchor: NumericScrollLineAnchor | undefined;
// This may end up being quite expensive on extremely large files, we may
@@ -651,12 +718,16 @@ export class VirtualizedFileDiff<
iterateOverDiff({
diff: this.fileDiff,
diffStyle,
- startingLine: checkpoint?.renderedLineIndex ?? 0,
+ startingLine:
+ foldedHunk == null || foldedLayout == null
+ ? (checkpoint?.renderedLineIndex ?? 0)
+ : (foldedLayout.rawHunkStarts[foldedHunk] ?? 0),
expandedHunks: expandUnchanged
? true
: this.hunksRenderer.getExpandedHunksMap(),
collapsedContextThreshold,
callback: ({
+ type,
hunkIndex,
hunk,
collapsedBefore,
@@ -687,30 +758,36 @@ export class VirtualizedFileDiff<
}
}
- if (top >= localViewportTop) {
- if (deletionLine != null) {
- anchor = {
- lineNumber: deletionLine.lineNumber,
- side: 'deletions',
- top,
- };
- } else if (additionLine != null) {
- anchor = {
- lineNumber: additionLine.lineNumber,
- side: 'additions',
- top,
- };
- }
- if (anchor != null) {
- return true;
+ const folded = this.hunksRenderer.isLineFolded({
+ type,
+ additionLine,
+ deletionLine,
+ });
+ if (!folded) {
+ if (top >= localViewportTop) {
+ if (deletionLine != null) {
+ anchor = {
+ lineNumber: deletionLine.lineNumber,
+ side: 'deletions',
+ top,
+ };
+ } else if (additionLine != null) {
+ anchor = {
+ lineNumber: additionLine.lineNumber,
+ side: 'additions',
+ top,
+ };
+ }
+ if (anchor != null) {
+ return true;
+ }
}
- }
- const lineHeight = this.getLineHeight(
- lineIndex,
- (additionLine?.noEOFCR ?? false) || (deletionLine?.noEOFCR ?? false)
- );
- top += lineHeight;
+ top += this.getLineHeight(
+ lineIndex,
+ (additionLine?.noEOFCR ?? false) || (deletionLine?.noEOFCR ?? false)
+ );
+ }
if (collapsedAfter > 0) {
const separator = getTrailingHunkSeparatorLayout({
@@ -780,6 +857,14 @@ export class VirtualizedFileDiff<
}
override cleanUp(recycle = false): void {
+ const recycledAdditionRanges = recycle ? this.additionFoldRanges : [];
+ const recycledDeletionRanges = recycle ? this.deletionFoldRanges : [];
+ const recycledAdditionFolds = recycle
+ ? this.additionFoldingManager.getFoldRanges()
+ : [];
+ const recycledDeletionFolds = recycle
+ ? this.deletionFoldingManager.getFoldRanges()
+ : [];
if (this.fileContainer != null && this.isSimpleMode()) {
this.getSimpleVirtualizer()?.disconnect(this.fileContainer);
}
@@ -790,6 +875,26 @@ export class VirtualizedFileDiff<
}
this.isSetup = false;
super.cleanUp(recycle);
+ if (recycle) {
+ this.additionFoldRanges = recycledAdditionRanges;
+ this.deletionFoldRanges = recycledDeletionRanges;
+ this.hunksRenderer.setFoldRanges(recycledAdditionRanges, 'additions');
+ this.hunksRenderer.setFoldRanges(recycledDeletionRanges, 'deletions');
+ if (this.fileDiff != null) {
+ this.additionFoldingManager.setLines(
+ this.fileDiff.additionLines,
+ 2,
+ true
+ );
+ this.deletionFoldingManager.setLines(
+ this.fileDiff.deletionLines,
+ 2,
+ true
+ );
+ this.additionFoldingManager.setFoldRanges(recycledAdditionFolds);
+ this.deletionFoldingManager.setFoldRanges(recycledDeletionFolds);
+ }
+ }
}
override expandHunk = (
@@ -1115,7 +1220,47 @@ export class VirtualizedFileDiff<
'VirtualizedFileDiff.getActiveEstimatedHeight: missing estimated height'
);
}
- return estimatedHeight;
+ if (!this.hunksRenderer.hasFoldRanges() || fileDiff == null) {
+ return estimatedHeight;
+ }
+ const diffStyle = this.getDiffStyle();
+ let foldedHeight = 0;
+ iterateOverDiff({
+ diff: fileDiff,
+ diffStyle,
+ expandedHunks:
+ this.options.expandUnchanged === true
+ ? true
+ : this.hunksRenderer.getExpandedHunksMap(),
+ collapsedContextThreshold:
+ this.options.collapsedContextThreshold ??
+ DEFAULT_COLLAPSED_CONTEXT_THRESHOLD,
+ callback: ({ type, hunk, additionLine, deletionLine }) => {
+ if (
+ this.hunksRenderer.isLineFolded({ type, additionLine, deletionLine })
+ ) {
+ const splitLineIndex =
+ additionLine?.splitLineIndex ?? deletionLine?.splitLineIndex;
+ const unifiedLineIndex =
+ additionLine?.unifiedLineIndex ?? deletionLine?.unifiedLineIndex;
+ if (splitLineIndex == null || unifiedLineIndex == null) {
+ return;
+ }
+ const isFinalSplitHunkRow =
+ diffStyle === 'split' &&
+ hunk != null &&
+ splitLineIndex === hunk.splitLineStart + hunk.splitLineCount - 1;
+ foldedHeight += this.getLineHeight(
+ diffStyle === 'split' ? splitLineIndex : unifiedLineIndex,
+ (additionLine?.noEOFCR ?? false) ||
+ (deletionLine?.noEOFCR ?? false) ||
+ (isFinalSplitHunkRow &&
+ (hunk.noEOFCRAdditions || hunk.noEOFCRDeletions))
+ );
+ }
+ },
+ });
+ return Math.max(0, estimatedHeight - foldedHeight);
}
private ensureEstimatedDiffHeights(
@@ -1231,6 +1376,7 @@ export class VirtualizedFileDiff<
);
return false;
}
+ this.syncReadOnlyFolding(nextFileDiff, targetChanged);
if (!isSetup) {
this.computeApproximateSize(false, nextFileDiff);
@@ -1348,7 +1494,8 @@ export class VirtualizedFileDiff<
(!this.layoutDirty && this.cache.checkpoints.length > 0) ||
fileDiff == null ||
fileDiff.hunks.length === 0 ||
- this.options.collapsed === true
+ this.options.collapsed === true ||
+ this.hunksRenderer.hasFoldRanges()
) {
return;
}
@@ -1680,6 +1827,147 @@ export class VirtualizedFileDiff<
);
}
+ private getFoldedLayout(fileDiff: FileDiffMetadata): FoldedDiffLayout {
+ if (this.cache.foldedLayout != null) {
+ return this.cache.foldedLayout;
+ }
+
+ const {
+ expandUnchanged = false,
+ collapsedContextThreshold = DEFAULT_COLLAPSED_CONTEXT_THRESHOLD,
+ } = this.options;
+ const { hunkLineCount } = this.metrics;
+ const diffStyle = this.getDiffStyle();
+ const hunkSeparators = this.getHunkSeparatorType();
+ const canHydratePartialDiff = canHydrateCollapsedContext(
+ fileDiff,
+ this.options.loadDiffFiles != null
+ );
+ const hunkOffsets: number[] = [];
+ const rawHunkStarts: number[] = [];
+ let rawLine = 0;
+ let top = 0;
+ let visibleLine = 0;
+
+ iterateOverDiff({
+ diff: fileDiff,
+ diffStyle,
+ expandedHunks: expandUnchanged
+ ? true
+ : this.hunksRenderer.getExpandedHunksMap(),
+ collapsedContextThreshold,
+ callback: ({
+ type,
+ hunkIndex,
+ hunk,
+ collapsedBefore,
+ collapsedAfter,
+ deletionLine,
+ additionLine,
+ }) => {
+ const visibleHunk = Math.floor(visibleLine / hunkLineCount);
+ if (
+ visibleLine % hunkLineCount === 0 &&
+ rawHunkStarts[visibleHunk] == null
+ ) {
+ rawHunkStarts[visibleHunk] = rawLine;
+ hunkOffsets[visibleHunk] = top;
+ }
+
+ if (collapsedBefore > 0) {
+ top +=
+ getLeadingHunkSeparatorLayout({
+ type: hunkSeparators,
+ metrics: this.metrics,
+ hunkIndex,
+ hunkSpecs: hunk?.hunkSpecs,
+ })?.totalHeight ?? 0;
+ }
+
+ const splitLineIndex =
+ additionLine?.splitLineIndex ?? deletionLine?.splitLineIndex;
+ const unifiedLineIndex =
+ additionLine?.unifiedLineIndex ?? deletionLine?.unifiedLineIndex;
+ if (splitLineIndex == null || unifiedLineIndex == null) {
+ throw new Error(
+ 'VirtualizedFileDiff.getFoldedLayout: missing line index data'
+ );
+ }
+ const isFinalSplitHunkRow =
+ diffStyle === 'split' &&
+ hunk != null &&
+ splitLineIndex === hunk.splitLineStart + hunk.splitLineCount - 1;
+ const isFinalHunkRow =
+ hunkIndex === fileDiff.hunks.length - 1 &&
+ hunk != null &&
+ (diffStyle === 'split'
+ ? isFinalSplitHunkRow
+ : unifiedLineIndex ===
+ hunk.unifiedLineStart + hunk.unifiedLineCount - 1);
+
+ if (
+ !this.hunksRenderer.isLineFolded({ type, additionLine, deletionLine })
+ ) {
+ top += this.getLineHeight(
+ diffStyle === 'split' ? splitLineIndex : unifiedLineIndex,
+ (additionLine?.noEOFCR ?? false) ||
+ (deletionLine?.noEOFCR ?? false) ||
+ (isFinalSplitHunkRow &&
+ hunk != null &&
+ (hunk.noEOFCRAdditions || hunk.noEOFCRDeletions))
+ );
+ visibleLine++;
+ }
+ rawLine++;
+
+ if (collapsedAfter > 0 || (isFinalHunkRow && canHydratePartialDiff)) {
+ top +=
+ getTrailingHunkSeparatorLayout({
+ type: hunkSeparators,
+ metrics: this.metrics,
+ })?.totalHeight ?? 0;
+ }
+ },
+ });
+
+ if (visibleLine % hunkLineCount === 0) {
+ const finalHunk = visibleLine / hunkLineCount;
+ rawHunkStarts[finalHunk] ??= rawLine;
+ hunkOffsets[finalHunk] ??= top;
+ }
+
+ return (this.cache.foldedLayout = {
+ hunkOffsets,
+ rawHunkStarts,
+ rawLineCount: rawLine,
+ totalHeight: top,
+ visibleLineCount: visibleLine,
+ });
+ }
+
+ private getFoldedHunkBeforeOffset(
+ layout: FoldedDiffLayout,
+ offset: number
+ ): number {
+ if (layout.visibleLineCount === 0) {
+ return 0;
+ }
+ let low = 0;
+ let high = layout.hunkOffsets.length;
+ while (low < high) {
+ const middle = low + ((high - low) >> 1);
+ if ((layout.hunkOffsets[middle] ?? 0) <= Math.max(0, offset)) {
+ low = middle + 1;
+ } else {
+ high = middle;
+ }
+ }
+ return Math.min(
+ Math.ceil(layout.visibleLineCount / this.metrics.hunkLineCount) - 1,
+ Math.max(0, low - 1)
+ );
+ }
+
private computeRenderRangeFromWindow(
fileDiff: FileDiffMetadata,
fileTop: number,
@@ -1750,6 +2038,60 @@ export class VirtualizedFileDiff<
Math.ceil(estimatedTargetLines / hunkLineCount) * hunkLineCount +
hunkLineCount;
const totalHunks = totalLines / hunkLineCount;
+ if (this.hunksRenderer.hasFoldRanges()) {
+ const foldedLayout = this.getFoldedLayout(fileDiff);
+ if (foldedLayout.visibleLineCount === 0) {
+ return {
+ startingLine: 0,
+ totalLines: 0,
+ bufferBefore: 0,
+ bufferAfter: codeHeight,
+ };
+ }
+
+ const visibleHunkCount = Math.ceil(
+ foldedLayout.visibleLineCount / hunkLineCount
+ );
+ const viewportCenter = (top + bottom) / 2;
+ const centerOffset = Math.max(
+ 0,
+ viewportCenter - fileTop - codeRegionTop
+ );
+ const centerHunk = this.getFoldedHunkBeforeOffset(
+ foldedLayout,
+ centerOffset
+ );
+ const idealStartHunk = centerHunk - Math.floor(totalHunks / 2);
+ const maxStartHunk = Math.max(0, visibleHunkCount - totalHunks);
+ const startHunk = Math.max(0, Math.min(idealStartHunk, maxStartHunk));
+ const startingVisibleLine = startHunk * hunkLineCount;
+ const clampedVisibleLines = Math.max(
+ 0,
+ Math.min(
+ idealStartHunk < 0
+ ? totalLines + idealStartHunk * hunkLineCount
+ : totalLines,
+ foldedLayout.visibleLineCount - startingVisibleLine
+ )
+ );
+ const endHunk = Math.ceil(
+ (startingVisibleLine + clampedVisibleLines) / hunkLineCount
+ );
+ const startingLine = foldedLayout.rawHunkStarts[startHunk] ?? 0;
+ const finalLine =
+ foldedLayout.rawHunkStarts[endHunk] ?? foldedLayout.rawLineCount;
+ const startOffset = foldedLayout.hunkOffsets[startHunk] ?? 0;
+ const endOffset =
+ foldedLayout.hunkOffsets[endHunk] ?? foldedLayout.totalHeight;
+
+ return {
+ startingLine,
+ totalLines: Math.max(0, finalLine - startingLine),
+ bufferBefore:
+ startingLine === 0 ? 0 : fileAnnotationHeight + startOffset,
+ bufferAfter: Math.max(0, codeHeight - endOffset),
+ };
+ }
const overflowHunks = totalHunks;
const hunkOffsets: number[] = [];
// Halfway between top & bottom, represented as an absolute position
@@ -1777,6 +2119,7 @@ export class VirtualizedFileDiff<
: this.hunksRenderer.getExpandedHunksMap(),
collapsedContextThreshold,
callback: ({
+ type,
hunkIndex,
hunk,
collapsedBefore,
@@ -1831,37 +2174,45 @@ export class VirtualizedFileDiff<
}
}
- const lineHeight = this.getLineHeight(
- diffStyle === 'split' ? splitLineIndex : unifiedLineIndex,
- hasMetadata
- );
+ const folded = this.hunksRenderer.isLineFolded({
+ type,
+ additionLine,
+ deletionLine,
+ });
+ if (!folded) {
+ const lineHeight = this.getLineHeight(
+ diffStyle === 'split' ? splitLineIndex : unifiedLineIndex,
+ hasMetadata
+ );
- // Track visible region
- if (absoluteLineTop > top - lineHeight && absoluteLineTop < bottom) {
- firstVisibleHunk ??= currentHunk;
- }
+ // Track visible region
+ if (absoluteLineTop > top - lineHeight && absoluteLineTop < bottom) {
+ firstVisibleHunk ??= currentHunk;
+ }
- // Track which hunk contains the viewport center
- // If viewport center is above this line and we haven't set centerHunk yet,
- // this is the first line at or past the center
- if (
- centerHunk == null &&
- absoluteLineTop + lineHeight > viewportCenter
- ) {
- centerHunk = currentHunk;
- }
+ // Track which hunk contains the viewport center
+ // If viewport center is above this line and we haven't set centerHunk yet,
+ // this is the first line at or past the center
+ if (
+ centerHunk == null &&
+ absoluteLineTop + lineHeight > viewportCenter
+ ) {
+ centerHunk = currentHunk;
+ }
- // Start overflow when we are out of the viewport at a hunk boundary
- if (
- overflowCounter == null &&
- absoluteLineTop >= bottom &&
- isAtHunkBoundary
- ) {
- overflowCounter = overflowHunks;
+ // Start overflow when we are out of the viewport at a hunk boundary
+ if (
+ overflowCounter == null &&
+ absoluteLineTop >= bottom &&
+ isAtHunkBoundary
+ ) {
+ overflowCounter = overflowHunks;
+ }
+
+ absoluteLineTop += lineHeight;
}
currentLine++;
- absoluteLineTop += lineHeight;
if (collapsedAfter > 0 || (isFinalHunkRow && canHydratePartialDiff)) {
const trailingSeparator = getTrailingHunkSeparatorLayout({
@@ -2091,6 +2442,7 @@ function hasDiffLayoutOptionChanged(
(nextOptions.disableLineNumbers ?? false) ||
(previousOptions.disableFileHeader ?? false) !==
(nextOptions.disableFileHeader ?? false) ||
+ (previousOptions.folding ?? false) !== (nextOptions.folding ?? false) ||
(previousOptions.diffIndicators ?? 'bars') !==
(nextOptions.diffIndicators ?? 'bars') ||
(previousOptions.hunkSeparators ?? 'line-info') !==
diff --git a/packages/diffs/src/editor/editor.css b/packages/diffs/src/editor/editor.css
index 44101e007..a65a8bf78 100644
--- a/packages/diffs/src/editor/editor.css
+++ b/packages/diffs/src/editor/editor.css
@@ -65,108 +65,6 @@
box-shadow: inset 0 0 0 1px var(--diffs-editor-line-highlight-border);
}
-[data-code][data-editor-folding] {
- --diffs-editor-fold-gap: 4px;
- --diffs-editor-fold-width: 16px;
-}
-
-[data-code][data-editor-folding] [data-column-number] {
- position: relative;
- padding-right: calc(
- var(--diffs-editor-fold-width) + var(--diffs-editor-fold-gap)
- );
-}
-
-[data-file][data-disable-line-numbers]
- [data-code][data-editor-folding]
- [data-column-number] {
- min-width: var(--diffs-editor-fold-width);
- padding-right: var(--diffs-editor-fold-width);
-}
-
-[data-fold] {
- position: absolute;
- top: 0;
- right: 0;
- width: var(--diffs-editor-fold-width);
- height: 1lh;
- display: flex;
- align-items: center;
- justify-content: center;
- pointer-events: none;
- z-index: 2;
-}
-
-[data-fold-toggle] {
- all: unset;
- box-sizing: border-box;
- width: var(--diffs-editor-fold-width);
- height: 1lh;
- display: flex;
- align-items: center;
- justify-content: center;
- color: var(--diffs-fg-number);
- cursor: pointer;
- opacity: 0;
- pointer-events: auto;
- transition: opacity 0.2s ease;
-}
-
-[data-fold-toggle][data-folded],
-[data-gutter]:hover [data-fold-toggle],
-[data-fold]:hover [data-fold-toggle],
-[data-fold-toggle]:focus-visible {
- opacity: 0.5;
-}
-
-[data-fold] [data-fold-toggle]:hover,
-[data-fold] [data-fold-toggle]:focus-visible {
- opacity: 0.75;
-}
-
-[data-fold] [data-fold-toggle]:focus-visible {
- outline: 1px solid currentColor;
- outline-offset: -2px;
-}
-
-[data-fold-indicator] {
- display: inline-flex;
- align-items: center;
- height: 1lh;
- margin-inline-start: 1ch;
- vertical-align: top;
- color: var(--diffs-fg);
- user-select: none;
-}
-
-[data-fold-ellipsis] {
- all: unset;
- box-sizing: border-box;
- width: 18px;
- height: calc(1lh - 4px);
- display: inline-flex;
- align-items: center;
- justify-content: center;
- border-radius: 4px;
- color: var(--diffs-fg-number);
- background-color: color-mix(in lab, var(--diffs-fg) 8%, transparent);
- cursor: pointer;
- transition:
- color 80ms ease,
- background-color 80ms ease;
-}
-
-[data-fold-ellipsis]:hover,
-[data-fold-ellipsis]:focus-visible {
- color: var(--diffs-fg);
- background-color: color-mix(in lab, var(--diffs-fg) 14%, transparent);
-}
-
-[data-fold-ellipsis]:focus-visible {
- outline: 1px solid currentColor;
- outline-offset: 1px;
-}
-
[data-editor-overlay] {
display: contents;
}
diff --git a/packages/diffs/src/editor/editor.ts b/packages/diffs/src/editor/editor.ts
index 6ffd1ef81..1c33ecc32 100644
--- a/packages/diffs/src/editor/editor.ts
+++ b/packages/diffs/src/editor/editor.ts
@@ -207,8 +207,6 @@ export interface EditorOptions {
roundedSelection?: boolean;
/** Highlight matching brackets near the caret, default is true. */
matchBrackets?: boolean;
- /** Show code-fold controls for attached file components, default is true. */
- folding?: boolean;
/**
* Controls auto-surround when typing quotes or brackets over a selection.
* Default is `"default"` (both quotes and brackets).
@@ -435,7 +433,6 @@ export class Editor implements DiffsEditor {
}
setOptions(options: EditorOptions): void {
- const wasFoldingRequested = this.#options.folding !== false;
const previousStorageOption =
this.#options.persistStateStorage ?? 'inMemory';
const nextOptions = {
@@ -466,9 +463,6 @@ export class Editor implements DiffsEditor {
this.#stateStorageOption = undefined;
this.#pendingStateWrites.clear();
}
- if (wasFoldingRequested !== (this.#options.folding !== false)) {
- this.#handleFoldingOptionChange();
- }
}
// Small typescript hack to prevent UnresolvedFile from being editable.
@@ -490,10 +484,7 @@ export class Editor implements DiffsEditor {
});
fileInstance.rerender();
}
- if (
- fileInstance.type !== 'file' ||
- fileInstance.__setFoldRanges === undefined
- ) {
+ if (fileInstance.__setFoldRanges === undefined) {
this.#resetFoldingState();
}
this.#fileInstance = fileInstance;
@@ -1100,6 +1091,13 @@ export class Editor implements DiffsEditor {
}
}
+ if (this.#syncedFoldingEnabled === true && !this.#isFoldingEnabled) {
+ if (this.#foldedStartLines.size > 0) {
+ this.#foldedStartLines.clear();
+ this.#foldStateVersion++;
+ }
+ this.#setHiddenLineRanges([]);
+ }
this.#refreshFoldingRanges();
this.#syncFoldedRangesToHost();
this.#renderFoldingControls();
@@ -1357,10 +1355,11 @@ export class Editor implements DiffsEditor {
}
get #isFoldingEnabled(): boolean {
+ const fileInstance = this.#fileInstance;
return (
- this.#options.folding !== false &&
- this.#fileInstance?.type === 'file' &&
- this.#fileInstance.__setFoldRanges !== undefined
+ fileInstance != null &&
+ fileInstance.options.folding !== false &&
+ fileInstance.__setFoldRanges !== undefined
);
}
@@ -1491,7 +1490,7 @@ export class Editor implements DiffsEditor {
#syncFoldedRangesToHost(): void {
const host = this.#fileInstance;
- if (host?.type !== 'file' || host.__setFoldRanges === undefined) {
+ if (host?.__setFoldRanges === undefined) {
return;
}
const foldingEnabled = this.#isFoldingEnabled;
@@ -1574,8 +1573,10 @@ export class Editor implements DiffsEditor {
if (!(child instanceof HTMLElement)) {
continue;
}
- const lineIndex = Number(child.dataset.lineIndex);
+ const lineIndex = Number(child.dataset.line) - 1;
const isFolded =
+ child.dataset.lineType !== 'change-deletion' &&
+ child.dataset.lineType !== 'context-expanded' &&
Number.isInteger(lineIndex) &&
this.#foldedStartLines.has(lineIndex) &&
this.#foldRangesByStart.has(lineIndex);
@@ -1596,11 +1597,13 @@ export class Editor implements DiffsEditor {
if (!(child instanceof HTMLElement)) {
continue;
}
- const lineIndex = Number(child.dataset.lineIndex);
+ const lineIndex = Number(child.dataset.columnNumber) - 1;
const existingZone = child.querySelector(
':scope > [data-fold]'
);
if (
+ child.dataset.lineType === 'change-deletion' ||
+ child.dataset.lineType === 'context-expanded' ||
child.dataset.columnNumber === undefined ||
!Number.isInteger(lineIndex)
) {
@@ -1970,33 +1973,6 @@ export class Editor implements DiffsEditor {
return false;
}
- #handleFoldingOptionChange(): void {
- if (this.#isFoldingEnabled) {
- this.#foldRangeVersion = -1;
- this.#refreshFoldingRanges(true);
- } else {
- if (this.#foldedStartLines.size > 0) {
- this.#foldStateVersion++;
- }
- this.#foldRanges = [];
- this.#foldRangesByStart.clear();
- this.#foldEndLines.clear();
- this.#foldClosingDelimiterLines.clear();
- this.#foldedStartLines.clear();
- this.#setHiddenLineRanges([]);
- this.#foldRangeDocument = undefined;
- this.#foldRangeVersion = -1;
- }
- this.#syncFoldedRangesToHost();
- this.#resetCache();
- this.#gutterWidthCache = undefined;
- this.#contentWidthCache = undefined;
- this.#renderFoldingControls();
- if (this.#selections !== undefined) {
- this.#updateSelections(this.#selections);
- }
- }
-
// Whether a zero-based document line has (or will have on scroll) a rendered
// row. File folds and collapsed diff regions share this visibility gate.
#isLineRenderable(line: number): boolean {
diff --git a/packages/diffs/src/managers/FoldingManager.ts b/packages/diffs/src/managers/FoldingManager.ts
new file mode 100644
index 000000000..1d22f7c9f
--- /dev/null
+++ b/packages/diffs/src/managers/FoldingManager.ts
@@ -0,0 +1,509 @@
+import {
+ computeIndentFoldingRanges,
+ mergeHiddenLineRanges,
+} from '../editor/folding';
+import type { LineRange } from '../types';
+
+export type FoldingSide = 'additions' | 'deletions';
+
+export interface FoldingManagerOptions {
+ side?: FoldingSide;
+ onChange(ranges: LineRange[]): void;
+ onToggle?(
+ startLine: number,
+ counterpartStartLine: number | undefined
+ ): boolean;
+}
+
+export interface FoldingManagerSetupOptions {
+ diffStyle?: 'unified' | 'split';
+}
+
+/**
+ * Owns indentation-fold state and controls for one file or diff side.
+ */
+export class FoldingManager {
+ private readonly side: FoldingSide | undefined;
+ private readonly sideKey: FoldingSide | 'file';
+ private readonly onChange: (ranges: LineRange[]) => void;
+ private readonly onToggle:
+ | ((startLine: number, counterpartStartLine: number | undefined) => boolean)
+ | undefined;
+ private lines: readonly string[] = [];
+ private tabSize = 2;
+ private codeElement: HTMLElement | undefined;
+ private unified = false;
+ private foldRanges: LineRange[] = [];
+ private foldRangesByStart = new Map();
+ private foldedStartLines = new Set();
+ private hiddenLineRanges: LineRange[] = [];
+ private pendingFocusLine: number | undefined;
+
+ constructor({ side, onChange, onToggle }: FoldingManagerOptions) {
+ this.side = side;
+ this.sideKey = side ?? 'file';
+ this.onChange = onChange;
+ this.onToggle = onToggle;
+ }
+
+ public setLines(lines: readonly string[], tabSize = 2, force = false): void {
+ if (!force && this.lines === lines && this.tabSize === tabSize) {
+ return;
+ }
+
+ this.lines = lines;
+ this.tabSize = tabSize;
+ this.foldRanges = computeIndentFoldingRanges(
+ {
+ lineCount: lines.length,
+ getLineText: (line) => lines[line] ?? '',
+ },
+ tabSize
+ );
+ this.foldRangesByStart = new Map(
+ this.foldRanges.map((range) => [range.startLine, range])
+ );
+
+ for (const startLine of this.foldedStartLines) {
+ if (!this.foldRangesByStart.has(startLine)) {
+ this.foldedStartLines.delete(startLine);
+ }
+ }
+
+ this.updateHiddenLineRanges();
+ this.renderControls();
+ }
+
+ public setup(
+ codeElement: HTMLElement | undefined,
+ { diffStyle }: FoldingManagerSetupOptions = {}
+ ): void {
+ this.unified = diffStyle === 'unified';
+ const eligible =
+ codeElement !== undefined &&
+ (this.side === undefined ||
+ (diffStyle === 'unified' &&
+ codeElement.dataset.unified !== undefined) ||
+ (diffStyle !== 'unified' &&
+ codeElement.dataset[this.side] !== undefined));
+ const nextCodeElement = eligible ? codeElement : undefined;
+
+ if (this.codeElement !== nextCodeElement) {
+ this.removeControls(this.codeElement);
+ }
+ if (!eligible) {
+ this.removeControls(codeElement);
+ }
+
+ this.codeElement = nextCodeElement;
+ if (nextCodeElement !== undefined) {
+ const tabSize = Math.trunc(
+ Number.parseFloat(
+ getComputedStyle(nextCodeElement).getPropertyValue('tab-size')
+ )
+ );
+ if (Number.isFinite(tabSize) && tabSize > 0 && tabSize !== this.tabSize) {
+ this.setLines(this.lines, tabSize, true);
+ return;
+ }
+ }
+ this.renderControls();
+ }
+
+ public getFoldRanges(): LineRange[] {
+ const ranges: LineRange[] = [];
+ for (const range of this.foldRanges) {
+ if (this.foldedStartLines.has(range.startLine)) {
+ ranges.push({ ...range });
+ }
+ }
+ return ranges;
+ }
+
+ public getHiddenLineRanges(): LineRange[] {
+ return this.hiddenLineRanges.map((range) => ({ ...range }));
+ }
+
+ public setFoldRanges(ranges: readonly LineRange[]): void {
+ const nextFoldedStartLines = new Set();
+ for (const range of ranges) {
+ const candidate = this.foldRangesByStart.get(range.startLine);
+ if (candidate?.endLine === range.endLine) {
+ nextFoldedStartLines.add(range.startLine);
+ }
+ }
+
+ let changed = nextFoldedStartLines.size !== this.foldedStartLines.size;
+ if (!changed) {
+ for (const line of nextFoldedStartLines) {
+ if (!this.foldedStartLines.has(line)) {
+ changed = true;
+ break;
+ }
+ }
+ }
+ if (!changed) {
+ return;
+ }
+
+ this.foldedStartLines = nextFoldedStartLines;
+ this.updateHiddenLineRanges();
+ this.renderControls();
+ }
+
+ public toggleFold(startLine: number): boolean {
+ if (!this.foldRangesByStart.has(startLine)) {
+ return false;
+ }
+
+ return this.setFolded(startLine, !this.foldedStartLines.has(startLine));
+ }
+
+ public setFolded(startLine: number, folded: boolean, notify = true): boolean {
+ if (
+ !this.foldRangesByStart.has(startLine) ||
+ this.foldedStartLines.has(startLine) === folded
+ ) {
+ return false;
+ }
+
+ if (folded) {
+ this.foldedStartLines.add(startLine);
+ } else {
+ this.foldedStartLines.delete(startLine);
+ }
+ this.updateHiddenLineRanges(notify);
+ this.renderControls();
+ return true;
+ }
+
+ public isFolded(startLine: number): boolean {
+ return this.foldedStartLines.has(startLine);
+ }
+
+ public unfoldLine(line: number): boolean {
+ let changed = false;
+ for (const startLine of this.foldedStartLines) {
+ const range = this.foldRangesByStart.get(startLine);
+ if (range !== undefined && line > startLine && line <= range.endLine) {
+ this.foldedStartLines.delete(startLine);
+ changed = true;
+ }
+ }
+ if (changed) {
+ this.updateHiddenLineRanges();
+ this.renderControls();
+ }
+ return changed;
+ }
+
+ public reset(): void {
+ if (this.foldedStartLines.size === 0) {
+ this.renderControls();
+ return;
+ }
+ this.foldedStartLines.clear();
+ this.updateHiddenLineRanges();
+ this.renderControls();
+ }
+
+ public cleanUp(): void {
+ this.removeControls(this.codeElement);
+ this.codeElement = undefined;
+ this.lines = [];
+ this.foldRanges = [];
+ this.foldRangesByStart.clear();
+ this.foldedStartLines.clear();
+ this.hiddenLineRanges = [];
+ this.pendingFocusLine = undefined;
+ this.unified = false;
+ }
+
+ private updateHiddenLineRanges(notify = true): void {
+ const ranges = mergeHiddenLineRanges(
+ this.foldRanges,
+ this.foldedStartLines
+ );
+ if (
+ ranges.length === this.hiddenLineRanges.length &&
+ ranges.every((range, index) => {
+ const previous = this.hiddenLineRanges[index];
+ return (
+ previous?.startLine === range.startLine &&
+ previous.endLine === range.endLine
+ );
+ })
+ ) {
+ return;
+ }
+
+ this.hiddenLineRanges = ranges;
+ if (notify) {
+ this.onChange(ranges.map((range) => ({ ...range })));
+ }
+ }
+
+ private getRowLineNumber(row: HTMLElement): number | undefined {
+ const value =
+ this.unified && this.side !== undefined
+ ? row.dataset[
+ this.side === 'additions' ? 'additionLine' : 'deletionLine'
+ ]
+ : (row.dataset.columnNumber ?? row.dataset.line);
+ const lineNumber = Number(value);
+ return Number.isInteger(lineNumber) && lineNumber > 0
+ ? lineNumber
+ : undefined;
+ }
+
+ private getCounterpartLineNumber(row: HTMLElement): number | undefined {
+ if (!this.unified || this.side === undefined) {
+ return undefined;
+ }
+ const lineNumber = Number(
+ row.dataset[this.side === 'additions' ? 'deletionLine' : 'additionLine']
+ );
+ return Number.isInteger(lineNumber) && lineNumber > 0
+ ? lineNumber
+ : undefined;
+ }
+
+ private isRowEligible(row: HTMLElement): boolean {
+ if (row.dataset.lineType === 'context-expanded') {
+ return false;
+ }
+ if (this.side === 'additions') {
+ return row.dataset.lineType !== 'change-deletion';
+ }
+ if (this.side === 'deletions') {
+ return row.dataset.lineType !== 'change-addition';
+ }
+ return true;
+ }
+
+ private initializeButton(button: HTMLButtonElement): void {
+ button.addEventListener('pointerdown', (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ });
+ button.addEventListener('keydown', (event) => {
+ if (event.key !== 'Escape') {
+ event.stopPropagation();
+ }
+ });
+ button.addEventListener('click', (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ const row = button.closest(
+ '[data-column-number], [data-line]'
+ );
+ if (row === null) {
+ return;
+ }
+ const lineNumber = this.getRowLineNumber(row);
+ if (lineNumber === undefined) return;
+ const counterpartLineNumber = this.getCounterpartLineNumber(row);
+ this.pendingFocusLine = event.detail === 0 ? lineNumber - 1 : undefined;
+ if (
+ this.onToggle?.(
+ lineNumber - 1,
+ counterpartLineNumber === undefined
+ ? undefined
+ : counterpartLineNumber - 1
+ ) !== true
+ ) {
+ this.toggleFold(lineNumber - 1);
+ }
+ this.restorePendingFocus();
+ });
+ }
+
+ private renderControls(): void {
+ const codeElement = this.codeElement;
+ if (codeElement === undefined) {
+ return;
+ }
+
+ codeElement.setAttribute('data-folding', '');
+ const contentElement = codeElement.querySelector(
+ ':scope > [data-content]'
+ );
+ const gutterElement = codeElement.querySelector(
+ ':scope > [data-gutter]'
+ );
+ if (contentElement === null || gutterElement === null) {
+ this.removeControls(codeElement);
+ return;
+ }
+
+ const foldedLineElements = new Map();
+ for (const child of contentElement.children) {
+ if (!(child instanceof HTMLElement) || !this.isRowEligible(child)) {
+ continue;
+ }
+ const lineNumber = this.getRowLineNumber(child);
+ if (
+ lineNumber !== undefined &&
+ this.foldedStartLines.has(lineNumber - 1)
+ ) {
+ foldedLineElements.set(lineNumber - 1, child);
+ }
+ }
+
+ for (const indicator of contentElement.querySelectorAll(
+ `:scope > [data-line] > [data-fold-indicator][data-fold-side="${this.sideKey}"]`
+ )) {
+ const lineNumber =
+ indicator.parentElement instanceof HTMLElement
+ ? this.getRowLineNumber(indicator.parentElement)
+ : undefined;
+ if (
+ lineNumber === undefined ||
+ foldedLineElements.get(lineNumber - 1) !== indicator.parentElement
+ ) {
+ indicator.remove();
+ }
+ }
+
+ for (const child of gutterElement.children) {
+ if (!(child instanceof HTMLElement)) {
+ continue;
+ }
+ const lineNumber = this.getRowLineNumber(child);
+ const existingZone = child.querySelector(
+ `:scope > [data-fold][data-fold-side="${this.sideKey}"]`
+ );
+ if (lineNumber === undefined || !this.isRowEligible(child)) {
+ existingZone?.remove();
+ continue;
+ }
+
+ const lineIndex = lineNumber - 1;
+ if (!this.foldRangesByStart.has(lineIndex)) {
+ existingZone?.remove();
+ continue;
+ }
+ if (
+ existingZone === null &&
+ child.querySelector(':scope > [data-fold]') !== null
+ ) {
+ continue;
+ }
+
+ const folded = this.foldedStartLines.has(lineIndex);
+ const zone = existingZone ?? document.createElement('span');
+ if (existingZone === null) {
+ zone.setAttribute('data-fold', '');
+ zone.dataset.foldSide = this.sideKey;
+ child.appendChild(zone);
+ }
+
+ let button = zone.querySelector(
+ ':scope > [data-fold-toggle]'
+ );
+ const previousFolded = button?.dataset.folded !== undefined;
+ if (button === null) {
+ button = document.createElement('button');
+ button.type = 'button';
+ button.setAttribute('data-fold-toggle', '');
+ button.dataset.foldSide = this.sideKey;
+ zone.appendChild(button);
+ this.initializeButton(button);
+ }
+
+ button.ariaExpanded = folded ? 'false' : 'true';
+ button.ariaLabel = `${folded ? 'Unfold' : 'Fold'} line ${lineNumber}`;
+ button.title = folded ? 'Unfold' : 'Fold';
+ button.toggleAttribute('data-folded', folded);
+ if (previousFolded !== folded || button.childElementCount === 0) {
+ button.innerHTML =
+ `` +
+ ` `;
+ }
+
+ const lineElement = foldedLineElements.get(lineIndex);
+ if (!folded || lineElement === undefined) {
+ continue;
+ }
+
+ let indicator = lineElement.querySelector(
+ `:scope > [data-fold-indicator][data-fold-side="${this.sideKey}"]`
+ );
+ if (indicator === null) {
+ indicator = document.createElement('span');
+ indicator.setAttribute('data-fold-indicator', '');
+ indicator.dataset.foldSide = this.sideKey;
+ indicator.contentEditable = 'false';
+ indicator.spellcheck = false;
+ lineElement.appendChild(indicator);
+ }
+
+ const text = this.lines[lineIndex] ?? '';
+ indicator.dataset.foldCharacter = (
+ text.length - (text.endsWith('\r\n') ? 2 : /[\r\n]$/.test(text) ? 1 : 0)
+ ).toString();
+
+ let ellipsis = indicator.querySelector(
+ ':scope > [data-fold-ellipsis]'
+ );
+ if (ellipsis === null) {
+ ellipsis = document.createElement('button');
+ ellipsis.type = 'button';
+ ellipsis.setAttribute('data-fold-ellipsis', '');
+ ellipsis.dataset.foldSide = this.sideKey;
+ ellipsis.innerHTML =
+ '' +
+ ' ';
+ indicator.appendChild(ellipsis);
+ this.initializeButton(ellipsis);
+ }
+ ellipsis.ariaLabel = `Unfold line ${lineNumber}`;
+ ellipsis.title = 'Unfold';
+ }
+
+ this.restorePendingFocus();
+ }
+
+ private restorePendingFocus(): void {
+ const line = this.pendingFocusLine;
+ const gutterElement = this.codeElement?.querySelector(
+ ':scope > [data-gutter]'
+ );
+ if (line === undefined || gutterElement == null) {
+ return;
+ }
+ for (const child of gutterElement.children) {
+ if (
+ child instanceof HTMLElement &&
+ this.getRowLineNumber(child) === line + 1 &&
+ this.isRowEligible(child)
+ ) {
+ const toggle = child.querySelector(
+ `:scope > [data-fold][data-fold-side="${this.sideKey}"] > [data-fold-toggle]`
+ );
+ if (toggle?.isConnected === true) {
+ toggle.focus({ preventScroll: true });
+ this.pendingFocusLine = undefined;
+ }
+ return;
+ }
+ }
+ }
+
+ private removeControls(codeElement: HTMLElement | undefined): void {
+ if (codeElement === undefined) {
+ return;
+ }
+ codeElement
+ .querySelectorAll(
+ `[data-fold][data-fold-side="${this.sideKey}"], ` +
+ `[data-fold-indicator][data-fold-side="${this.sideKey}"]`
+ )
+ .forEach((element) => element.remove());
+ if (
+ codeElement.querySelector('[data-fold], [data-fold-indicator]') === null
+ ) {
+ codeElement.removeAttribute('data-folding');
+ }
+ }
+}
diff --git a/packages/diffs/src/renderers/DiffHunksRenderer.ts b/packages/diffs/src/renderers/DiffHunksRenderer.ts
index 82dd77588..ae58bdf90 100644
--- a/packages/diffs/src/renderers/DiffHunksRenderer.ts
+++ b/packages/diffs/src/renderers/DiffHunksRenderer.ts
@@ -8,6 +8,7 @@ import {
DEFAULT_THEMES,
DEFAULT_TOKENIZE_MAX_LENGTH,
} from '../constants';
+import { LineRangeIndex } from '../editor/folding';
import { areLanguagesAttached } from '../highlighter/languages/areLanguagesAttached';
import {
getHighlighterIfLoaded,
@@ -31,6 +32,7 @@ import type {
HunkData,
HunkExpansionRegion,
HunkSeparators,
+ LineRange,
LineTypes,
RenderDiffOptions,
RenderDiffResult,
@@ -233,6 +235,10 @@ export class DiffHunksRenderer {
private deletionAnnotations: AnnotationLineMap = {};
private additionAnnotations: AnnotationLineMap = {};
+ private additionFoldRanges: LineRange[] = [];
+ private additionFoldRangeIndex = new LineRangeIndex();
+ private deletionFoldRanges: LineRange[] = [];
+ private deletionFoldRangeIndex = new LineRangeIndex();
private computedLang: SupportedLanguages = 'text';
private renderCache: RenderedDiffASTCache | undefined;
@@ -266,6 +272,10 @@ export class DiffHunksRenderer {
this.clearRenderCache();
this.additionAnnotations = {};
this.deletionAnnotations = {};
+ this.additionFoldRanges = [];
+ this.additionFoldRangeIndex = new LineRangeIndex();
+ this.deletionFoldRanges = [];
+ this.deletionFoldRangeIndex = new LineRangeIndex();
this.workerManager?.cleanUpTasks(this);
// Session hunks and the metadata dirty marker survive recycle in the
// shared FileDiffMetadata; the renderer-local state re-seeds on the next
@@ -273,6 +283,46 @@ export class DiffHunksRenderer {
this.endEditSession();
}
+ public setFoldRanges(
+ ranges: readonly LineRange[],
+ side: 'additions' | 'deletions' = 'additions'
+ ): void {
+ const nextRanges = ranges.map((range) => ({ ...range }));
+ if (side === 'additions') {
+ this.additionFoldRanges = nextRanges;
+ this.additionFoldRangeIndex = new LineRangeIndex(nextRanges);
+ } else {
+ this.deletionFoldRanges = nextRanges;
+ this.deletionFoldRangeIndex = new LineRangeIndex(nextRanges);
+ }
+ }
+
+ public hasFoldRanges(): boolean {
+ return (
+ this.additionFoldRanges.length > 0 || this.deletionFoldRanges.length > 0
+ );
+ }
+
+ public isLineFolded({
+ type,
+ additionLine,
+ deletionLine,
+ }: Pick<
+ RenderedLineContext,
+ 'type' | 'additionLine' | 'deletionLine'
+ >): boolean {
+ if (type === 'context-expanded') {
+ return false;
+ }
+ const additionFolded =
+ additionLine != null &&
+ this.additionFoldRangeIndex.isHidden(additionLine.lineIndex);
+ const deletionFolded =
+ deletionLine != null &&
+ this.deletionFoldRangeIndex.isHidden(deletionLine.lineIndex);
+ return additionFolded || deletionFolded;
+ }
+
/**
* Enter edit-session mode: hunk updates preserve the current region
* skeleton instead of recomputing hunks. Called on every editor attach,
@@ -1257,6 +1307,17 @@ export class DiffHunksRenderer {
additionLine != null
? additionLine.unifiedLineIndex
: deletionLine.unifiedLineIndex;
+ const isFinalSplitHunkRow =
+ diffStyle === 'split' &&
+ hunk != null &&
+ splitLineIndex === hunk.splitLineStart + hunk.splitLineCount - 1;
+ const isFinalHunkRow =
+ hunkIndex === fileDiff.hunks.length - 1 &&
+ hunk != null &&
+ (diffStyle === 'split'
+ ? isFinalSplitHunkRow
+ : unifiedLineIndex ===
+ hunk.unifiedLineStart + hunk.unifiedLineCount - 1);
if (diffStyle === 'split' && type !== 'change') {
pendingSplitContext.flush();
@@ -1274,6 +1335,29 @@ export class DiffHunksRenderer {
});
}
+ if (this.isLineFolded({ type, additionLine, deletionLine })) {
+ if (
+ hunkSeparators !== 'simple' &&
+ hunkSeparators !== 'metadata' &&
+ (collapsedAfter > 0 || (isFinalHunkRow && canHydrateContext))
+ ) {
+ pushSeparators({
+ hunkIndex:
+ type === 'context-expanded' ? hunkIndex : hunkIndex + 1,
+ collapsedLines:
+ isFinalHunkRow && canHydrateContext
+ ? 'unknown'
+ : collapsedAfter,
+ rangeSize: trailingRangeSize,
+ hunkSpecs: undefined,
+ isFirstHunk: false,
+ isLastHunk: true,
+ isExpandable: isExpandableDiff,
+ });
+ }
+ return;
+ }
+
const lineIndex =
diffStyle === 'unified' ? unifiedLineIndex : splitLineIndex;
const renderedLineContext: RenderedLineContext = {
@@ -1320,6 +1404,12 @@ export class DiffHunksRenderer {
additionLineIndex: additionLine?.lineIndex,
deletionLineIndex: deletionLine?.lineIndex,
});
+ const sideProperties = {
+ 'data-addition-line': additionLine?.lineNumber,
+ 'data-addition-line-index': additionLine?.lineIndex,
+ 'data-deletion-line': deletionLine?.lineNumber,
+ 'data-deletion-line-index': deletionLine?.lineIndex,
+ };
pushGutterLineNumber(
'unified',
lineDecoration.gutterLineType,
@@ -1327,29 +1417,35 @@ export class DiffHunksRenderer {
? additionLine.lineNumber
: deletionLine.lineNumber,
`${unifiedLineIndex},${splitLineIndex}`,
- lineDecoration.gutterProperties
+ { ...lineDecoration.gutterProperties, ...sideProperties }
);
if (additionLineContent != null) {
additionLineContent = withContentProperties(
additionLineContent,
lineDecoration.contentProperties,
- isRenderCacheDirty && additionLine != null
- ? {
- 'data-line': additionLine.lineNumber,
- 'data-line-index': `${unifiedLineIndex},${splitLineIndex}`,
- }
- : undefined
+ {
+ ...sideProperties,
+ ...(isRenderCacheDirty && additionLine != null
+ ? {
+ 'data-line': additionLine.lineNumber,
+ 'data-line-index': `${unifiedLineIndex},${splitLineIndex}`,
+ }
+ : undefined),
+ }
);
} else if (deletionLineContent != null) {
deletionLineContent = withContentProperties(
deletionLineContent,
lineDecoration.contentProperties,
- isRenderCacheDirty && deletionLine != null
- ? {
- 'data-line': deletionLine.lineNumber,
- 'data-line-index': `${unifiedLineIndex},${splitLineIndex}`,
- }
- : undefined
+ {
+ ...sideProperties,
+ ...(isRenderCacheDirty && deletionLine != null
+ ? {
+ 'data-line': deletionLine.lineNumber,
+ 'data-line-index': `${unifiedLineIndex},${splitLineIndex}`,
+ }
+ : undefined),
+ }
);
}
pushLineWithAnnotation({
@@ -1509,17 +1605,6 @@ export class DiffHunksRenderer {
}
}
- const isFinalSplitHunkRow =
- diffStyle === 'split' &&
- hunk != null &&
- splitLineIndex === hunk.splitLineStart + hunk.splitLineCount - 1;
- const isFinalHunkRow =
- hunkIndex === fileDiff.hunks.length - 1 &&
- hunk != null &&
- (diffStyle === 'split'
- ? splitLineIndex === hunk.splitLineStart + hunk.splitLineCount - 1
- : unifiedLineIndex ===
- hunk.unifiedLineStart + hunk.unifiedLineCount - 1);
const splitNoEOFCRDeletion = isFinalSplitHunkRow
? hunk.noEOFCRDeletions
: false;
diff --git a/packages/diffs/src/sprite.ts b/packages/diffs/src/sprite.ts
index da56b833f..51f6ac3c4 100644
--- a/packages/diffs/src/sprite.ts
+++ b/packages/diffs/src/sprite.ts
@@ -2,9 +2,12 @@ export type SVGSpriteNames =
| 'diffs-icon-arrow-right-short'
| 'diffs-icon-brand-github'
| 'diffs-icon-chevron'
+ | 'diffs-icon-chevron-down'
+ | 'diffs-icon-chevron-right'
| 'diffs-icon-chevrons-narrow'
| 'diffs-icon-diff-split'
| 'diffs-icon-diff-unified'
+ | 'diffs-icon-ellipsis'
| 'diffs-icon-expand'
| 'diffs-icon-expand-all'
| 'diffs-icon-file-code'
@@ -27,6 +30,12 @@ export const SVGSpriteSheet = `
+
+
+
+
+
+
@@ -36,6 +45,9 @@ export const SVGSpriteSheet = `
+
+
+
diff --git a/packages/diffs/src/style.css b/packages/diffs/src/style.css
index 170945f08..8c108c164 100644
--- a/packages/diffs/src/style.css
+++ b/packages/diffs/src/style.css
@@ -760,6 +760,108 @@
scrollbar-gutter: stable;
}
+ [data-code]:is([data-folding], [data-editor-folding]) {
+ --diffs-editor-fold-gap: 4px;
+ --diffs-editor-fold-width: 16px;
+
+ [data-column-number] {
+ position: relative;
+ padding-right: calc(
+ var(--diffs-editor-fold-width) + var(--diffs-editor-fold-gap)
+ );
+ }
+ }
+
+ [data-file][data-disable-line-numbers]
+ [data-code]:is([data-folding], [data-editor-folding])
+ [data-column-number] {
+ min-width: var(--diffs-editor-fold-width);
+ padding-right: var(--diffs-editor-fold-width);
+ }
+
+ [data-fold] {
+ position: absolute;
+ top: 0;
+ right: 0;
+ width: var(--diffs-editor-fold-width);
+ height: 1lh;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ pointer-events: none;
+ z-index: 2;
+ }
+
+ [data-fold-toggle] {
+ all: unset;
+ box-sizing: border-box;
+ width: var(--diffs-editor-fold-width);
+ height: 1lh;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--diffs-fg-number);
+ cursor: pointer;
+ opacity: 0;
+ pointer-events: auto;
+ transition: opacity 0.2s ease;
+ }
+
+ [data-fold-toggle][data-folded],
+ [data-gutter]:hover [data-fold-toggle],
+ [data-fold]:hover [data-fold-toggle],
+ [data-fold-toggle]:focus-visible {
+ opacity: 0.5;
+ }
+
+ [data-fold] [data-fold-toggle]:hover,
+ [data-fold] [data-fold-toggle]:focus-visible {
+ opacity: 0.75;
+ }
+
+ [data-fold] [data-fold-toggle]:focus-visible {
+ outline: 1px solid currentColor;
+ outline-offset: -2px;
+ }
+
+ [data-fold-indicator] {
+ display: inline-flex;
+ align-items: center;
+ height: 1lh;
+ margin-inline-start: 1ch;
+ vertical-align: top;
+ color: var(--diffs-fg);
+ user-select: none;
+ }
+
+ [data-fold-ellipsis] {
+ all: unset;
+ box-sizing: border-box;
+ width: 18px;
+ height: calc(1lh - 4px);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 4px;
+ color: var(--diffs-fg-number);
+ background-color: color-mix(in lab, var(--diffs-fg) 8%, transparent);
+ cursor: pointer;
+ transition:
+ color 80ms ease,
+ background-color 80ms ease;
+ }
+
+ [data-fold-ellipsis]:hover,
+ [data-fold-ellipsis]:focus-visible {
+ color: var(--diffs-fg);
+ background-color: color-mix(in lab, var(--diffs-fg) 14%, transparent);
+ }
+
+ [data-fold-ellipsis]:focus-visible {
+ outline: 1px solid currentColor;
+ outline-offset: 1px;
+ }
+
[data-diffs-scrollbar-measure] {
position: absolute;
top: -200px;
diff --git a/packages/diffs/src/types.ts b/packages/diffs/src/types.ts
index 08ce4ce13..c48257145 100644
--- a/packages/diffs/src/types.ts
+++ b/packages/diffs/src/types.ts
@@ -991,6 +991,7 @@ export interface DiffsComponentOptions extends BaseCodeOptions {
enableGutterUtility?: boolean;
enableLineSelection?: boolean;
expandUnchanged?: boolean;
+ folding?: boolean;
diffStyle?: 'unified' | 'split';
lineHoverHighlight?: 'disabled' | 'both' | 'number' | 'line';
}
@@ -1036,9 +1037,12 @@ export interface DiffsEditableComponent<
) => void;
/**
* @internal Hide inclusive zero-based document-line ranges while an editor
- * fold is active. FileDiff intentionally leaves this unimplemented.
+ * fold is active.
*/
- __setFoldRanges?: (ranges: LineRange[]) => void;
+ __setFoldRanges?: (
+ ranges: LineRange[],
+ side?: 'additions' | 'deletions'
+ ) => void;
/**
* Return the position and height of a one-based line relative to this component.
* The host uses it to scroll to virtualized lines before their DOM nodes exist.
diff --git a/packages/diffs/test/VirtualizedFile.folding.test.ts b/packages/diffs/test/VirtualizedFile.folding.test.ts
index 0ce88739f..7e8868dd7 100644
--- a/packages/diffs/test/VirtualizedFile.folding.test.ts
+++ b/packages/diffs/test/VirtualizedFile.folding.test.ts
@@ -31,6 +31,10 @@ interface InspectableVirtualizedFile {
editorFoldedLineIndex: {
isHidden(lineIndex: number): boolean;
};
+ foldingManager: {
+ getFoldRanges(): { startLine: number; endLine: number }[];
+ toggleFold(startLine: number): boolean;
+ };
fileRenderer: {
renderCache?: { result?: { code: unknown[] } };
renderFile(
@@ -87,6 +91,47 @@ function createVirtualizer(layoutChanges: boolean[]) {
}
describe('VirtualizedFile editor folding', () => {
+ test('preserves read-only folds across recycle but clears them for a new file', () => {
+ const contents = [
+ 'function outer() {',
+ ' const before = 1;',
+ ' if (before) {',
+ ' console.log(before);',
+ ' }',
+ ' return before;',
+ '}',
+ 'const after = true;',
+ ].join('\n');
+ const file = { name: 'first.ts', contents };
+ const instance = new VirtualizedFile(
+ { folding: true },
+ createVirtualizer([]),
+ metrics
+ );
+ instance.prepareCodeViewItem(file, 0);
+
+ expect(inspect(instance).foldingManager.toggleFold(0)).toBe(true);
+ expect(instance.getVirtualizedHeight()).toBe(64);
+
+ instance.cleanUp(true);
+ instance.virtualizedSetup();
+ instance.prepareCodeViewItem(file, 0);
+ expect(inspect(instance).foldingManager.getFoldRanges()).toEqual([
+ { startLine: 0, endLine: 5 },
+ ]);
+ expect(instance.getVirtualizedHeight()).toBe(64);
+
+ instance.prepareCodeViewItem(
+ {
+ name: 'second.ts',
+ contents: contents.replace('before = 1', 'after = 1'),
+ },
+ 0
+ );
+ expect(inspect(instance).foldingManager.getFoldRanges()).toEqual([]);
+ expect(instance.getVirtualizedHeight()).toBe(114);
+ });
+
test('removes hidden rows from geometry and invalidates layout on toggles', () => {
const layoutChanges: boolean[] = [];
const file = createFile(20);
diff --git a/packages/diffs/test/e2e/fixtures/folding.html b/packages/diffs/test/e2e/fixtures/folding.html
index 658aeed28..8cb8f91cb 100644
--- a/packages/diffs/test/e2e/fixtures/folding.html
+++ b/packages/diffs/test/e2e/fixtures/folding.html
@@ -3,16 +3,30 @@
- diffs editor-folding fixture
+ diffs read-only folding fixture
@@ -30,15 +44,37 @@
"
>← All fixtures
-
+
+
+ FileDiff
+
+
+ Toggle split/unified
+
+
+
+
+
diff --git a/packages/diffs/test/e2e/fixtures/index.html b/packages/diffs/test/e2e/fixtures/index.html
index 8eae18871..35507533b 100644
--- a/packages/diffs/test/e2e/fixtures/index.html
+++ b/packages/diffs/test/e2e/fixtures/index.html
@@ -119,9 +119,10 @@ @pierre/diffs — E2E fixtures
folding.html
- A foldable single-file Editor. Drives
- folding.pw.ts (gutter and icon hover states, fold/unfold
- interaction). Ready flag: window.__foldingReady.
+ Read-only File and FileDiff folding,
+ including split/unified layout switching. Drives
+ folding.pw.ts. Ready flag:
+ window.__foldingReady.
diff --git a/packages/diffs/test/e2e/folding.pw.ts b/packages/diffs/test/e2e/folding.pw.ts
index f1e4072e7..d6c3de4aa 100644
--- a/packages/diffs/test/e2e/folding.pw.ts
+++ b/packages/diffs/test/e2e/folding.pw.ts
@@ -1,40 +1,41 @@
import { expect, type Page, test } from '@playwright/test';
-const GUTTER = '[data-code][data-editor-folding] [data-gutter]';
-const CONTENT_ROWS = '[data-content] > [data-line]';
-const OUTER_TOGGLE = '[data-column-number="1"] [data-fold-toggle]';
-const OUTER_INDICATOR =
- '[data-content] > [data-line="1"] > [data-fold-indicator]';
+const FILE = '[data-file-mount]';
+const FILE_DIFF = '[data-file-diff-mount]';
+const FILE_GUTTER = `${FILE} [data-code][data-folding] [data-gutter]`;
+const FILE_CONTENT_ROWS = `${FILE} [data-content] > [data-line]`;
+const OUTER_TOGGLE = `${FILE} [data-column-number="1"] [data-fold-toggle]`;
+const OUTER_INDICATOR = `${FILE} [data-content] > [data-line="1"] > [data-fold-indicator]`;
async function openFixture(page: Page): Promise {
await page.goto('/test/e2e/fixtures/folding.html');
await page.waitForFunction(() => window.__foldingReady === true);
}
-const renderedLineNumbers = (page: Page): Promise =>
+const renderedLineNumbers = (page: Page, selector: string): Promise =>
page
- .locator(CONTENT_ROWS)
+ .locator(selector)
.evaluateAll((rows) =>
rows.map((row) => Number((row as HTMLElement).dataset.line))
);
const firstTokenColor = (page: Page): Promise =>
page
- .locator('[data-content] > [data-line="1"] [data-char]')
+ .locator(`${FILE} [data-content] > [data-line="1"] [data-char]`)
.first()
.evaluate((element) => getComputedStyle(element).color);
-test.describe('editor folding controls', () => {
- test('reveal on hover and fold or unfold the outer block', async ({
+test.describe('read-only folding controls', () => {
+ test('File reveals controls and folds or unfolds the outer block', async ({
page,
}) => {
await openFixture(page);
- const gutter = page.locator(GUTTER);
+ const gutter = page.locator(FILE_GUTTER);
let toggle = page.locator(OUTER_TOGGLE);
await expect(toggle.locator('use')).toHaveAttribute(
'href',
- '#diffs-editor-icon-chevron-down'
+ '#diffs-icon-chevron-down'
);
await expect(toggle).toHaveCSS('opacity', '0');
@@ -45,14 +46,20 @@ test.describe('editor folding controls', () => {
await expect(toggle).toHaveCSS('opacity', '0.75');
await toggle.click();
- await expect.poll(() => renderedLineNumbers(page)).toEqual([1, 7, 8]);
+ await expect
+ .poll(() => renderedLineNumbers(page, FILE_CONTENT_ROWS))
+ .toEqual([1, 7, 8]);
- await page.mouse.move(1150, 780);
+ await page.locator('[data-fixtures-index]').hover();
toggle = page.locator(OUTER_TOGGLE);
+ await expect(toggle).not.toBeFocused();
+ expect(
+ await toggle.evaluate((element) => element.matches(':focus-visible'))
+ ).toBe(false);
await expect(toggle).toHaveAttribute('data-folded', '');
await expect(toggle.locator('use')).toHaveAttribute(
'href',
- '#diffs-editor-icon-chevron-right'
+ '#diffs-icon-chevron-right'
);
await expect(toggle).toHaveCSS('opacity', '0.5');
@@ -61,13 +68,13 @@ test.describe('editor folding controls', () => {
await expect(indicator).toBeVisible();
await expect(ellipsis.locator('use')).toHaveAttribute(
'href',
- '#diffs-editor-icon-ellipsis'
+ '#diffs-icon-ellipsis'
);
expect(await indicator.getAttribute('data-fold-end-text')).toBeNull();
await expect(indicator).toHaveText('');
- await expect(page.locator('[data-content] > [data-line="7"]')).toHaveText(
- '}'
- );
+ await expect(
+ page.locator(`${FILE} [data-content] > [data-line="7"]`)
+ ).toHaveText('}');
const ellipsisBox = await ellipsis.boundingBox();
const indicatorBox = await indicator.boundingBox();
@@ -84,17 +91,121 @@ test.describe('editor folding controls', () => {
await ellipsis.click();
await expect
- .poll(() => renderedLineNumbers(page))
+ .poll(() => renderedLineNumbers(page, FILE_CONTENT_ROWS))
.toEqual([1, 2, 3, 4, 5, 6, 7, 8]);
await expect(indicator).toHaveCount(0);
- await page.mouse.move(1150, 780);
+ await page.locator('[data-fixtures-index]').hover();
toggle = page.locator(OUTER_TOGGLE);
+ await expect(toggle).not.toBeFocused();
await expect(toggle).not.toHaveAttribute('data-folded', '');
await expect(toggle.locator('use')).toHaveAttribute(
'href',
- '#diffs-editor-icon-chevron-down'
+ '#diffs-icon-chevron-down'
);
await expect(toggle).toHaveCSS('opacity', '0');
});
+
+ test('shows the fold outline only for keyboard focus', async ({ page }) => {
+ await openFixture(page);
+
+ const toggle = page.locator(OUTER_TOGGLE);
+ await toggle.click();
+ await expect(toggle).not.toBeFocused();
+ expect(
+ await toggle.evaluate((element) => element.matches(':focus-visible'))
+ ).toBe(false);
+
+ await page.locator('[data-fixtures-index]').focus();
+ await page.keyboard.press('Tab');
+ await expect(toggle).toBeFocused();
+ expect(
+ await toggle.evaluate((element) => element.matches(':focus-visible'))
+ ).toBe(true);
+ await expect(toggle).toHaveCSS('outline-style', 'solid');
+
+ await page.keyboard.press('Enter');
+ await expect(toggle).toBeFocused();
+ expect(
+ await toggle.evaluate((element) => element.matches(':focus-visible'))
+ ).toBe(true);
+ });
+
+ test('FileDiff folds split and unified layouts', async ({ page }) => {
+ await openFixture(page);
+
+ const additions = `${FILE_DIFF} [data-code][data-additions]`;
+ const deletions = `${FILE_DIFF} [data-code][data-deletions]`;
+ const additionsRows = `${additions} [data-content] > [data-line]`;
+ const deletionsRows = `${deletions} [data-content] > [data-line]`;
+ const splitToggle = page.locator(
+ `${additions} [data-column-number="1"] ` +
+ '[data-fold-toggle][data-fold-side="additions"]'
+ );
+
+ await expect(page.locator('[data-current-diff-style]')).toHaveText('split');
+ await expect(page.locator(additions)).toHaveAttribute('data-folding', '');
+ await expect(page.locator(deletions)).toHaveAttribute('data-folding', '');
+ await expect
+ .poll(() => renderedLineNumbers(page, additionsRows))
+ .toEqual([1, 2, 3, 4, 5, 6, 7, 8]);
+ await expect
+ .poll(() => renderedLineNumbers(page, deletionsRows))
+ .toEqual([1, 2, 3, 4, 5, 6, 7, 8]);
+
+ await splitToggle.click();
+ await expect
+ .poll(() => renderedLineNumbers(page, additionsRows))
+ .toEqual([1, 7, 8]);
+ await expect
+ .poll(() => renderedLineNumbers(page, deletionsRows))
+ .toEqual([1, 7, 8]);
+
+ await page
+ .locator(
+ `${additions} [data-line="1"] ` +
+ '[data-fold-indicator][data-fold-side="additions"] ' +
+ '[data-fold-ellipsis]'
+ )
+ .click();
+ await expect
+ .poll(() => renderedLineNumbers(page, additionsRows))
+ .toEqual([1, 2, 3, 4, 5, 6, 7, 8]);
+ await expect
+ .poll(() => renderedLineNumbers(page, deletionsRows))
+ .toEqual([1, 2, 3, 4, 5, 6, 7, 8]);
+
+ await page.locator('[data-toggle-diff-style]').click();
+ await expect(page.locator('[data-current-diff-style]')).toHaveText(
+ 'unified'
+ );
+ await expect(page.locator(additions)).toHaveCount(0);
+ await expect(page.locator(deletions)).toHaveCount(0);
+
+ const unified = `${FILE_DIFF} [data-code][data-unified]`;
+ const unifiedRows = `${unified} [data-content] > [data-line]`;
+ const unifiedToggle = page.locator(
+ `${unified} [data-addition-line="1"] ` +
+ '[data-fold-toggle][data-fold-side="additions"]'
+ );
+ await expect(page.locator(unified)).toHaveAttribute('data-folding', '');
+ await expect
+ .poll(() => renderedLineNumbers(page, unifiedRows))
+ .toEqual([1, 2, 3, 4, 5, 6, 6, 7, 8]);
+
+ await unifiedToggle.click();
+ await expect
+ .poll(() => renderedLineNumbers(page, unifiedRows))
+ .toEqual([1, 7, 8]);
+ await expect(page.locator(`${unified} [data-fold-indicator]`)).toHaveCount(
+ 1
+ );
+
+ await page
+ .locator(`${unified} [data-fold-indicator] [data-fold-ellipsis]`)
+ .click();
+ await expect
+ .poll(() => renderedLineNumbers(page, unifiedRows))
+ .toEqual([1, 2, 3, 4, 5, 6, 6, 7, 8]);
+ });
});
diff --git a/packages/diffs/test/editorFolding.test.ts b/packages/diffs/test/editorFolding.test.ts
index 5cf65e2df..8e38a93df 100644
--- a/packages/diffs/test/editorFolding.test.ts
+++ b/packages/diffs/test/editorFolding.test.ts
@@ -1,9 +1,9 @@
import { afterAll, describe, expect, test } from 'bun:test';
-import { File } from '../src/components/File';
+import { File, type FileOptions } from '../src/components/File';
import { FileDiff } from '../src/components/FileDiff';
import { DEFAULT_THEMES } from '../src/constants';
-import { Editor, type EditorOptions } from '../src/editor/editor';
+import { Editor } from '../src/editor/editor';
import { disposeHighlighter } from '../src/highlighter/shared_highlighter';
import type { FileContents, LineRange } from '../src/types';
import { installDom, wait, waitFor } from './domHarness';
@@ -48,8 +48,9 @@ async function waitForEditableContent(container: HTMLElement): Promise {
}
async function createFileEditorFixture(
- editorOptions?: EditorOptions,
- contents = FOLDABLE_CONTENTS
+ fileOptions: Pick, 'folding'> = { folding: true },
+ contents = FOLDABLE_CONTENTS,
+ editing = true
): Promise {
const dom = installDom();
const container = document.createElement('div');
@@ -58,8 +59,9 @@ async function createFileEditorFixture(
const file = new File({
disableFileHeader: true,
theme: DEFAULT_THEMES,
+ ...fileOptions,
});
- const editor = new Editor(editorOptions);
+ const editor = new Editor();
const fileContents: FileContents = {
name: 'foldable.ts',
contents,
@@ -70,8 +72,21 @@ async function createFileEditorFixture(
fileContainer: container,
forceRender: true,
});
- editor.edit(file);
- await waitForEditableContent(container);
+ if (editing) {
+ editor.edit(file);
+ await waitForEditableContent(container);
+ } else {
+ await waitFor(
+ () => container.shadowRoot?.querySelector('[data-content]') != null,
+ { timeout: 3000 }
+ );
+ if (fileOptions.folding === true) {
+ await waitFor(
+ () => container.shadowRoot?.querySelector('[data-fold-toggle]') != null,
+ { timeout: 3000 }
+ );
+ }
+ }
return {
cleanup() {
@@ -101,6 +116,58 @@ function renderedLineNumbers(container: HTMLElement): number[] {
].map((line) => Number(line.dataset.line));
}
+function codeColumn(
+ container: HTMLElement,
+ side: 'additions' | 'deletions' | 'unified'
+): HTMLElement {
+ const code = shadowRoot(container).querySelector(
+ `[data-code][data-${side}]`
+ );
+ if (code == null) {
+ throw new Error(`no ${side} code column found`);
+ }
+ return code;
+}
+
+function renderedColumnLineNumbers(code: HTMLElement): number[] {
+ return [
+ ...code.querySelectorAll('[data-content] > [data-line]'),
+ ].map((line) => Number(line.dataset.line));
+}
+
+function columnFoldToggle(
+ code: HTMLElement,
+ oneIndexedLine: number,
+ side: 'additions' | 'deletions'
+): HTMLButtonElement {
+ const toggle = code.querySelector(
+ `[data-column-number="${oneIndexedLine}"] ` +
+ `[data-fold-toggle][data-fold-side="${side}"]`
+ );
+ if (!(toggle instanceof HTMLButtonElement)) {
+ throw new Error(`no ${side} fold toggle found for line ${oneIndexedLine}`);
+ }
+ return toggle;
+}
+
+function columnFoldEllipsis(
+ code: HTMLElement,
+ oneIndexedLine: number,
+ side: 'additions' | 'deletions'
+): HTMLButtonElement {
+ const ellipsis = code.querySelector(
+ `[data-content] > [data-line="${oneIndexedLine}"] ` +
+ `[data-fold-indicator][data-fold-side="${side}"] ` +
+ '[data-fold-ellipsis]'
+ );
+ if (!(ellipsis instanceof HTMLButtonElement)) {
+ throw new Error(
+ `no ${side} fold ellipsis found for line ${oneIndexedLine}`
+ );
+ }
+ return ellipsis;
+}
+
function gutterRow(
container: HTMLElement,
oneIndexedLine: number
@@ -180,25 +247,38 @@ async function waitForLines(
expect(renderedLineNumbers(container)).toEqual(expected);
}
+async function waitForColumnLines(
+ code: HTMLElement,
+ expected: number[]
+): Promise {
+ await waitFor(
+ () =>
+ JSON.stringify(renderedColumnLineNumbers(code)) ===
+ JSON.stringify(expected),
+ { timeout: 3000 }
+ );
+ expect(renderedColumnLineNumbers(code)).toEqual(expected);
+}
+
describe('editor folding on File', () => {
- test('is enabled by default and folds or unfolds a block from the gutter', async () => {
- const { cleanup, container } = await createFileEditorFixture();
+ test('folds or unfolds a read-only block from the gutter', async () => {
+ const { cleanup, container } = await createFileEditorFixture(
+ undefined,
+ FOLDABLE_CONTENTS,
+ false
+ );
try {
const shadow = shadowRoot(container);
const firstGutterRow = gutterRow(container, 1);
const foldZone = firstGutterRow.querySelector(':scope > [data-fold]');
const initialToggle = foldToggle(container, 1);
- expect(shadow.querySelector('[data-code][data-editor-folding]')).not.toBe(
- null
- );
+ expect(shadow.querySelector('[data-code][data-folding]')).not.toBe(null);
expect(foldZone?.parentElement).toBe(firstGutterRow);
expect(firstGutterRow.closest('[data-gutter]')).not.toBe(null);
expect(foldZone?.contains(initialToggle)).toBe(true);
expect(initialToggle.getAttribute('aria-expanded')).toBe('true');
- expect(foldIconHref(initialToggle)).toBe(
- '#diffs-editor-icon-chevron-down'
- );
+ expect(foldIconHref(initialToggle)).toBe('#diffs-icon-chevron-down');
initialToggle.focus();
initialToggle.click();
@@ -208,16 +288,14 @@ describe('editor folding on File', () => {
expect(shadow.activeElement).toBe(foldedToggle);
expect(foldedToggle.hasAttribute('data-folded')).toBe(true);
expect(foldedToggle.getAttribute('aria-expanded')).toBe('false');
- expect(foldIconHref(foldedToggle)).toBe(
- '#diffs-editor-icon-chevron-right'
- );
+ expect(foldIconHref(foldedToggle)).toBe('#diffs-icon-chevron-right');
const indicator = foldIndicator(container, 1);
const ellipsis = foldEllipsis(container, 1);
expect(indicator.parentElement?.dataset.line).toBe('1');
- expect(indicator.getAttribute('contenteditable')).toBe('false');
+ expect(indicator.contentEditable).toBe('false');
expect(ellipsis.ariaLabel).toBe('Unfold line 1');
- expect(foldIconHref(ellipsis)).toBe('#diffs-editor-icon-ellipsis');
+ expect(foldIconHref(ellipsis)).toBe('#diffs-icon-ellipsis');
expect(indicator.dataset.foldEndText).toBeUndefined();
expect(indicator.children.length).toBe(1);
expect(indicator.firstElementChild).toBe(ellipsis);
@@ -230,9 +308,7 @@ describe('editor folding on File', () => {
const unfoldedToggle = foldToggle(container, 1);
expect(shadow.activeElement).toBe(unfoldedToggle);
expect(unfoldedToggle.hasAttribute('data-folded')).toBe(false);
- expect(foldIconHref(unfoldedToggle)).toBe(
- '#diffs-editor-icon-chevron-down'
- );
+ expect(foldIconHref(unfoldedToggle)).toBe('#diffs-icon-chevron-down');
} finally {
cleanup();
}
@@ -256,24 +332,43 @@ describe('editor folding on File', () => {
});
test('responds to folding option changes at runtime', async () => {
- const { cleanup, container, editor } = await createFileEditorFixture({
- folding: false,
- });
+ const { cleanup, container, file } = await createFileEditorFixture(
+ { folding: false },
+ FOLDABLE_CONTENTS,
+ false
+ );
try {
- editor.setOptions({ folding: true });
- await waitFor(() => foldToggle(container, 1) != null);
+ file.setOptions({ ...file.options, folding: true });
+ file.rerender();
+ await waitFor(
+ () =>
+ shadowRoot(container).querySelector(
+ '[data-column-number="1"] [data-fold-toggle]'
+ ) != null
+ );
+ expect(renderedLineNumbers(container)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]);
+ expect(foldToggle(container, 1).hasAttribute('data-folded')).toBe(false);
foldToggle(container, 1).click();
await waitForLines(container, [1, 7, 8]);
- editor.setOptions({ folding: false });
+ file.setOptions({ ...file.options, folding: false });
+ file.rerender();
await waitForLines(container, [1, 2, 3, 4, 5, 6, 7, 8]);
expect(shadowRoot(container).querySelector('[data-fold-toggle]')).toBe(
null
);
- editor.setOptions({ folding: true });
- expect(foldToggle(container, 1)).toBeInstanceOf(HTMLButtonElement);
+ file.setOptions({ ...file.options, folding: true });
+ file.rerender();
+ await waitFor(
+ () =>
+ shadowRoot(container).querySelector(
+ '[data-column-number="1"] [data-fold-toggle]'
+ ) != null
+ );
+ expect(renderedLineNumbers(container)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]);
+ expect(foldToggle(container, 1).hasAttribute('data-folded')).toBe(false);
} finally {
cleanup();
}
@@ -597,54 +692,425 @@ describe('editor folding on File', () => {
});
});
-describe('editor folding on FileDiff', () => {
- test('does not render fold controls even when folding is enabled', async () => {
+describe('folding on FileDiff', () => {
+ for (const diffStyle of ['split', 'unified'] as const) {
+ test(`${diffStyle} folds and unfolds a read-only additions block`, async () => {
+ const dom = installDom();
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+ const fileDiff = new FileDiff({
+ disableFileHeader: true,
+ diffStyle,
+ folding: true,
+ parseDiffOptions: { context: 20 },
+ theme: DEFAULT_THEMES,
+ });
+ const oldFile: FileContents = {
+ name: 'foldable.ts',
+ contents: FOLDABLE_CONTENTS,
+ };
+ const newFile: FileContents = {
+ name: 'foldable.ts',
+ contents: FOLDABLE_CONTENTS.replace(
+ ' return before;',
+ ' return before + 1;'
+ ),
+ };
+
+ try {
+ fileDiff.render({
+ oldFile,
+ newFile,
+ fileContainer: container,
+ forceRender: true,
+ });
+
+ await waitFor(
+ () => {
+ const code = container.shadowRoot?.querySelector(
+ `[data-code][data-${diffStyle === 'split' ? 'additions' : 'unified'}]`
+ );
+ return (
+ code?.querySelector(
+ '[data-column-number="1"] ' +
+ '[data-fold-toggle][data-fold-side="additions"]'
+ ) != null
+ );
+ },
+ { timeout: 3000 }
+ );
+
+ const additions = codeColumn(
+ container,
+ diffStyle === 'split' ? 'additions' : 'unified'
+ );
+ expect(
+ additions.querySelector('[data-content]')
+ ?.contentEditable
+ ).not.toBe('true');
+
+ const initialLines =
+ diffStyle === 'split'
+ ? [1, 2, 3, 4, 5, 6, 7, 8]
+ : [1, 2, 3, 4, 5, 6, 6, 7, 8];
+ await waitForColumnLines(additions, initialLines);
+
+ columnFoldToggle(additions, 1, 'additions').click();
+ await waitForColumnLines(additions, [1, 7, 8]);
+
+ const deletions =
+ diffStyle === 'split'
+ ? codeColumn(container, 'deletions')
+ : undefined;
+ if (deletions !== undefined) {
+ await waitForColumnLines(deletions, [1, 7, 8]);
+ }
+
+ columnFoldEllipsis(additions, 1, 'additions').click();
+ await waitForColumnLines(additions, initialLines);
+ if (deletions !== undefined) {
+ await waitForColumnLines(deletions, [1, 2, 3, 4, 5, 6, 7, 8]);
+ columnFoldToggle(deletions, 1, 'deletions').click();
+ await waitForColumnLines(additions, [1, 7, 8]);
+ await waitForColumnLines(deletions, [1, 7, 8]);
+ columnFoldEllipsis(deletions, 1, 'deletions').click();
+ await waitForColumnLines(additions, initialLines);
+ await waitForColumnLines(deletions, initialLines);
+ }
+ } finally {
+ await wait(10);
+ fileDiff.cleanUp();
+ dom.cleanup();
+ }
+ });
+ }
+
+ test('unified folds a read-only deleted file', async () => {
const dom = installDom();
const container = document.createElement('div');
document.body.appendChild(container);
const fileDiff = new FileDiff({
disableFileHeader: true,
- diffStyle: 'split',
+ diffStyle: 'unified',
+ folding: true,
+ parseDiffOptions: { context: 20 },
theme: DEFAULT_THEMES,
});
- const editor = new Editor({ folding: true });
- const oldFile: FileContents = {
- name: 'foldable.ts',
- contents: FOLDABLE_CONTENTS,
- };
- const newFile: FileContents = {
- name: 'foldable.ts',
- contents: FOLDABLE_CONTENTS.replace(
- ' return before;',
- ' return before + 1;'
- ),
- };
try {
fileDiff.render({
- oldFile,
- newFile,
+ oldFile: { name: 'deleted.ts', contents: FOLDABLE_CONTENTS },
+ newFile: null,
fileContainer: container,
forceRender: true,
});
- editor.edit(fileDiff);
- await waitForEditableContent(container);
- const shadow = shadowRoot(container);
- expect(shadow.querySelector('[data-code][data-editor-folding]')).toBe(
- null
+ await waitFor(
+ () =>
+ container.shadowRoot?.querySelector(
+ '[data-code][data-unified] ' +
+ '[data-column-number="1"] ' +
+ '[data-fold-toggle][data-fold-side="deletions"]'
+ ) != null,
+ { timeout: 3000 }
);
- expect(shadow.querySelector('[data-fold]')).toBe(null);
- expect(shadow.querySelector('[data-fold-toggle]')).toBe(null);
+ const unified = codeColumn(container, 'unified');
+ await waitForColumnLines(unified, [1, 2, 3, 4, 5, 6, 7, 8]);
- editor.setOptions({ folding: false });
- editor.setOptions({ folding: true });
- expect(shadow.querySelector('[data-fold-toggle]')).toBe(null);
+ columnFoldToggle(unified, 1, 'deletions').click();
+ await waitForColumnLines(unified, [1, 7, 8]);
+ columnFoldEllipsis(unified, 1, 'deletions').click();
+ await waitForColumnLines(unified, [1, 2, 3, 4, 5, 6, 7, 8]);
} finally {
await wait(10);
- editor.cleanUp();
fileDiff.cleanUp();
dom.cleanup();
}
});
+
+ test('unified folds a deleted block in a modified file', async () => {
+ const dom = installDom();
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+ const fileDiff = new FileDiff({
+ disableFileHeader: true,
+ diffStyle: 'unified',
+ folding: true,
+ parseDiffOptions: { context: 20 },
+ theme: DEFAULT_THEMES,
+ });
+ const deletedBodySelector =
+ '[data-content] > [data-line="3"][data-line-type="change-deletion"]';
+
+ try {
+ fileDiff.render({
+ oldFile: {
+ name: 'modified.ts',
+ contents: [
+ 'function outer() {',
+ ' if (before) {',
+ ' console.log(before);',
+ ' }',
+ ' return before;',
+ '}',
+ ].join('\n'),
+ },
+ newFile: {
+ name: 'modified.ts',
+ contents: ['function outer() {', ' return before;', '}'].join('\n'),
+ },
+ fileContainer: container,
+ forceRender: true,
+ });
+
+ await waitFor(
+ () =>
+ container.shadowRoot?.querySelector(
+ '[data-column-number="2"] ' +
+ '[data-fold-toggle][data-fold-side="deletions"]'
+ ) != null,
+ { timeout: 3000 }
+ );
+ const unified = codeColumn(container, 'unified');
+ expect(unified.querySelector(deletedBodySelector)).not.toBe(null);
+
+ columnFoldToggle(unified, 2, 'deletions').click();
+ await waitFor(() => unified.querySelector(deletedBodySelector) == null);
+ columnFoldEllipsis(unified, 2, 'deletions').click();
+ await waitFor(() => unified.querySelector(deletedBodySelector) != null);
+ } finally {
+ await wait(10);
+ fileDiff.cleanUp();
+ dom.cleanup();
+ }
+ });
+
+ test('unified places a shifted deletion fold on its header', async () => {
+ const dom = installDom();
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+ const fileDiff = new FileDiff({
+ disableFileHeader: true,
+ diffStyle: 'unified',
+ folding: true,
+ parseDiffOptions: { context: 20 },
+ theme: DEFAULT_THEMES,
+ });
+
+ try {
+ fileDiff.render({
+ oldFile: {
+ name: 'shifted.ts',
+ contents: [
+ 'const removedA = true;',
+ 'const removedB = true;',
+ 'function outer() {',
+ ' oldOnly();',
+ '}',
+ 'const tail = true;',
+ ].join('\n'),
+ },
+ newFile: {
+ name: 'shifted.ts',
+ contents: [
+ 'const inserted = true;',
+ 'function outer() {',
+ '}',
+ 'const tail = true;',
+ ].join('\n'),
+ },
+ fileContainer: container,
+ forceRender: true,
+ });
+
+ const unified = codeColumn(container, 'unified');
+ await waitFor(
+ () =>
+ unified.querySelector(
+ '[data-fold-toggle][data-fold-side="deletions"]'
+ ) != null,
+ { timeout: 3000 }
+ );
+
+ const toggle = unified.querySelector(
+ '[data-fold-toggle][data-fold-side="deletions"]'
+ );
+ const gutter = unified.querySelector('[data-gutter]');
+ const content = unified.querySelector('[data-content]');
+ const header = [...(content?.children ?? [])].find((row) =>
+ row.textContent?.includes('function outer()')
+ );
+ const toggleRow = toggle?.closest('[data-column-number]');
+ if (
+ toggle == null ||
+ gutter == null ||
+ content == null ||
+ header == null ||
+ toggleRow == null
+ ) {
+ throw new Error('shifted deletion fold did not render');
+ }
+
+ expect(toggle.ariaLabel).toBe('Fold line 3');
+ expect([...gutter.children].indexOf(toggleRow)).toBe(
+ [...content.children].indexOf(header)
+ );
+
+ toggle.click();
+ await waitFor(
+ () =>
+ unified.querySelector(
+ '[data-content] > [data-line="4"]' +
+ '[data-line-type="change-deletion"]'
+ ) == null
+ );
+ expect(
+ header.querySelector(
+ '[data-fold-indicator][data-fold-side="deletions"]'
+ )
+ ).not.toBe(null);
+ } finally {
+ await wait(10);
+ fileDiff.cleanUp();
+ dom.cleanup();
+ }
+ });
+
+ test('unified folds both sides from a shared shifted header', async () => {
+ const dom = installDom();
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+ const fileDiff = new FileDiff({
+ disableFileHeader: true,
+ diffStyle: 'unified',
+ folding: true,
+ parseDiffOptions: { context: 20 },
+ theme: DEFAULT_THEMES,
+ });
+ const oldOnlySelector =
+ '[data-content] > [data-line="6"]' + '[data-line-type="change-deletion"]';
+ const newOnlySelector =
+ '[data-content] > [data-line="4"]' + '[data-line-type="change-addition"]';
+
+ try {
+ fileDiff.render({
+ oldFile: {
+ name: 'shared.ts',
+ contents: [
+ 'const removedA = true;',
+ 'const removedB = true;',
+ 'function outer() {',
+ ' const before = true;',
+ ' oldA();',
+ ' oldB();',
+ ' return before;',
+ '}',
+ 'const tail = true;',
+ ].join('\n'),
+ },
+ newFile: {
+ name: 'shared.ts',
+ contents: [
+ 'const inserted = true;',
+ 'function outer() {',
+ ' const before = true;',
+ ' newOnly();',
+ ' return before;',
+ '}',
+ 'const tail = true;',
+ ].join('\n'),
+ },
+ fileContainer: container,
+ forceRender: true,
+ });
+
+ const unified = codeColumn(container, 'unified');
+ await waitFor(
+ () =>
+ unified.querySelector(
+ '[data-column-number="2"] ' +
+ '[data-fold-toggle][data-fold-side="additions"]'
+ ) != null,
+ { timeout: 3000 }
+ );
+ expect(unified.querySelector(oldOnlySelector)).not.toBe(null);
+ expect(unified.querySelector(newOnlySelector)).not.toBe(null);
+
+ columnFoldToggle(unified, 2, 'additions').click();
+ await waitFor(
+ () =>
+ unified.querySelector(oldOnlySelector) == null &&
+ unified.querySelector(newOnlySelector) == null
+ );
+ expect(
+ unified.querySelectorAll('[data-content] [data-fold-indicator]')
+ ).toHaveLength(1);
+
+ columnFoldEllipsis(unified, 2, 'additions').click();
+ await waitFor(
+ () =>
+ unified.querySelector(oldOnlySelector) != null &&
+ unified.querySelector(newOnlySelector) != null
+ );
+ } finally {
+ await wait(10);
+ fileDiff.cleanUp();
+ dom.cleanup();
+ }
+ });
+
+ for (const diffStyle of ['split', 'unified'] as const) {
+ test(`${diffStyle} ignores folds inside expanded unchanged context`, async () => {
+ const dom = installDom();
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+ const fileDiff = new FileDiff({
+ disableFileHeader: true,
+ diffStyle,
+ expandUnchanged: true,
+ folding: true,
+ theme: DEFAULT_THEMES,
+ });
+ const unchangedTail = Array.from(
+ { length: 12 },
+ (_, index) => `const padding${index} = true;`
+ ).join('\n');
+ const oldContents = `${FOLDABLE_CONTENTS}\n${unchangedTail}\nconst tail = true;`;
+ const newContents = oldContents.replace(
+ 'const tail = true;',
+ 'const tail = false;'
+ );
+
+ try {
+ fileDiff.render({
+ oldFile: { name: 'expanded.ts', contents: oldContents },
+ newFile: { name: 'expanded.ts', contents: newContents },
+ fileContainer: container,
+ forceRender: true,
+ });
+
+ const additions = codeColumn(
+ container,
+ diffStyle === 'split' ? 'additions' : 'unified'
+ );
+ await waitFor(() => renderedColumnLineNumbers(additions).length >= 21, {
+ timeout: 3000,
+ });
+ const initialLines = renderedColumnLineNumbers(additions);
+
+ fileDiff.__setFoldRanges([{ startLine: 1, endLine: 5 }], 'additions');
+ await waitForColumnLines(additions, initialLines);
+ expect(
+ additions.querySelector(
+ '[data-column-number="1"] ' +
+ '[data-fold-toggle][data-fold-side="additions"]'
+ )
+ ).toBe(null);
+ } finally {
+ await wait(10);
+ fileDiff.cleanUp();
+ dom.cleanup();
+ }
+ });
+ }
});
diff --git a/packages/diffs/test/virtualizedFileDiffEstimatedHeights.test.ts b/packages/diffs/test/virtualizedFileDiffEstimatedHeights.test.ts
index a501dea13..301450bc3 100644
--- a/packages/diffs/test/virtualizedFileDiffEstimatedHeights.test.ts
+++ b/packages/diffs/test/virtualizedFileDiffEstimatedHeights.test.ts
@@ -42,6 +42,7 @@ const virtualizer = {
getOffsetInScrollContainer() {
return 0;
},
+ markDOMDirty() {},
instanceChanged() {},
isInstanceVisible() {
return true;
@@ -50,6 +51,10 @@ const virtualizer = {
} as never;
interface InspectableVirtualizedFileDiff {
+ additionFoldingManager: {
+ getFoldRanges(): { startLine: number; endLine: number }[];
+ toggleFold(startLine: number): boolean;
+ };
cache: {
heightDeltas: Map;
measuredHeightDeltaTotal: number;
@@ -283,6 +288,53 @@ function countIteratedRows(
}
describe('VirtualizedFileDiff estimated height cache', () => {
+ test('preserves read-only folds across recycle but clears them for a new diff', () => {
+ const oldFile = {
+ name: 'folded.ts',
+ contents: [
+ 'function outer() {',
+ ' const before = 1;',
+ ' return before;',
+ '}',
+ ].join('\n'),
+ };
+ const newFile = {
+ ...oldFile,
+ contents: oldFile.contents.replace('before = 1', 'before = 2'),
+ };
+ const fileDiff = parseDiffFromFile(oldFile, newFile, { context: 20 });
+ const instance = new VirtualizedFileDiff(
+ { diffStyle: 'unified', folding: true },
+ virtualizer,
+ metrics
+ );
+ instance.prepareCodeViewItem(fileDiff, 0);
+ const unfoldedHeight = instance.getVirtualizedHeight();
+
+ expect(inspect(instance).additionFoldingManager.toggleFold(0)).toBe(true);
+ const foldedHeight = instance.getVirtualizedHeight();
+ expect(foldedHeight).toBeLessThan(unfoldedHeight);
+
+ instance.cleanUp(true);
+ instance.virtualizedSetup();
+ instance.prepareCodeViewItem(fileDiff, 0);
+ expect(inspect(instance).additionFoldingManager.getFoldRanges()).toEqual([
+ { startLine: 0, endLine: 2 },
+ ]);
+ expect(instance.getVirtualizedHeight()).toBe(foldedHeight);
+
+ const nextDiff = parseDiffFromFile(
+ { ...oldFile, name: 'next.ts' },
+ { ...newFile, name: 'next.ts' },
+ { context: 20 }
+ );
+ instance.prepareCodeViewItem(nextDiff, 0);
+ expect(inspect(instance).additionFoldingManager.getFoldRanges()).toEqual(
+ []
+ );
+ expect(instance.getVirtualizedHeight()).toBe(unfoldedHeight);
+ });
+
test('computes split and unified estimates together on first prepare', () => {
const instance = new VirtualizedFileDiff({}, virtualizer, metrics);
@@ -891,6 +943,44 @@ describe('VirtualizedFileDiff estimated height cache', () => {
expect(inspect(instance).cache.checkpoints.length).toBeGreaterThan(1);
});
+ test('maps folded visible windows back to raw render rows', () => {
+ const instance = new VirtualizedFileDiff(
+ { diffStyle: 'unified' },
+ virtualizer,
+ metrics
+ );
+ const fileDiff = createHugeSingleBlockDiff(1_000);
+ instance.prepareCodeViewItem(fileDiff, 0);
+ instance.__setFoldRanges([{ startLine: 1, endLine: 998 }], 'additions');
+
+ const range = inspect(instance).computeRenderRangeFromWindow(fileDiff, 0, {
+ top: metrics.diffHeaderHeight,
+ bottom: metrics.diffHeaderHeight + 20,
+ });
+
+ expect(range.startingLine).toBe(0);
+ expect(range.startingLine + range.totalLines).toBeGreaterThanOrEqual(1_000);
+ });
+
+ test('removes folded split metadata from the estimated height', () => {
+ const instance = new VirtualizedFileDiff(
+ { diffStyle: 'split', parseDiffOptions: { context: 20 } },
+ virtualizer,
+ metrics
+ );
+ const fileDiff = parseDiffFromFile(
+ { name: 'no-eof.ts', contents: 'if (x)\n old' },
+ { name: 'no-eof.ts', contents: 'if (x)\n a\n b\n c\n' },
+ { context: 20 }
+ );
+ instance.prepareCodeViewItem(fileDiff, 0);
+ instance.__setFoldRanges([{ startLine: 1, endLine: 3 }], 'additions');
+
+ expect(instance.getVirtualizedHeight()).toBe(
+ metrics.diffHeaderHeight + metrics.lineHeight + metrics.spacing
+ );
+ });
+
test('ignores trailing fromEnd expansion in render range line totals', () => {
const { cleanup } = installDom();
const fileDiff = createTwoHunkDiff();