diff --git a/apps/demo/src/main.ts b/apps/demo/src/main.ts
index b2eb2f3fa..ff16ba162 100644
--- a/apps/demo/src/main.ts
+++ b/apps/demo/src/main.ts
@@ -1064,6 +1064,8 @@ if (renderFileButton != null) {
overflow: wrap ? 'wrap' : 'scroll',
theme: DEMO_THEME,
themeType: getThemeType(),
+ // Folding starts disabled in the demo; the header toggle enables it.
+ folding: false,
renderAnnotation,
...(RENDER_FILENAME_SUFFIX
? {
@@ -1098,6 +1100,22 @@ if (renderFileButton != null) {
}
}
);
+ // Folding lives on the shared code options, so this flip reaches
+ // whichever side currently owns folding: the read-only file or an
+ // attached editor.
+ const foldingToggle = createToggle(
+ 'Folding',
+ instance?.options.folding === true,
+ (checked) => {
+ instance?.setOptions({
+ ...instance.options,
+ folding: checked,
+ });
+ if (!VIRTUALIZE) {
+ void instance.rerender();
+ }
+ }
+ );
editShortcutCallback = (): boolean | void => {
if (!isEditing) {
editableToggle.querySelector('input')?.click();
@@ -1107,7 +1125,7 @@ if (renderFileButton != null) {
const div = document.createElement('div');
div.style.display = 'flex';
div.style.gap = '8px';
- div.append(collapsedToggle, editableToggle);
+ div.append(collapsedToggle, editableToggle, foldingToggle);
return div;
},
diff --git a/apps/docs/app/(diffs)/docs/Edit/constants.ts b/apps/docs/app/(diffs)/docs/Edit/constants.ts
index e71a8fd78..d2744033d 100644
--- a/apps/docs/app/(diffs)/docs/Edit/constants.ts
+++ b/apps/docs/app/(diffs)/docs/Edit/constants.ts
@@ -1223,14 +1223,16 @@ const file: FileContents | undefined = editor.getFile();
// Full document text, or '' when nothing is attached.
const text: string = editor.getText();
-// Snapshot selections and scroll positions for explicit restoration:
+// Snapshot selections, active folds, and scroll positions 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 };
// }
-// Restore selections and scroll positions after re-rendering.
+// Restore selections, folds, and scroll positions after re-rendering.
editor.setState(state);
// Replace all cursors and ranges programmatically. Positions are zero-based;
diff --git a/apps/docs/app/(diffs)/playground/PlaygroundClient.tsx b/apps/docs/app/(diffs)/playground/PlaygroundClient.tsx
index b1aaca774..1608d25f4 100644
--- a/apps/docs/app/(diffs)/playground/PlaygroundClient.tsx
+++ b/apps/docs/app/(diffs)/playground/PlaygroundClient.tsx
@@ -23,6 +23,7 @@ import {
IconCodeStyleBars,
IconCodeStyleBg,
IconCodeStyleInline,
+ IconCollapsedRow,
IconColorAuto,
IconColorDark,
IconColorLight,
@@ -131,6 +132,7 @@ export type SharedRenderOptions = Pick<
| 'disableBackground'
| 'disableLineNumbers'
| 'overflow'
+ | 'folding'
| 'themeType'
| 'theme'
> & {
@@ -169,6 +171,8 @@ interface PlaygroundControlsContentProps {
setDisableLineNumbers: (v: boolean) => void;
overflow: 'wrap' | 'scroll';
setOverflow: (v: 'wrap' | 'scroll') => void;
+ folding: boolean;
+ setFolding: (v: boolean) => void;
enableLineSelection: boolean;
setEnableLineSelection: (v: boolean) => void;
enableGutterUtility: boolean;
@@ -213,6 +217,8 @@ function PlaygroundControlsContent({
setDisableLineNumbers,
overflow,
setOverflow,
+ folding,
+ setFolding,
enableLineSelection,
setEnableLineSelection,
enableGutterUtility,
@@ -493,6 +499,18 @@ function PlaygroundControlsContent({
}
/>
+ {/* Folding only applies to file surfaces (the Virtualizer README and
+ CodeView file items); diffs don't fold, so hide it in Normal. */}
+ {viewMode !== 'normal' && (
+ }
+ label="Folding"
+ checked={folding}
+ onCheckedChange={setFolding}
+ title="Code folding on file surfaces (diffs don't fold)"
+ />
+ )}
+
}
label="Annotations"
@@ -686,6 +704,7 @@ export function PlaygroundClient({ prerenderedDiff }: PlaygroundClientProps) {
urlState.disableLineNumbers
);
const [overflow, setOverflow] = useState(urlState.overflow);
+ const [folding, setFolding] = useState(urlState.folding);
const [enableLineSelection, setEnableLineSelection] = useState(
urlState.enableLineSelection
);
@@ -795,6 +814,7 @@ export function PlaygroundClient({ prerenderedDiff }: PlaygroundClientProps) {
params.set('ln', disableLineNumbers ? '0' : '1');
if ((overflow === 'wrap') !== DEFAULTS.wrap)
params.set('wrap', overflow === 'wrap' ? '1' : '0');
+ if (folding !== DEFAULTS.folding) params.set('fold', folding ? '1' : '0');
if (interactionMode !== DEFAULTS.interactionMode)
params.set('lineMode', interactionMode);
if (enableLineSelection !== DEFAULTS.lineSelection)
@@ -833,6 +853,7 @@ export function PlaygroundClient({ prerenderedDiff }: PlaygroundClientProps) {
disableBackground,
disableLineNumbers,
overflow,
+ folding,
interactionMode,
enableLineSelection,
enableGutterUtility,
@@ -988,6 +1009,8 @@ export function PlaygroundClient({ prerenderedDiff }: PlaygroundClientProps) {
setDisableLineNumbers,
overflow,
setOverflow,
+ folding,
+ setFolding,
enableLineSelection,
setEnableLineSelection,
enableGutterUtility,
@@ -1026,6 +1049,7 @@ export function PlaygroundClient({ prerenderedDiff }: PlaygroundClientProps) {
disableBackground,
disableLineNumbers,
overflow,
+ folding,
themeType: effectiveColorMode,
theme: { dark: selectedDarkTheme, light: selectedLightTheme },
}),
@@ -1038,6 +1062,7 @@ export function PlaygroundClient({ prerenderedDiff }: PlaygroundClientProps) {
disableBackground,
disableLineNumbers,
overflow,
+ folding,
effectiveColorMode,
selectedDarkTheme,
selectedLightTheme,
diff --git a/apps/docs/app/(diffs)/playground/PlaygroundVirtualizerElementView.tsx b/apps/docs/app/(diffs)/playground/PlaygroundVirtualizerElementView.tsx
index e9ee1e28e..97690691e 100644
--- a/apps/docs/app/(diffs)/playground/PlaygroundVirtualizerElementView.tsx
+++ b/apps/docs/app/(diffs)/playground/PlaygroundVirtualizerElementView.tsx
@@ -21,7 +21,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { flushSync } from 'react-dom';
import type { PlaygroundAnnotationMetadata } from './constants';
-import { ITEM_UNSAFE_CSS, LONG_README_FILE } from './constants';
+import { ITEM_UNSAFE_CSS, LONG_CODE_FILE } from './constants';
import type { SharedRenderOptions } from './PlaygroundClient';
import { CommentForm, CommentThread } from './PlaygroundComments';
@@ -40,7 +40,7 @@ interface PlaygroundVirtualizerElementViewProps {
// fixed-height scroll region, in contrast to the window-scroll variant that
// drives the vanilla Virtualizer against `document`. Any React
// nested under auto-virtualizes through context; no imperative
-// wiring is needed. The long README plain file leads the list (as in
+// wiring is needed. The long foldable plain file leads the list (as in
// CodeView), rendered through , which virtualizes the same way.
export function PlaygroundVirtualizerElementView({
diffs,
@@ -75,7 +75,7 @@ const FILE_EDITOR_OPTIONS: EditorOptions = {
},
};
-// The long README plain-file surface leading the list. Carries the same
+// The long foldable plain-file surface leading the list. Carries the same
// header Edit toggle as the diffs (the app-level EditProvider creates its
// editor); no comment wiring, since the demo file has no annotations.
function ElementVirtualizerFile({ options }: { options: SharedRenderOptions }) {
@@ -113,7 +113,7 @@ function ElementVirtualizerFile({ options }: { options: SharedRenderOptions }) {
return (
[] = [
+ {
+ id: `file:${LONG_FIXTURE_NAME}`,
+ type: 'file',
+ file: LONG_CODE_FILE,
+ },
{
id: 'file:README.md',
type: 'file',
diff --git a/apps/docs/app/(diffs)/playground/searchParams.ts b/apps/docs/app/(diffs)/playground/searchParams.ts
index cb010d594..a3bd5f3f7 100644
--- a/apps/docs/app/(diffs)/playground/searchParams.ts
+++ b/apps/docs/app/(diffs)/playground/searchParams.ts
@@ -78,6 +78,7 @@ export const DEFAULTS = {
background: true,
lineNumbers: true,
wrap: true,
+ folding: true,
lineSelection: true,
gutterButton: true,
interactionMode: 'comment' as const,
@@ -99,6 +100,8 @@ export interface PlaygroundUrlState {
disableBackground: boolean;
disableLineNumbers: boolean;
overflow: 'wrap' | 'scroll';
+ // Code folding on file surfaces (diffs don't fold).
+ folding: boolean;
enableLineSelection: boolean;
enableGutterUtility: boolean;
showAnnotations: boolean;
@@ -176,6 +179,7 @@ export function parsePlaygroundSearchParams(
disableBackground: !pickBool(get('bg'), DEFAULTS.background),
disableLineNumbers: !pickBool(get('ln'), DEFAULTS.lineNumbers),
overflow: pickBool(get('wrap'), DEFAULTS.wrap) ? 'wrap' : 'scroll',
+ folding: pickBool(get('fold'), DEFAULTS.folding),
enableLineSelection,
enableGutterUtility,
showAnnotations: pickBool(get('annot'), DEFAULTS.annotations),
diff --git a/packages/diffs/src/components/CodeView.ts b/packages/diffs/src/components/CodeView.ts
index db35c8083..749a92b6c 100644
--- a/packages/diffs/src/components/CodeView.ts
+++ b/packages/diffs/src/components/CodeView.ts
@@ -272,6 +272,7 @@ export const CODE_VIEW_DIFF_OPTION_KEYS = [
'themeType',
'disableFileHeader',
'disableVirtualizationBuffers',
+ 'folding',
'preferredHighlighter',
'useCSSClasses',
'useTokenTransformer',
@@ -305,6 +306,7 @@ export const CODE_VIEW_FILE_OPTION_KEYS = [
'themeType',
'disableFileHeader',
'disableVirtualizationBuffers',
+ 'folding',
'preferredHighlighter',
'useCSSClasses',
'useTokenTransformer',
@@ -4118,6 +4120,8 @@ function hasItemLayoutOptionChanged(
(previousOptions.disableFileHeader ?? false) !==
(nextOptions.disableFileHeader ?? false) ||
previousOptions.unsafeCSS !== nextOptions.unsafeCSS ||
+ // Disabling folding unfolds items, changing their heights.
+ (previousOptions.folding ?? true) !== (nextOptions.folding ?? true) ||
(previousOptions.diffStyle ?? 'split') !==
(nextOptions.diffStyle ?? 'split') ||
(previousOptions.diffIndicators ?? 'bars') !==
diff --git a/packages/diffs/src/components/File.ts b/packages/diffs/src/components/File.ts
index 99fccac34..723088236 100644
--- a/packages/diffs/src/components/File.ts
+++ b/packages/diffs/src/components/File.ts
@@ -13,6 +13,7 @@ import {
THEME_CSS_ATTRIBUTE,
UNSAFE_CSS_ATTRIBUTE,
} from '../constants';
+import { FoldManager } from '../managers/FoldManager';
import {
type GetHoveredLineResult,
InteractionManager,
@@ -33,6 +34,7 @@ import type {
FileContents,
HighlightedToken,
LineAnnotation,
+ LineRange,
PostRenderPhase,
PrePropertiesConfig,
RenderFileMetadata,
@@ -178,6 +180,11 @@ export class File<
public file: FileContents | undefined;
protected renderRange: RenderRange | undefined;
protected enabled = true;
+ protected foldRanges: LineRange[] = [];
+ // Interactive read-only fold state; shared with the renderer, which
+ // decorates fold headers from it. An attached editor bypasses it and pushes
+ // its own hidden ranges through __setFoldRanges.
+ protected foldManager: FoldManager;
protected editor: DiffsEditor | undefined;
@@ -191,6 +198,12 @@ export class File<
this.handleHighlightRender,
this.workerManager
);
+ this.foldManager = new FoldManager({
+ isEnabled: () => this.isReadOnlyFoldingEnabled(),
+ onToggleFold: (startLine, restoreFocus) =>
+ this.toggleFold(startLine, restoreFocus),
+ });
+ this.fileRenderer.setFoldManager(this.foldManager);
this.resizeManager = new ResizeManager();
this.interactionManager = new InteractionManager(
'file',
@@ -216,6 +229,83 @@ export class File<
return this.file;
}
+ public __setFoldRanges(ranges: LineRange[]): void {
+ this.applyFoldRanges(ranges);
+ }
+
+ // Hide the given line ranges and re-render when they changed. The
+ // virtualized subclass overrides this to also invalidate row layout.
+ protected applyFoldRanges(ranges: LineRange[]): boolean {
+ if (!this.updateFoldRanges(ranges)) {
+ return false;
+ }
+ if (this.enabled && this.file != null) {
+ this.rerender();
+ }
+ return true;
+ }
+
+ // Whether this component drives its own folding: the folding option is on
+ // and no editor session owns the fold state.
+ protected isReadOnlyFoldingEnabled(): boolean {
+ return this.options.folding !== false && this.editor == null;
+ }
+
+ // Fold or unfold the block starting at the zero-based header line, hiding
+ // or revealing its body. Invoked by the FoldManager for fold-control clicks.
+ protected toggleFold(startLine: number, restoreFocus = false): void {
+ const { file } = this;
+ if (!this.isReadOnlyFoldingEnabled() || file == null) {
+ return;
+ }
+ const lines = this.fileRenderer.getOrCreateLineCache(file);
+ if (!this.foldManager.toggleFold(startLine, lines)) {
+ return;
+ }
+ if (!this.applyFoldRanges(this.foldManager.getHiddenLineRanges(lines))) {
+ // The hidden ranges can survive a toggle (e.g. a fold nested inside a
+ // still-folded block); re-render for the control state alone.
+ this.rerender();
+ }
+ if (restoreFocus) {
+ this.pre
+ ?.querySelector(
+ `[data-column-number][data-line-index="${startLine}"] [data-fold-toggle]`
+ )
+ ?.focus();
+ }
+ }
+
+ /**
+ * Unfold everything and drop the interactive fold state, re-rendering when
+ * anything was folded. Used when an editor attaches (the session owns
+ * folding) and when the folding option turns off.
+ */
+ protected resetReadOnlyFolding(): void {
+ const hadFolds = this.foldManager.reset();
+ if (!this.applyFoldRanges([]) && hadFolds) {
+ 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();
@@ -223,9 +313,16 @@ export class File<
public setOptions(options: FileOptions | undefined): void {
if (options == null) return;
+ const foldingDisabled =
+ options.folding === false && this.options.folding !== false;
this.options = options;
this.cachedHeaderHTML = undefined;
this.syncInteractionOptions();
+ if (foldingDisabled && this.editor == null) {
+ this.resetReadOnlyFolding();
+ }
+ // The editor reads shared code options (e.g. folding) from this host.
+ this.editor?.__hostOptionsChanged?.();
}
protected syncInteractionOptions(): void {
@@ -324,6 +421,7 @@ export class File<
const { overflow = 'scroll' } = this.options;
this.interactionManager.setup(this.pre);
+ this.foldManager.setup(this.pre);
this.resizeManager.setup(this.pre, {
disableAnnotations: overflow === 'wrap',
columnVariables: this.shouldApplyColumnVariables(overflow)
@@ -344,6 +442,11 @@ export class File<
this.editor = undefined;
this.resizeManager.cleanUp();
this.interactionManager.cleanUp();
+ this.foldManager.cleanUp();
+ // A recycle keeps the fold state so a virtualized remount restores it.
+ if (!recycle) {
+ this.foldManager.reset();
+ }
this.managersDirty = false;
this.workerManager?.unsubscribeToThemeChanges(this);
this.renderRange = undefined;
@@ -393,6 +496,8 @@ export class File<
this.workerManager = undefined;
this.file = undefined;
}
+ this.foldRanges = [];
+
this.enabled = false;
}
@@ -553,6 +658,10 @@ export class File<
public attachEditor(editor: DiffsEditor): () => void {
this.editor?.cleanUp();
+ // The attaching editor owns folding for the session; unfold the
+ // read-only state so the session starts from (and detaches back to) a
+ // clean view.
+ this.resetReadOnlyFolding();
this.editor = editor;
this.fileRenderer.beginEditSession();
const preparedFile =
@@ -664,6 +773,11 @@ export class File<
this.renderRange = nextRenderRange;
if (didFileChange) {
this.cachedHeaderHTML = undefined;
+ // Folded blocks don't carry over to different contents. State-only
+ // reset: the render below already reflects it.
+ if (this.foldManager.reset()) {
+ this.updateFoldRanges([]);
+ }
}
this.file = file;
this.fileRenderer.setOptions(getFileRendererOptions(this.options));
@@ -672,6 +786,16 @@ export class File<
this.setLineAnnotations(lineAnnotations);
}
this.fileRenderer.setLineAnnotations(this.lineAnnotations);
+ // Re-derive hidden ranges from the interactive fold state so every render
+ // hides exactly the folded bodies, including after a recycle dropped the
+ // ranges while the fold state survived.
+ if (this.isReadOnlyFoldingEnabled() && this.foldManager.hasFolds()) {
+ this.updateFoldRanges(
+ this.foldManager.getHiddenLineRanges(
+ this.fileRenderer.getOrCreateLineCache(file)
+ )
+ );
+ }
const { disableErrorHandling = false, disableFileHeader = false } =
this.options;
@@ -1113,6 +1237,9 @@ export class File<
this.cleanupErrorWrapper();
this.applyPreNodeAttributes(pre, result);
const code = (this.code = getOrCreateCodeNode({ code: this.code }));
+ // Reserves gutter space for fold toggles (see style.css). An attached
+ // editor re-applies the attribute when it renders its own controls.
+ code.toggleAttribute('data-folding', this.fileRenderer.showsFoldControls());
const codeAst = this.fileRenderer.renderCodeAST(result);
this.editor?.__captureFocusForDOMReplacement();
const applyColumns = () => {
diff --git a/packages/diffs/src/components/VirtualizedFile.ts b/packages/diffs/src/components/VirtualizedFile.ts
index 864c05bcb..300f3cb11 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 '../managers/FoldManager';
import type {
DiffsTextDocument,
FileContents,
LineAnnotation,
+ LineRange,
NumericScrollLineAnchor,
PendingCodeViewLayoutReset,
RenderRange,
@@ -84,6 +86,7 @@ export class VirtualizedFile<
private layoutDirty = true;
private forceRenderOverride: true | undefined;
private currentCollapsed: boolean | undefined;
+ private editorFoldedLineIndex = new LineRangeIndex();
constructor(
options: FileOptions | undefined,
@@ -140,6 +143,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;
@@ -185,6 +200,37 @@ export class VirtualizedFile<
super.setThemeType(themeType);
}
+ protected override updateFoldRanges(ranges: LineRange[]): boolean {
+ if (!super.updateFoldRanges(ranges)) {
+ return false;
+ }
+ this.editorFoldedLineIndex = new LineRangeIndex(this.foldRanges);
+ return true;
+ }
+
+ protected override applyFoldRanges(ranges: LineRange[]): boolean {
+ if (!this.updateFoldRanges(ranges)) {
+ return false;
+ }
+ this.forceRenderOverride = true;
+ this.invalidateEditorFoldingLayout();
+ if (this.enabled && this.file != null) {
+ this.virtualizer.instanceChanged(this, true);
+ }
+ return 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;
@@ -340,6 +386,16 @@ export class VirtualizedFile<
shouldResetLayoutCache = true;
}
+ // CodeView flips options globally without calling setOptions on items, so
+ // catch a disabled folding option here and unfold before layout runs.
+ if (this.options.folding === false && this.foldManager.hasFolds()) {
+ this.foldManager.reset();
+ if (this.updateFoldRanges([])) {
+ this.forceRenderOverride = true;
+ shouldResetLayoutCache = true;
+ }
+ }
+
if (shouldResetLayoutCache) {
this.resetLayoutCache();
}
@@ -377,6 +433,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,
@@ -386,12 +452,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 {
@@ -448,6 +524,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
@@ -474,18 +594,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;
@@ -538,6 +668,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);
}
@@ -546,6 +677,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.
@@ -592,11 +729,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,
+ });
}
}
@@ -806,11 +984,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.
@@ -849,7 +1040,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
@@ -872,7 +1064,7 @@ export class VirtualizedFile<
}
}
- if (hunkLineCount == null) {
+ if (hunkLineCount == null || this.foldRanges.length > 0) {
return resultIndex >= 0 ? this.cache.checkpoints[resultIndex] : undefined;
}
@@ -918,6 +1110,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,
@@ -994,21 +1190,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(
@@ -1018,7 +1254,7 @@ export class VirtualizedFile<
return {
startingLine,
- totalLines: clampedTotalLines,
+ totalLines: clampedVisibleLines,
bufferBefore,
bufferAfter,
};
@@ -1036,17 +1272,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);
@@ -1061,15 +1308,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;
}
@@ -1083,7 +1333,8 @@ export class VirtualizedFile<
}
currentLine++;
- absoluteLineTop += lineHeight;
+ absoluteLineTop += visibleLineHeight;
+ lineIndex++;
}
// No visible lines found
@@ -1109,19 +1360,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;
@@ -1134,7 +1414,7 @@ export class VirtualizedFile<
return {
startingLine,
- totalLines: clampedTotalLines,
+ totalLines: rawTotalLines,
bufferBefore,
bufferAfter: Math.max(0, bufferAfter),
};
diff --git a/packages/diffs/src/editor/editor.ts b/packages/diffs/src/editor/editor.ts
index c42dfe899..2645001e7 100644
--- a/packages/diffs/src/editor/editor.ts
+++ b/packages/diffs/src/editor/editor.ts
@@ -1,3 +1,9 @@
+import {
+ computeIndentFoldingRanges,
+ isFoldingClosingDelimiter,
+ LineRangeIndex,
+ mergeHiddenLineRanges,
+} from '../managers/FoldManager';
import {
dequeueRender,
queueRender,
@@ -15,6 +21,7 @@ import type {
FileDiffMetadata,
HighlightedToken,
LineAnnotation,
+ LineRange,
Position,
Range,
RenderRange,
@@ -22,6 +29,11 @@ import type {
SelectionSide,
TextEdit,
} from '../types';
+import {
+ FOLD_ELLIPSIS_ICON_SIZE,
+ FOLD_TOGGLE_ICON_SIZE,
+ getFoldIconSvg,
+} from '../utils/foldControls';
import { getFiletypeFromFileName } from '../utils/getFiletypeFromFileName';
import { isGutterUtilityPath } from '../utils/isGutterUtilityPath';
import {
@@ -291,6 +303,7 @@ export class Editor implements DiffsEditor {
cacheKey: string;
textDocument: TextDocument;
documentVersion: number;
+ foldStateVersion: number;
selections: EditorSelection[] | undefined;
view: EditorState['view'];
completion: Promise;
@@ -331,6 +344,7 @@ export class Editor implements DiffsEditor {
#themeStyleElement?: HTMLStyleElement;
#spriteElement?: SVGSVGElement;
#fileContainer?: HTMLElement;
+ #codeElement?: HTMLElement;
#gutterElement?: HTMLElement;
#contentElement?: HTMLElement;
#overlayElement?: HTMLElement;
@@ -396,6 +410,27 @@ 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;
+ // Cached `folding` option from the attached host component; refreshed on
+ // attach, on every render-view sync, and via __hostOptionsChanged.
+ #hostFoldingEnabled = false;
+ #foldRangeDocument?: TextDocument;
+ #foldRangeVersion = -1;
+ #renderViewGeneration = 0;
+ #pendingFoldFocus?: { line: number; generation: number };
+ #hasFoldIndicators = false;
+ #initializedFoldButtons = new WeakSet();
#onDeferTokenize = (
lines: Map>,
@@ -417,7 +452,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);
}
}
}
@@ -461,6 +496,15 @@ export class Editor implements DiffsEditor {
}
}
+ /**
+ * @internal Called by the host component after its options change. The
+ * `folding` option lives on the host (`BaseCodeOptions`), so this is how an
+ * option flip reaches an already-attached editor without a host re-render.
+ */
+ __hostOptionsChanged(): void {
+ this.#refreshHostFoldingOption();
+ }
+
// Small typescript hack to prevent UnresolvedFile from being editable.
edit>(
fileInstance: EditableInstance
@@ -473,6 +517,15 @@ export class Editor implements DiffsEditor {
}
}
this.#invalidateOnAttach();
+ this.#hostFoldingEnabled =
+ fileInstance.type === 'file' &&
+ fileInstance.__getEffectiveCodeOptions().folding !== false;
+ if (
+ !this.#hostFoldingEnabled ||
+ fileInstance.__setFoldRanges === undefined
+ ) {
+ this.#resetFoldingState();
+ }
this.#fileInstance = fileInstance;
this.#initialize();
this.#detach = fileInstance.attachEditor(this);
@@ -494,6 +547,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.
@@ -595,8 +649,17 @@ export class Editor implements DiffsEditor {
getState(): EditorState {
const fileInstance = this.#fileInstance;
+ 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:
fileInstance != null
? {
@@ -607,11 +670,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
@@ -741,7 +809,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.#options.persistState === true;
this.#stateRestoreGeneration++;
this.#persistCurrentState();
@@ -774,8 +843,19 @@ 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;
+ this.#hostFoldingEnabled = false;
// cache
this.#gutterWidthCache = undefined;
@@ -795,6 +875,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;
@@ -884,6 +965,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.');
@@ -912,6 +994,7 @@ export class Editor implements DiffsEditor {
this.#replacementFocusRequest = undefined;
return;
}
+ this.#codeElement = codeElement;
this.#getPopoverManager().setViewportElements(fileContainer, codeElement);
@@ -983,6 +1066,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 = {
@@ -1101,6 +1185,10 @@ export class Editor implements DiffsEditor {
}
}
+ this.#refreshHostFoldingOption();
+ this.#refreshFoldingRanges();
+ this.#syncFoldedRangesToHost();
+ this.#renderFoldingControls();
this.#resetCache();
// The tokenizer is created once per attached document and reused across
@@ -1235,6 +1323,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
@@ -1297,6 +1386,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;
let inputWatch: ViewportInputWatch | undefined;
@@ -1310,6 +1400,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 ||
this.#fileInfo === undefined ||
@@ -1362,6 +1453,7 @@ export class Editor implements DiffsEditor {
cacheKey,
textDocument,
documentVersion,
+ foldStateVersion,
selections,
view,
completion: result.catch(() => {}),
@@ -1376,6 +1468,671 @@ export class Editor implements DiffsEditor {
}
}
+ get #isFoldingEnabled(): boolean {
+ return (
+ this.#hostFoldingEnabled &&
+ this.#fileInstance?.type === 'file' &&
+ this.#fileInstance.__setFoldRanges !== undefined
+ );
+ }
+
+ // Re-read the host's `folding` option and rebuild or clear the fold state
+ // when it flipped. Host option changes always re-render the host, which
+ // lands in #syncRenderView, so polling there plus the explicit
+ // __hostOptionsChanged hook covers both render-driven and direct flips.
+ #refreshHostFoldingOption(): void {
+ const fileInstance = this.#fileInstance;
+ if (fileInstance?.type !== 'file') {
+ return;
+ }
+ const enabled = fileInstance.__getEffectiveCodeOptions().folding !== false;
+ if (this.#hostFoldingEnabled === enabled) {
+ return;
+ }
+ this.#hostFoldingEnabled = enabled;
+ this.#handleFoldingOptionChange();
+ }
+
+ #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-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;
+ }
+ }
+
+ // Attach toggle listeners once per button. Buttons can be adopted from
+ // read-only fold markup the file renderer emitted before the editor
+ // attached, so creation and initialization are tracked separately.
+ #initializeFoldButton(button: HTMLButtonElement): void {
+ if (this.#initializedFoldButtons.has(button)) {
+ return;
+ }
+ this.#initializedFoldButtons.add(button);
+ 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-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 = getFoldIconSvg(
+ folded ? 'chevron-right' : 'chevron-down',
+ FOLD_TOGGLE_ICON_SIZE
+ );
+ }
+
+ 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: getFoldIconSvg('ellipsis', FOLD_ELLIPSIS_ICON_SIZE),
+ });
+ 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);
+ }
+ }
+
#watchViewportUserInput(): ViewportInputWatch | undefined {
const viewport = this.#getScrollViewport();
if (!(viewport instanceof HTMLElement)) {
@@ -1401,28 +2158,59 @@ export class Editor implements DiffsEditor {
return { userScrolled: () => scrolled, dispose };
}
- // 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.
+ // 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;
};
}
@@ -1430,6 +2218,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);
}
@@ -3190,6 +3979,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[],
@@ -3246,7 +4065,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;
@@ -5228,6 +6047,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) {
@@ -5360,6 +6181,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/selection.ts b/packages/diffs/src/editor/selection.ts
index cb83b2e15..9999360f8 100644
--- a/packages/diffs/src/editor/selection.ts
+++ b/packages/diffs/src/editor/selection.ts
@@ -2406,6 +2406,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;
@@ -2635,6 +2649,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..a4102e8fc 100644
--- a/packages/diffs/src/editor/sprite.ts
+++ b/packages/diffs/src/editor/sprite.ts
@@ -8,13 +8,11 @@ export type SVGSpriteNames =
| '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 = `
@@ -57,6 +55,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/managers/FoldManager.ts b/packages/diffs/src/managers/FoldManager.ts
new file mode 100644
index 000000000..4429dcc4a
--- /dev/null
+++ b/packages/diffs/src/managers/FoldManager.ts
@@ -0,0 +1,479 @@
+import type { LineRange } from '../types';
+
+const CLOSING_DELIMITER_ONLY = /^[}\])]+[;,]?$/;
+
+/**
+ * The minimal line access folding needs; structurally satisfied by the
+ * editor's TextDocument and by adapters over a split-line cache.
+ */
+export interface FoldableLineSource {
+ lineCount: number;
+ getLineText(lineNumber: number): string;
+}
+
+/**
+ * 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: FoldableLineSource,
+ 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;
+}
+
+// Indentation fold candidates derived from a split-line cache, keyed by the
+// lines array identity so edits and file swaps invalidate the cache naturally.
+interface FoldableRangeCache {
+ lines: readonly string[];
+ ranges: LineRange[];
+ rangesByStart: Map;
+}
+
+export interface FoldManagerCallbacks {
+ /**
+ * Whether fold interception is currently active. Read-only File components
+ * return false while an editor session owns folding or the `folding`
+ * option is off.
+ */
+ isEnabled(): boolean;
+ /**
+ * A fold toggle or folded-block ellipsis was activated for the zero-based
+ * header line. `restoreFocus` is true for keyboard activations, asking the
+ * host to move focus back to the re-rendered toggle.
+ */
+ onToggleFold(startLine: number, restoreFocus: boolean): void;
+}
+
+/**
+ * Owns interactive code-fold state for read-only file components: the
+ * indentation fold candidates for the current contents, which fold headers
+ * the user has collapsed, and the capture-phase listeners that intercept
+ * clicks on rendered fold controls before line-selection handlers see them.
+ * An attached editor bypasses this manager entirely and drives fold state
+ * through its own document.
+ */
+export class FoldManager {
+ private foldedStarts = new Set();
+ private cache: FoldableRangeCache | undefined;
+ private pre: HTMLElement | undefined;
+
+ constructor(private callbacks?: FoldManagerCallbacks) {}
+
+ get foldedStartLines(): ReadonlySet {
+ return this.foldedStarts;
+ }
+
+ isFolded(startLine: number): boolean {
+ return this.foldedStarts.has(startLine);
+ }
+
+ hasFolds(): boolean {
+ return this.foldedStarts.size > 0;
+ }
+
+ /** Indentation fold candidates for `lines`, cached per lines identity. */
+ getFoldableRanges(lines: readonly string[]): LineRange[] {
+ return this.getRangeCache(lines).ranges;
+ }
+
+ /** Fold candidates for `lines` keyed by their zero-based header line. */
+ getFoldableRangesByStart(lines: readonly string[]): Map {
+ return this.getRangeCache(lines).rangesByStart;
+ }
+
+ private getRangeCache(lines: readonly string[]): FoldableRangeCache {
+ if (this.cache?.lines !== lines) {
+ const ranges = computeIndentFoldingRanges({
+ lineCount: lines.length,
+ getLineText: (line) => lines[line] ?? '',
+ });
+ this.cache = {
+ lines,
+ ranges,
+ rangesByStart: new Map(ranges.map((range) => [range.startLine, range])),
+ };
+ }
+ return this.cache;
+ }
+
+ /** Toggle a fold header; returns false when the line is not foldable. */
+ toggleFold(startLine: number, lines: readonly string[]): boolean {
+ if (!this.getFoldableRangesByStart(lines).has(startLine)) {
+ return false;
+ }
+ if (!this.foldedStarts.delete(startLine)) {
+ this.foldedStarts.add(startLine);
+ }
+ return true;
+ }
+
+ /**
+ * Hidden line ranges derived from the collapsed folds, after dropping folds
+ * whose header is no longer foldable in the current contents.
+ */
+ getHiddenLineRanges(lines: readonly string[]): LineRange[] {
+ if (this.foldedStarts.size === 0) {
+ return [];
+ }
+ const { ranges, rangesByStart } = this.getRangeCache(lines);
+ for (const startLine of this.foldedStarts) {
+ if (!rangesByStart.has(startLine)) {
+ this.foldedStarts.delete(startLine);
+ }
+ }
+ return mergeHiddenLineRanges(ranges, this.foldedStarts);
+ }
+
+ /** Clear all collapsed folds; returns whether anything was folded. */
+ reset(): boolean {
+ if (this.foldedStarts.size === 0) {
+ return false;
+ }
+ this.foldedStarts.clear();
+ return true;
+ }
+
+ /**
+ * Listen for fold-control activation on the rendered code. Capture phase so
+ * a handled toggle press never reaches the line-selection listeners the
+ * InteractionManager attaches to the same element.
+ */
+ setup(pre: HTMLElement): void {
+ if (this.pre === pre) {
+ return;
+ }
+ this.cleanUp();
+ this.pre = pre;
+ pre.addEventListener('click', this.handleClick, true);
+ pre.addEventListener('pointerdown', this.handlePointerDown, true);
+ }
+
+ /** Detach listeners. Fold state is kept; use reset() to clear it. */
+ cleanUp(): void {
+ this.pre?.removeEventListener('click', this.handleClick, true);
+ this.pre?.removeEventListener('pointerdown', this.handlePointerDown, true);
+ this.pre = undefined;
+ }
+
+ private handlePointerDown = (event: Event): void => {
+ if (
+ this.callbacks?.isEnabled() !== true ||
+ foldButtonFromEvent(event) == null
+ ) {
+ return;
+ }
+ // Match editor fold buttons: no focus-on-press and no selection drag.
+ event.preventDefault();
+ event.stopPropagation();
+ };
+
+ private handleClick = (event: Event): void => {
+ const callbacks = this.callbacks;
+ if (callbacks?.isEnabled() !== true) {
+ return;
+ }
+ const button = foldButtonFromEvent(event);
+ if (button == null) {
+ return;
+ }
+ event.preventDefault();
+ event.stopPropagation();
+ const lineIndex = Number(
+ button.closest('[data-line-index]')?.dataset.lineIndex
+ );
+ if (Number.isInteger(lineIndex)) {
+ const restoreFocus = event instanceof MouseEvent && event.detail === 0;
+ callbacks.onToggleFold(lineIndex, restoreFocus);
+ }
+ };
+}
+
+function foldButtonFromEvent(event: Event): HTMLElement | null {
+ const target = event.target;
+ if (!(target instanceof Element)) {
+ return null;
+ }
+ return target.closest(
+ '[data-fold-toggle], [data-fold-ellipsis]'
+ );
+}
diff --git a/packages/diffs/src/renderers/FileRenderer.ts b/packages/diffs/src/renderers/FileRenderer.ts
index fed6a00c9..5f6953c31 100644
--- a/packages/diffs/src/renderers/FileRenderer.ts
+++ b/packages/diffs/src/renderers/FileRenderer.ts
@@ -13,6 +13,7 @@ import {
} from '../highlighter/shared_highlighter';
import { areThemesAttached } from '../highlighter/themes/areThemesAttached';
import { hasResolvedThemes } from '../highlighter/themes/hasResolvedThemes';
+import type { FoldManager } from '../managers/FoldManager';
import type {
BaseCodeOptions,
DiffsHighlighter,
@@ -21,6 +22,7 @@ import type {
FileHeaderRenderMode,
HighlightedToken,
LineAnnotation,
+ LineRange,
RenderedFileASTCache,
RenderFileOptions,
RenderFileResult,
@@ -37,6 +39,10 @@ import { createAnnotationElement } from '../utils/createAnnotationElement';
import { createContentColumn } from '../utils/createContentColumn';
import { createFileHeaderElement } from '../utils/createFileHeaderElement';
import { createPreElement } from '../utils/createPreElement';
+import {
+ createFoldIndicatorElement,
+ createFoldToggleElement,
+} from '../utils/foldControls';
import { getFiletypeFromFileName } from '../utils/getFiletypeFromFileName';
import { getHighlighterOptions } from '../utils/getHighlighterOptions';
import { getLineAnnotationName } from '../utils/getLineAnnotationName';
@@ -109,6 +115,10 @@ export class FileRenderer {
private renderCache: RenderedFileASTCache | undefined;
private computedLang: SupportedLanguages = 'text';
private lineAnnotations: AnnotationLineMap = {};
+ private foldRanges: LineRange[] = [];
+ // Owns fold candidates and collapsed-fold state for read-only rendering.
+ // Shared by the owning File component, which routes toggles through it.
+ private foldManager: FoldManager | undefined;
private lineCache: LineCache | undefined;
private pendingStructuralRows: Map | undefined;
private textDocumentCache = new WeakMap();
@@ -151,6 +161,31 @@ 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 setFoldManager(foldManager: FoldManager): void {
+ this.foldManager = foldManager;
+ }
+
+ /**
+ * Whether rendered output should include fold controls: a fold manager is
+ * present, the folding option is on, and no editor session is active (an
+ * attached editor renders its own controls against its live document).
+ */
+ public showsFoldControls(): boolean {
+ return (
+ this.foldManager != null &&
+ this.options.folding !== false &&
+ !this.editSessionActive
+ );
+ }
+
public cleanUp(): void {
this.recycle();
this.workerManager = undefined;
@@ -187,6 +222,7 @@ export class FileRenderer {
this.clearRenderCache();
this.highlighter = undefined;
this.workerManager?.cleanUpTasks(this);
+ this.foldRanges = [];
this.lineCache = undefined;
// The session flag re-seeds on the next editor attach (beginEditSession).
this.endEditSession();
@@ -214,7 +250,9 @@ export class FileRenderer {
) {
return;
}
- renderCache.file.contents = lineCache.lines.join('');
+ const contents = lineCache.lines.join('');
+ renderCache.file.contents = contents;
+ lineCache.sourceContents = contents;
}
// Unkeyed files use object identity, so compare the retained source text to
@@ -627,7 +665,8 @@ export class FileRenderer {
file,
renderRange.startingLine,
renderRange.totalLines,
- lines
+ lines,
+ this.foldRanges
);
}
this.renderCache.renderRange = renderRange;
@@ -657,20 +696,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,
};
}
@@ -678,14 +720,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.applyHighlightResult(
+ file,
+ result,
+ options,
+ !forcePlainText,
+ renderRange
+ );
}
- this.applyHighlightResult(file, result, options, !forcePlainText);
- });
+ );
}
}
@@ -702,16 +752,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));
@@ -729,18 +782,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 };
}
@@ -752,6 +811,10 @@ export class FileRenderer {
): FileRenderResult {
const totalLines = this.getLineCount(file);
const { disableFileHeader = false } = this.options;
+ const foldManager = this.showsFoldControls() ? this.foldManager : undefined;
+ const foldableRangesByStart = foldManager?.getFoldableRangesByStart(
+ this.getOrCreateLineCache(file)
+ );
const contentArray: ElementContent[] = [];
const gutter = createGutterWrapper();
const endLine = Math.min(
@@ -778,11 +841,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
@@ -797,11 +884,34 @@ export class FileRenderer {
throw new Error(message);
}
- // Add gutter line number
- gutter.children.push(
- createGutterItem('context', lineNumber, `${lineIndex}`)
+ // Add gutter line number, with a fold toggle on foldable headers
+ const gutterItem = createGutterItem(
+ 'context',
+ lineNumber,
+ `${lineIndex}`
+ );
+ const foldable = foldableRangesByStart?.get(lineIndex) != null;
+ const isFoldedHeader =
+ foldable && foldManager?.isFolded(lineIndex) === true;
+ if (foldable) {
+ gutterItem.children.push(
+ createFoldToggleElement(lineIndex, isFoldedHeader)
+ );
+ }
+ gutter.children.push(gutterItem);
+ // The cached HAST row is shared across renders; clone before appending
+ // the folded-block indicator so unfolding never leaves one behind.
+ contentArray.push(
+ isFoldedHeader && line.type === 'element'
+ ? {
+ ...line,
+ children: [
+ ...line.children,
+ createFoldIndicatorElement(lineIndex),
+ ],
+ }
+ : line
);
- contentArray.push(line);
rowCount++;
// Check annotations using ACTUAL line number from file
@@ -860,7 +970,10 @@ export class FileRenderer {
createHastElement({
tagName: 'code',
children: this.renderCodeAST(result),
- properties: { 'data-code': '' },
+ properties: {
+ 'data-code': '',
+ 'data-folding': this.showsFoldControls() ? '' : undefined,
+ },
})
);
return { ...result.preAST, children };
@@ -909,19 +1022,21 @@ export class FileRenderer {
file: FileContents,
result: ThemedFileResult,
options: RenderFileOptions,
- highlighted = true
+ highlighted = true,
+ renderRange?: RenderRange
): void {
if (this.editSessionActive) {
return;
}
- this.applyHighlightResult(file, result, options, highlighted);
+ this.applyHighlightResult(file, result, options, highlighted, renderRange);
}
private applyHighlightResult(
file: FileContents,
result: ThemedFileResult,
options: RenderFileOptions,
- highlighted = true
+ highlighted = true,
+ renderRange?: RenderRange
): void {
if (this.renderCache == null) {
return;
@@ -936,7 +1051,7 @@ export class FileRenderer {
options,
highlighted,
result,
- renderRange: undefined,
+ renderRange: highlighted ? undefined : renderRange,
};
if (triggerRenderUpdate) {
diff --git a/packages/diffs/src/sprite.ts b/packages/diffs/src/sprite.ts
index da56b833f..3045b5f7a 100644
--- a/packages/diffs/src/sprite.ts
+++ b/packages/diffs/src/sprite.ts
@@ -8,6 +8,9 @@ export type SVGSpriteNames =
| 'diffs-icon-expand'
| 'diffs-icon-expand-all'
| 'diffs-icon-file-code'
+ | 'diffs-icon-fold-chevron-down'
+ | 'diffs-icon-fold-chevron-right'
+ | 'diffs-icon-fold-ellipsis'
| 'diffs-icon-plus'
| 'diffs-icon-symbol-added'
| 'diffs-icon-symbol-deleted'
@@ -45,6 +48,15 @@ export const SVGSpriteSheet = `
+
+
+
+
+
+
+
+
+
diff --git a/packages/diffs/src/style.css b/packages/diffs/src/style.css
index e796d1b77..ea8909a77 100644
--- a/packages/diffs/src/style.css
+++ b/packages/diffs/src/style.css
@@ -307,6 +307,126 @@
}
}
+ /* Code folding controls. `data-folding` is set on the code element whenever
+ * fold controls are active — by the read-only file renderer or by an
+ * attached editor — and reserves gutter space for the toggle zone. */
+ [data-code][data-folding] {
+ --diffs-fold-gap: 4px;
+ --diffs-fold-width: 16px;
+ }
+
+ [data-code][data-folding] [data-column-number] {
+ position: relative;
+ padding-right: calc(var(--diffs-fold-width) + var(--diffs-fold-gap));
+ }
+
+ [data-file][data-disable-line-numbers]
+ [data-code][data-folding]
+ [data-column-number] {
+ min-width: var(--diffs-fold-width);
+ padding-right: var(--diffs-fold-width);
+ }
+
+ /* The hover gutter utility also anchors to the cell's right edge; with fold
+ * controls active it yields that corner and sits left of the fold zone so
+ * both stay clickable. */
+ [data-code][data-folding] [data-gutter-utility-slot] {
+ right: calc(var(--diffs-fold-width) + var(--diffs-fold-gap));
+ }
+
+ /* Without line numbers the utility is left-anchored (see the
+ * data-disable-line-numbers block); undo the fold offset for that combo. */
+ [data-file][data-disable-line-numbers]
+ [data-code][data-folding]
+ [data-gutter-utility-slot] {
+ right: unset;
+ }
+
+ [data-fold] {
+ position: absolute;
+ top: 0;
+ right: 0;
+ width: var(--diffs-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-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;
+ }
+
+ /* Two attribute selectors so the indicator's color beats the token-color
+ * rule ([data-line] span) sharing this layer. */
+ [data-line] [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-line] span {
color: light-dark(
var(--diffs-token-light, var(--diffs-light)),
diff --git a/packages/diffs/src/types.ts b/packages/diffs/src/types.ts
index 67c013c7f..842f15f87 100644
--- a/packages/diffs/src/types.ts
+++ b/packages/diffs/src/types.ts
@@ -432,6 +432,13 @@ export interface BaseCodeOptions {
disableFileHeader?: boolean;
disableVirtualizationBuffers?: boolean;
stickyHeader?: boolean;
+ /**
+ * Show code-fold controls on file components, default is true. Read-only
+ * File components manage their own fold state; an attached editor takes
+ * over folding for the edit session. FileDiff components do not support
+ * folding and ignore this option.
+ */
+ folding?: boolean;
// Shiki config options, ignored if you're using a WorkerPoolManager
preferredHighlighter?: HighlighterTypes;
@@ -477,7 +484,12 @@ export interface BaseDiffOptions extends BaseCodeOptions {
export type BaseDiffOptionsWithDefaults = Required<
Omit<
BaseDiffOptions,
- 'unsafeCSS' | 'preferredHighlighter' | 'parseDiffOptions' | 'loadDiffFiles'
+ | 'unsafeCSS'
+ | 'preferredHighlighter'
+ | 'parseDiffOptions'
+ | 'loadDiffFiles'
+ // Diff components do not support folding.
+ | 'folding'
>
>;
@@ -793,6 +805,7 @@ export interface ForceFilePlainTextOptions {
totalLines?: number;
// Pre-split lines for caching in windowing scenarios
lines?: string[];
+ hiddenLineRanges?: readonly LineRange[];
}
export interface RenderFileOptions {
@@ -1042,6 +1055,11 @@ export interface DiffsEditableComponent<
getCodeScrollLeft: () => number;
/** Set the horizontal code scroll position (`scrollLeft`). */
setCodeScrollLeft: (position: number) => 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.
@@ -1124,6 +1142,12 @@ export type EditableInstance = T extends {
export interface DiffsEditor {
/** @internal */
__prepareFile?(file: FileContents): FileContents;
+ /**
+ * @internal Notify the editor that the host component's options changed.
+ * The editor reads shared code options (e.g. `folding`) from its host, so
+ * hosts call this after an options swap that does not re-render.
+ */
+ __hostOptionsChanged?(): void;
__postponeBgTokenizeToNextFrame(): void;
/** @internal Capture focus intent before replacing the editable view. */
__captureFocusForDOMReplacement(): void;
@@ -1262,6 +1286,11 @@ export interface EditorViewState {
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?: EditorViewState;
}
@@ -1271,6 +1300,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/edit`, so factories can spread
diff --git a/packages/diffs/src/utils/foldControls.ts b/packages/diffs/src/utils/foldControls.ts
new file mode 100644
index 000000000..c61103f29
--- /dev/null
+++ b/packages/diffs/src/utils/foldControls.ts
@@ -0,0 +1,90 @@
+import type { Element as HASTElement } from 'hast';
+
+import { createHastElement } from './hast_utils';
+
+export type FoldIconNames = 'chevron-down' | 'chevron-right' | 'ellipsis';
+
+// Fold controls render the same markup whether the read-only renderer emits
+// them as HAST or the editor patches them into the DOM, so an editor attaching
+// to an already-rendered file can adopt the existing buttons in place. The
+// icons reference the base sprite (always present in the component shadow
+// root) and omit an outer viewBox so each symbol scales to the requested size.
+export const getFoldIconSvg = (name: FoldIconNames, size = 16): string =>
+ ` `;
+
+export const FOLD_TOGGLE_ICON_SIZE = 14;
+export const FOLD_ELLIPSIS_ICON_SIZE = 12;
+
+function createFoldIconElement(name: FoldIconNames, size: number): HASTElement {
+ return createHastElement({
+ tagName: 'svg',
+ properties: {
+ width: size,
+ height: size,
+ 'aria-hidden': 'true',
+ focusable: 'false',
+ },
+ children: [
+ createHastElement({
+ tagName: 'use',
+ properties: { href: `#diffs-icon-fold-${name}` },
+ }),
+ ],
+ });
+}
+
+/**
+ * Gutter fold control for a foldable line: an absolutely-positioned zone with
+ * the chevron toggle button, appended inside the line's gutter cell.
+ */
+export function createFoldToggleElement(
+ lineIndex: number,
+ folded: boolean
+): HASTElement {
+ return createHastElement({
+ tagName: 'span',
+ properties: { 'data-fold': '' },
+ children: [
+ createHastElement({
+ tagName: 'button',
+ properties: {
+ 'data-fold-toggle': '',
+ type: 'button',
+ 'data-folded': folded ? '' : undefined,
+ 'aria-expanded': folded ? 'false' : 'true',
+ 'aria-label': `${folded ? 'Unfold' : 'Fold'} line ${lineIndex + 1}`,
+ title: folded ? 'Unfold' : 'Fold',
+ },
+ children: [
+ createFoldIconElement(
+ folded ? 'chevron-right' : 'chevron-down',
+ FOLD_TOGGLE_ICON_SIZE
+ ),
+ ],
+ }),
+ ],
+ });
+}
+
+/**
+ * Inline indicator appended after a folded line's content: an ellipsis button
+ * that unfolds the hidden block.
+ */
+export function createFoldIndicatorElement(lineIndex: number): HASTElement {
+ return createHastElement({
+ tagName: 'span',
+ properties: { 'data-fold-indicator': '' },
+ children: [
+ createHastElement({
+ tagName: 'button',
+ properties: {
+ 'data-fold-ellipsis': '',
+ type: 'button',
+ 'aria-label': `Unfold line ${lineIndex + 1}`,
+ title: 'Unfold',
+ },
+ children: [createFoldIconElement('ellipsis', FOLD_ELLIPSIS_ICON_SIZE)],
+ }),
+ ],
+ });
+}
diff --git a/packages/diffs/src/utils/getFileRendererOptions.ts b/packages/diffs/src/utils/getFileRendererOptions.ts
index bfbb312b9..11dc2f519 100644
--- a/packages/diffs/src/utils/getFileRendererOptions.ts
+++ b/packages/diffs/src/utils/getFileRendererOptions.ts
@@ -16,6 +16,7 @@ export function getFileRendererOptions(
disableFileHeader: options?.disableFileHeader,
disableVirtualizationBuffers: options?.disableVirtualizationBuffers,
stickyHeader: options?.stickyHeader,
+ folding: options?.folding,
preferredHighlighter: options?.preferredHighlighter,
useCSSClasses: options?.useCSSClasses,
useTokenTransformer: shouldUseTokenTransformer(options),
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 4d28941eb..870c2c052 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,
@@ -637,7 +638,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();
@@ -647,7 +649,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..ad7475f36
--- /dev/null
+++ b/packages/diffs/test/VirtualizedFile.folding.test.ts
@@ -0,0 +1,267 @@
+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: 4,
+ bufferBefore: 20,
+ bufferAfter: 40,
+ });
+
+ 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_994,
+ bufferBefore: 0,
+ bufferAfter: 60,
+ });
+ expect(hiddenChecks).toBeLessThan(10);
+
+ const result = layout.fileRenderer.renderFile(file, range);
+ const code = layout.fileRenderer.renderCache?.result?.code;
+ expect(result?.rowCount).toBe(4);
+ expect(Object.keys(code ?? [])).toEqual(['0', '19991', '19992', '19993']);
+
+ 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_994);
+ expect(unfoldedCode?.[1]).toBeDefined();
+ expect(unfoldedCode?.[19_993]).toBeDefined();
+ });
+});
diff --git a/packages/diffs/test/e2e/e2e-globals.d.ts b/packages/diffs/test/e2e/e2e-globals.d.ts
index 4137972ae..84e1599fe 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;
@@ -71,12 +72,14 @@ interface Window {
__selectionChanges?: (E2ELineRange | null)[];
__gutterClicks?: E2ELineRange[];
__actionClicks?: string[];
+ __lineNumberClicks?: number[];
// theme.html helper for rendering one row in each line-highlight state.
__setLineHighlightState?: (state: E2ELineHighlightState) => void;
// Editor handle exposed by the editable fixtures.
__editor?: E2EEditor;
+ __setFoldingTheme?: () => void;
__forceEditorFullRender?: () => void;
__moveEditorContainer?: () => void;
__syncCount?: number;
diff --git a/packages/diffs/test/e2e/fixtures/folding-readonly.html b/packages/diffs/test/e2e/fixtures/folding-readonly.html
new file mode 100644
index 000000000..677d1f7f3
--- /dev/null
+++ b/packages/diffs/test/e2e/fixtures/folding-readonly.html
@@ -0,0 +1,91 @@
+
+
+
+
+
+ diffs read-only-folding fixture
+
+
+
+ ← All fixtures
+
+
+
+
+
diff --git a/packages/diffs/test/e2e/fixtures/folding.html b/packages/diffs/test/e2e/fixtures/folding.html
new file mode 100644
index 000000000..ae1fdfed8
--- /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..4b456bbd3 100644
--- a/packages/diffs/test/e2e/fixtures/index.html
+++ b/packages/diffs/test/e2e/fixtures/index.html
@@ -116,6 +116,26 @@ @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.
+
+
+
+ folding-readonly.html
+
+ A read-only File (no editor) with fold controls and line
+ selection enabled. Drives the read-only suite in
+ folding.pw.ts (fold/unfold, selection not triggered by
+ toggles). Ready flag: window.__foldingReady; line-number
+ clicks are logged to window.__lineNumberClicks.
+
+
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..ec373a823
--- /dev/null
+++ b/packages/diffs/test/e2e/folding.pw.ts
@@ -0,0 +1,147 @@
+import { expect, type Page, test } from '@playwright/test';
+
+const GUTTER = '[data-code][data-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);
+}
+
+async function openReadOnlyFixture(page: Page): Promise {
+ await page.goto('/test/e2e/fixtures/folding-readonly.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-icon-fold-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-icon-fold-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-icon-fold-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-icon-fold-chevron-down'
+ );
+ await expect(toggle).toHaveCSS('opacity', '0');
+ });
+});
+
+test.describe('read-only folding controls', () => {
+ test('fold and unfold without an editor and skip line selection', async ({
+ page,
+ }) => {
+ await openReadOnlyFixture(page);
+
+ const gutter = page.locator(GUTTER);
+ const toggle = page.locator(OUTER_TOGGLE);
+ await expect(toggle.locator('use')).toHaveAttribute(
+ 'href',
+ '#diffs-icon-fold-chevron-down'
+ );
+
+ await gutter.hover();
+ await expect(toggle).toHaveCSS('opacity', '0.5');
+
+ await toggle.click();
+ await expect.poll(() => renderedLineNumbers(page)).toEqual([1, 7, 8]);
+ await expect(toggle).toHaveAttribute('data-folded', '');
+
+ // The toggle click must not have reached the line-number handler.
+ expect(await page.evaluate(() => window.__lineNumberClicks ?? [])).toEqual(
+ []
+ );
+
+ const indicator = page.locator(OUTER_INDICATOR);
+ const ellipsis = indicator.locator('[data-fold-ellipsis]');
+ await expect(indicator).toBeVisible();
+ await ellipsis.click();
+ await expect
+ .poll(() => renderedLineNumbers(page))
+ .toEqual([1, 2, 3, 4, 5, 6, 7, 8]);
+ await expect(indicator).toHaveCount(0);
+
+ // A plain line-number click still selects.
+ await page.locator('[data-column-number="8"]').click();
+ await expect
+ .poll(() => page.evaluate(() => window.__lineNumberClicks ?? []))
+ .toEqual([8]);
+ });
+});
diff --git a/packages/diffs/test/editorFolding.test.ts b/packages/diffs/test/editorFolding.test.ts
new file mode 100644
index 000000000..7dc149832
--- /dev/null
+++ b/packages/diffs/test/editorFolding.test.ts
@@ -0,0 +1,643 @@
+import { afterAll, describe, expect, test } from 'bun:test';
+
+import { File, type FileOptions } from '../src/components/File';
+import { FileDiff } from '../src/components/FileDiff';
+import { DEFAULT_THEMES } from '../src/constants';
+import { Editor, type EditorOptions } from '../src/editor/editor';
+import { 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);
+}
+
+interface FileEditorFixtureProps {
+ editorOptions?: EditorOptions;
+ fileOptions?: Partial>;
+ contents?: string;
+}
+
+async function createFileEditorFixture({
+ editorOptions,
+ fileOptions,
+ contents = FOLDABLE_CONTENTS,
+}: FileEditorFixtureProps = {}): Promise {
+ const dom = installDom();
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+
+ const file = new File({
+ disableFileHeader: true,
+ theme: DEFAULT_THEMES,
+ ...fileOptions,
+ });
+ 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-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-icon-fold-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-icon-fold-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-icon-fold-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-icon-fold-chevron-down'
+ );
+ } finally {
+ cleanup();
+ }
+ });
+
+ test('can be disabled through the file options', async () => {
+ const { cleanup, container } = await createFileEditorFixture({
+ fileOptions: { folding: false },
+ });
+ try {
+ const shadow = shadowRoot(container);
+ expect(shadow.querySelector('[data-code][data-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, file } = await createFileEditorFixture({
+ fileOptions: { folding: false },
+ });
+ const setFolding = (folding: boolean): void => {
+ file.setOptions({ ...file.options, folding });
+ };
+ try {
+ setFolding(true);
+ await waitFor(() => foldToggle(container, 1) != null);
+
+ foldToggle(container, 1).click();
+ await waitForLines(container, [1, 7, 8]);
+
+ setFolding(false);
+ await waitForLines(container, [1, 2, 3, 4, 5, 6, 7, 8]);
+ expect(shadowRoot(container).querySelector('[data-fold-toggle]')).toBe(
+ null
+ );
+
+ setFolding(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-icon-fold-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({
+ 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-icon-fold-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-icon-fold-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({
+ contents: `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({
+ contents: ['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,
+ folding: true,
+ });
+ const editor = new Editor();
+ 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-folding]')).toBe(null);
+ expect(shadow.querySelector('[data-fold]')).toBe(null);
+ 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..247ac65ea
--- /dev/null
+++ b/packages/diffs/test/editorFoldingRanges.test.ts
@@ -0,0 +1,171 @@
+import { describe, expect, test } from 'bun:test';
+
+import { TextDocument } from '../src/editor/textDocument';
+import {
+ computeIndentFoldingRanges,
+ LineRangeIndex,
+ mergeHiddenLineRanges,
+} from '../src/managers/FoldManager';
+
+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 9b479f52d..ef4d09747 100644
--- a/packages/diffs/test/editorPersistStateLifecycle.test.ts
+++ b/packages/diffs/test/editorPersistStateLifecycle.test.ts
@@ -5,7 +5,7 @@ import { FileDiff } from '../src/components/FileDiff';
import { DEFAULT_THEMES } from '../src/constants';
import { Editor, type IStateStorage } from '../src/edit';
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 () => {
@@ -18,6 +18,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;
@@ -97,7 +112,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 34f419782..b894c9cf5 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));
diff --git a/packages/diffs/test/fileFolding.test.ts b/packages/diffs/test/fileFolding.test.ts
new file mode 100644
index 000000000..b60e05227
--- /dev/null
+++ b/packages/diffs/test/fileFolding.test.ts
@@ -0,0 +1,307 @@
+import { afterAll, describe, expect, test } from 'bun:test';
+
+import { File, type FileOptions } from '../src/components/File';
+import { DEFAULT_THEMES } from '../src/constants';
+import { Editor } from '../src/editor/editor';
+import { disposeHighlighter } from '../src/highlighter/shared_highlighter';
+import type { FileContents } from '../src/types';
+import { installDom, 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 ReadOnlyFileFixture {
+ cleanup(): void;
+ container: HTMLElement;
+ file: File;
+}
+
+async function createReadOnlyFileFixture(
+ fileOptions?: Partial>,
+ 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,
+ ...fileOptions,
+ });
+ const fileContents: FileContents = {
+ name: 'foldable.ts',
+ contents,
+ };
+
+ file.render({
+ file: fileContents,
+ fileContainer: container,
+ forceRender: true,
+ });
+ await waitFor(() => renderedLineNumbers(container).length > 0, {
+ timeout: 3000,
+ });
+
+ return {
+ cleanup() {
+ file.cleanUp();
+ dom.cleanup();
+ },
+ container,
+ 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 [
+ ...(container.shadowRoot?.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(button: HTMLButtonElement): string | null {
+ return button.querySelector('use')?.getAttribute('href') ?? null;
+}
+
+function foldEllipsis(
+ container: HTMLElement,
+ oneIndexedLine: number
+): HTMLButtonElement {
+ const ellipsis = shadowRoot(container).querySelector(
+ `[data-content] > [data-line="${oneIndexedLine}"] > [data-fold-indicator] > [data-fold-ellipsis]`
+ );
+ if (!(ellipsis instanceof HTMLButtonElement)) {
+ throw new Error(`no fold ellipsis found for line ${oneIndexedLine}`);
+ }
+ return ellipsis;
+}
+
+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('read-only File folding', () => {
+ test('renders fold controls by default and folds or unfolds from the gutter', async () => {
+ const { cleanup, container } = await createReadOnlyFileFixture();
+ try {
+ const shadow = shadowRoot(container);
+ await waitFor(() => foldToggle(container, 1) != null);
+
+ expect(shadow.querySelector('[data-code][data-folding]')).not.toBe(null);
+ const initialToggle = foldToggle(container, 1);
+ expect(initialToggle.getAttribute('aria-expanded')).toBe('true');
+ expect(foldIconHref(initialToggle)).toBe('#diffs-icon-fold-chevron-down');
+ // The context lines (return statement, closing brace) are not foldable.
+ expect(gutterRow(container, 6).querySelector('[data-fold-toggle]')).toBe(
+ null
+ );
+
+ initialToggle.click();
+ await waitForLines(container, [1, 7, 8]);
+
+ const foldedToggle = foldToggle(container, 1);
+ expect(foldedToggle.hasAttribute('data-folded')).toBe(true);
+ expect(foldedToggle.getAttribute('aria-expanded')).toBe('false');
+ expect(foldIconHref(foldedToggle)).toBe('#diffs-icon-fold-chevron-right');
+
+ const ellipsis = foldEllipsis(container, 1);
+ expect(ellipsis.getAttribute('aria-label')).toBe('Unfold line 1');
+ expect(foldIconHref(ellipsis)).toBe('#diffs-icon-fold-ellipsis');
+
+ ellipsis.click();
+ await waitForLines(container, [1, 2, 3, 4, 5, 6, 7, 8]);
+ expect(shadow.querySelector('[data-fold-indicator]')).toBe(null);
+ expect(foldToggle(container, 1).hasAttribute('data-folded')).toBe(false);
+ } finally {
+ cleanup();
+ }
+ });
+
+ test('preserves a nested fold while its outer fold is toggled', async () => {
+ const { cleanup, container } = await createReadOnlyFileFixture();
+ try {
+ await waitFor(() => foldToggle(container, 3) != null);
+
+ 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]);
+ expect(foldToggle(container, 3).hasAttribute('data-folded')).toBe(true);
+
+ foldToggle(container, 3).click();
+ await waitForLines(container, [1, 2, 3, 4, 5, 6, 7, 8]);
+ } finally {
+ cleanup();
+ }
+ });
+
+ test('renders no fold controls when the folding option is off', async () => {
+ const { cleanup, container } = await createReadOnlyFileFixture({
+ folding: false,
+ });
+ try {
+ const shadow = shadowRoot(container);
+ expect(shadow.querySelector('[data-code][data-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('unfolds and removes controls when folding is disabled at runtime', async () => {
+ const { cleanup, container, file } = await createReadOnlyFileFixture();
+ try {
+ await waitFor(() => foldToggle(container, 1) != null);
+ foldToggle(container, 1).click();
+ await waitForLines(container, [1, 7, 8]);
+
+ file.setOptions({ ...file.options, folding: false });
+ await waitForLines(container, [1, 2, 3, 4, 5, 6, 7, 8]);
+ expect(shadowRoot(container).querySelector('[data-fold-toggle]')).toBe(
+ null
+ );
+
+ file.setOptions({ ...file.options, folding: true });
+ file.rerender();
+ await waitFor(() => foldToggle(container, 1) != null);
+ } finally {
+ cleanup();
+ }
+ });
+
+ test('resets fold state when the rendered file changes', async () => {
+ const { cleanup, container, file } = await createReadOnlyFileFixture();
+ try {
+ await waitFor(() => foldToggle(container, 1) != null);
+ foldToggle(container, 1).click();
+ await waitForLines(container, [1, 7, 8]);
+
+ file.render({
+ file: {
+ name: 'foldable.ts',
+ contents: `// changed\n${FOLDABLE_CONTENTS}`,
+ },
+ forceRender: true,
+ });
+ await waitForLines(container, [1, 2, 3, 4, 5, 6, 7, 8, 9]);
+ expect(shadowRoot(container).querySelector('[data-folded]')).toBe(null);
+ } finally {
+ cleanup();
+ }
+ });
+
+ test('does not trigger line callbacks when a fold control is clicked', async () => {
+ const lineNumberClicks: number[] = [];
+ const { cleanup, container } = await createReadOnlyFileFixture({
+ enableLineSelection: true,
+ onLineNumberClick: (props) => {
+ lineNumberClicks.push(props.lineNumber);
+ },
+ });
+ try {
+ await waitFor(() => foldToggle(container, 1) != null);
+
+ foldToggle(container, 1).click();
+ await waitForLines(container, [1, 7, 8]);
+ expect(lineNumberClicks).toEqual([]);
+
+ // Clicking the gutter cell outside the toggle still reaches the
+ // line-number handler.
+ gutterRow(container, 8).click();
+ expect(lineNumberClicks).toEqual([8]);
+ } finally {
+ cleanup();
+ }
+ });
+
+ test('hands folding to an attached editor and restores it on detach', async () => {
+ const { cleanup, container, file } = await createReadOnlyFileFixture();
+ const editor = new Editor();
+ try {
+ await waitFor(() => foldToggle(container, 1) != null);
+ foldToggle(container, 1).click();
+ await waitForLines(container, [1, 7, 8]);
+
+ editor.edit(file);
+ // Attaching unfolds the read-only state; the editor owns folding now.
+ await waitForLines(container, [1, 2, 3, 4, 5, 6, 7, 8]);
+
+ await waitFor(() => foldToggle(container, 1) != null);
+ foldToggle(container, 1).click();
+ await waitForLines(container, [1, 7, 8]);
+
+ editor.cleanUp();
+ // Editor teardown clears its folds; read-only controls keep working.
+ await waitForLines(container, [1, 2, 3, 4, 5, 6, 7, 8]);
+ await waitFor(() => foldToggle(container, 1) != null);
+ foldToggle(container, 1).click();
+ await waitForLines(container, [1, 7, 8]);
+ } finally {
+ editor.cleanUp();
+ cleanup();
+ }
+ });
+});