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 = ``;
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..075787c2e 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,14 @@ 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 {
this.recycle();
this.workerManager = undefined;
@@ -154,6 +164,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
@@ -484,7 +495,8 @@ export class FileRenderer {
file,
renderRange.startingLine,
renderRange.totalLines,
- lines
+ lines,
+ this.foldRanges
);
}
this.renderCache.renderRange = renderRange;
@@ -514,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,
};
}
@@ -535,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);
- });
+ );
}
}
@@ -559,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));
@@ -586,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 };
}
@@ -635,11 +667,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
@@ -761,7 +817,8 @@ export class FileRenderer {
file: FileContents,
result: ThemedFileResult,
options: RenderFileOptions,
- highlighted = true
+ highlighted = true,
+ renderRange?: RenderRange
): void {
if (this.renderCache == null) {
return;
@@ -776,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 47cb5d959..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 {
@@ -1033,6 +1034,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 +1218,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 +1235,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/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
new file mode 100644
index 000000000..0ce88739f
--- /dev/null
+++ b/packages/diffs/test/VirtualizedFile.folding.test.ts
@@ -0,0 +1,274 @@
+import { afterAll, describe, expect, test } from 'bun:test';
+
+import { VirtualizedFile } from '../src/components/VirtualizedFile';
+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,
+ hunkLineCount: 2,
+ lineHeight: 10,
+ diffHeaderHeight: 30,
+ spacing: 4,
+};
+
+interface InspectableVirtualizedFile {
+ cache: {
+ heights: Map;
+ checkpoints: unknown[];
+ fileAnnotationHeight: number;
+ };
+ 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(
+ file: FileContents,
+ fileTop: number,
+ window: RenderWindow
+ ): RenderRange;
+}
+
+afterAll(async () => {
+ await disposeHighlighter();
+});
+
+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 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', tokenizeMaxLength: 1 },
+ createVirtualizer([]),
+ metrics,
+ workerManager
+ );
+ 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).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,
+ totalLines: lineCount,
+ bufferBefore: 0,
+ bufferAfter: 0,
+ };
+ expect(instance.getNumericScrollAnchor(50)).toEqual({
+ lineNumber: 19_993,
+ 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();
+ });
+});
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.
+
+ folding.html
+
+ A foldable single-file Editor. Drives
+ folding.pw.ts (gutter and icon hover states, fold/unfold
+ interaction). Ready flag: window.__foldingReady.
+
+
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..f1e4072e7
--- /dev/null
+++ b/packages/diffs/test/e2e/folding.pw.ts
@@ -0,0 +1,100 @@
+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]';
+
+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 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.5');
+
+ await toggle.hover();
+ await expect(toggle).toHaveCSS('opacity', '0.75');
+
+ 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.5');
+
+ 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));