From 0d8ea1a12a5cf962d1b4102cc8ff3c9a508c35b7 Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Thu, 28 May 2026 18:06:48 -0700 Subject: [PATCH 1/5] chore: empty commit for beta branch From 93c85e96e69e443f786df5cb1c80f27d6ffba06c Mon Sep 17 00:00:00 2001 From: Je Xia Date: Fri, 17 Jul 2026 01:17:54 +0800 Subject: [PATCH 2/5] [diffs/edit] add folding support --- .../docs/app/(diffs)/docs/Editor/constants.ts | 4 +- packages/diffs/src/components/File.ts | 30 + .../diffs/src/components/VirtualizedFile.ts | 347 +++++++- packages/diffs/src/editor/editor.css | 102 +++ packages/diffs/src/editor/editor.ts | 820 +++++++++++++++++- packages/diffs/src/editor/folding.ts | 294 +++++++ packages/diffs/src/editor/selection.ts | 18 + packages/diffs/src/editor/sprite.ts | 28 +- packages/diffs/src/editor/stateStorage.ts | 1 + packages/diffs/src/renderers/FileRenderer.ts | 31 + packages/diffs/src/types.ts | 15 + .../test/VirtualizedFile.folding.test.ts | 216 +++++ packages/diffs/test/e2e/e2e-globals.d.ts | 2 + packages/diffs/test/e2e/fixtures/folding.html | 94 ++ packages/diffs/test/e2e/fixtures/index.html | 8 + packages/diffs/test/e2e/folding.pw.ts | 105 +++ packages/diffs/test/editorFolding.test.ts | 650 ++++++++++++++ .../diffs/test/editorFoldingRanges.test.ts | 171 ++++ .../test/editorPersistStateLifecycle.test.ts | 120 ++- packages/diffs/test/editorSelection.test.ts | 24 + 20 files changed, 3008 insertions(+), 72 deletions(-) create mode 100644 packages/diffs/src/editor/folding.ts create mode 100644 packages/diffs/test/VirtualizedFile.folding.test.ts create mode 100644 packages/diffs/test/e2e/fixtures/folding.html create mode 100644 packages/diffs/test/e2e/folding.pw.ts create mode 100644 packages/diffs/test/editorFolding.test.ts create mode 100644 packages/diffs/test/editorFoldingRanges.test.ts diff --git a/apps/docs/app/(diffs)/docs/Editor/constants.ts b/apps/docs/app/(diffs)/docs/Editor/constants.ts index d6b538b7d..2e7c93400 100644 --- a/apps/docs/app/(diffs)/docs/Editor/constants.ts +++ b/apps/docs/app/(diffs)/docs/Editor/constants.ts @@ -1053,10 +1053,12 @@ const file: FileContents | undefined = editor.getFile(); // Full document text, or '' when nothing is attached. const text: string = editor.getText(); -// Snapshot selections and scroll position for persistence or remount restore. +// Snapshot selections, active folds, and scroll position for persistence or +// remount restore. const state: EditorState = editor.getState(); // EditorState = { // selections?: EditorSelection[]; +// foldRanges?: LineRange[]; // zero-based; standalone closers stay visible // view?: { scrollLeft: number; scrollTop: number }; // } diff --git a/packages/diffs/src/components/File.ts b/packages/diffs/src/components/File.ts index 22bf92f01..95b2cfdeb 100644 --- a/packages/diffs/src/components/File.ts +++ b/packages/diffs/src/components/File.ts @@ -33,6 +33,7 @@ import type { FileContents, HighlightedToken, LineAnnotation, + LineRange, PostRenderPhase, PrePropertiesConfig, RenderFileMetadata, @@ -176,6 +177,7 @@ export class File< public file: FileContents | undefined; protected renderRange: RenderRange | undefined; protected enabled = true; + protected foldRanges: LineRange[] = []; protected editor: DiffsEditor | undefined; @@ -214,6 +216,33 @@ export class File< return this.file; } + public __setFoldRanges(ranges: LineRange[]): void { + if (!this.updateFoldRanges(ranges)) { + return; + } + if (this.enabled && this.file != null) { + this.rerender(); + } + } + + protected updateFoldRanges(ranges: LineRange[]): boolean { + if ( + ranges.length === this.foldRanges.length && + ranges.every((range, index) => { + const previous = this.foldRanges[index]; + return ( + previous?.startLine === range.startLine && + previous.endLine === range.endLine + ); + }) + ) { + return false; + } + this.foldRanges = ranges.map((range) => ({ ...range })); + this.fileRenderer.setFoldRanges(this.foldRanges); + return true; + } + public onThemeChange(): void { this.fileRenderer.clearRenderCache(); this.rerender(); @@ -373,6 +402,7 @@ export class File< this.workerManager = undefined; this.file = undefined; } + this.foldRanges = []; this.enabled = false; diff --git a/packages/diffs/src/components/VirtualizedFile.ts b/packages/diffs/src/components/VirtualizedFile.ts index e214dfb09..f820e301e 100644 --- a/packages/diffs/src/components/VirtualizedFile.ts +++ b/packages/diffs/src/components/VirtualizedFile.ts @@ -1,8 +1,10 @@ import { DEFAULT_VIRTUAL_FILE_METRICS } from '../constants'; +import { LineRangeIndex } from '../editor/folding'; import type { DiffsTextDocument, FileContents, LineAnnotation, + LineRange, NumericScrollLineAnchor, PendingCodeViewLayoutReset, RenderRange, @@ -83,6 +85,7 @@ export class VirtualizedFile< private layoutDirty = true; private forceRenderOverride: true | undefined; private currentCollapsed: boolean | undefined; + private editorFoldedLineIndex = new LineRangeIndex(); constructor( options: FileOptions | undefined, @@ -135,6 +138,18 @@ export class VirtualizedFile< // If not cached and hasMetadataLine is true, adds lineHeight for the // metadata. public getLineHeight(lineIndex: number, hasMetadataLine = false): number { + if (this.editorFoldedLineIndex.isHidden(lineIndex)) { + return 0; + } + return this.getVisibleLineHeight(lineIndex, hasMetadataLine); + } + + // Read a row height after the caller has already established that the line + // is visible, avoiding another folded-range lookup inside layout scans. + private getVisibleLineHeight( + lineIndex: number, + hasMetadataLine = false + ): number { const cached = this.cache.heights.get(lineIndex); if (cached != null) { return cached; @@ -180,6 +195,30 @@ export class VirtualizedFile< super.setThemeType(themeType); } + public override __setFoldRanges(ranges: LineRange[]): void { + if (!this.updateFoldRanges(ranges)) { + return; + } + + this.editorFoldedLineIndex = new LineRangeIndex(this.foldRanges); + this.forceRenderOverride = true; + this.invalidateEditorFoldingLayout(); + if (this.enabled && this.file != null) { + this.virtualizer.instanceChanged(this, true); + } + } + + // Folding only changes which rows participate in layout. Preserve measured + // wrap/annotation heights and rebuild the derived total and checkpoints. + private invalidateEditorFoldingLayout(): void { + this.layoutDirty = true; + this.cache.checkpoints.length = 0; + this.renderRange = undefined; + if (this.isSimpleMode()) { + this.computeApproximateSize(); + } + } + private resetLayoutCache(recompute = false, resetRenderRange = true): void { this.layoutDirty = true; this.cache.fileAnnotationHeight = 0; @@ -371,6 +410,16 @@ export class VirtualizedFile< top += this.cache.fileAnnotationHeight; if (overflow === 'scroll' && !this.hasLineAnnotations()) { + if (this.foldRanges.length > 0) { + const hiddenBefore = + this.editorFoldedLineIndex.hiddenCountBefore(clampedLineIndex); + return { + top: top + (clampedLineIndex - hiddenBefore) * lineHeight, + height: this.editorFoldedLineIndex.isHidden(clampedLineIndex) + ? 0 + : lineHeight, + }; + } return { top: top + clampedLineIndex * lineHeight, height: lineHeight, @@ -380,12 +429,22 @@ export class VirtualizedFile< const checkpoint = this.getLayoutCheckpointBeforeLineIndex(clampedLineIndex); top = checkpoint?.top ?? top; - for ( - let lineIndex = checkpoint?.lineIndex ?? 0; - lineIndex < clampedLineIndex; - lineIndex++ - ) { - top += this.getLineHeight(lineIndex, false); + let lineIndex = checkpoint?.lineIndex ?? 0; + const lineAfterStartingFold = + this.editorFoldedLineIndex.lineAfterHiddenRange(lineIndex); + if (lineAfterStartingFold != null) { + lineIndex = Math.min(lineAfterStartingFold, clampedLineIndex); + } + let foldedRangeIndex = this.getFoldRangeIndexAtOrAfter(lineIndex); + while (lineIndex < clampedLineIndex) { + const foldedRange = this.foldRanges[foldedRangeIndex]; + if (foldedRange != null && lineIndex >= foldedRange.startLine) { + lineIndex = Math.min(foldedRange.endLine + 1, clampedLineIndex); + foldedRangeIndex++; + continue; + } + top += this.getVisibleLineHeight(lineIndex); + lineIndex++; } return { @@ -441,6 +500,50 @@ export class VirtualizedFile< // multiply our way to the the correct value if (overflow === 'scroll' && !this.hasLineAnnotations()) { const { lineHeight } = this.metrics; + if (this.foldRanges.length > 0) { + const firstVisibleLineIndex = + this.editorFoldedLineIndex.nearestVisibleLine( + firstRenderedLineIndex, + 'down', + lastLineIndex + 1 + ); + const lastVisibleLineIndex = + this.editorFoldedLineIndex.nearestVisibleLine( + lastRenderedLineIndex, + 'up', + lastLineIndex + 1 + ); + if ( + firstVisibleLineIndex == null || + lastVisibleLineIndex == null || + firstVisibleLineIndex > lastVisibleLineIndex + ) { + return undefined; + } + + const firstVisibleIndex = + firstVisibleLineIndex - + this.editorFoldedLineIndex.hiddenCountBefore(firstVisibleLineIndex); + const firstRenderedLineTop = + headerRegion + fileAnnotationHeight + firstVisibleIndex * lineHeight; + const deltaLineCount = Math.max( + Math.ceil((localViewportTop - firstRenderedLineTop) / lineHeight), + 0 + ); + const visibleIndex = firstVisibleIndex + deltaLineCount; + const lineIndex = this.editorFoldedLineIndex.lineAtVisibleIndex( + visibleIndex, + lastLineIndex + 1 + ); + if (lineIndex == null || lineIndex > lastVisibleLineIndex) { + return undefined; + } + + return { + lineNumber: lineIndex + 1, + top: headerRegion + fileAnnotationHeight + visibleIndex * lineHeight, + }; + } const firstRenderedLineTop = headerRegion + (firstRenderedLineIndex === 0 @@ -467,18 +570,28 @@ export class VirtualizedFile< (firstRenderedLineIndex === 0 ? fileAnnotationHeight : this.renderRange.bufferBefore); - for ( - let lineIndex = firstRenderedLineIndex; - lineIndex <= lastRenderedLineIndex; - lineIndex++ - ) { + let lineIndex = firstRenderedLineIndex; + const lineAfterStartingFold = + this.editorFoldedLineIndex.lineAfterHiddenRange(lineIndex); + if (lineAfterStartingFold != null) { + lineIndex = lineAfterStartingFold; + } + let foldedRangeIndex = this.getFoldRangeIndexAtOrAfter(lineIndex); + while (lineIndex <= lastRenderedLineIndex) { + const foldedRange = this.foldRanges[foldedRangeIndex]; + if (foldedRange != null && lineIndex >= foldedRange.startLine) { + lineIndex = foldedRange.endLine + 1; + foldedRangeIndex++; + continue; + } if (top >= localViewportTop) { return { lineNumber: lineIndex + 1, top, }; } - top += this.getLineHeight(lineIndex); + top += this.getVisibleLineHeight(lineIndex); + lineIndex++; } return undefined; @@ -531,6 +644,7 @@ export class VirtualizedFile< } override cleanUp(recycle = false): void { + const recycledFoldedRanges = recycle ? this.foldRanges : []; if (this.fileContainer != null && this.isSimpleMode()) { this.getSimpleVirtualizer()?.disconnect(this.fileContainer); } @@ -539,6 +653,12 @@ export class VirtualizedFile< } this.isSetup = false; super.cleanUp(recycle); + if (recycle && recycledFoldedRanges.length > 0) { + this.foldRanges = recycledFoldedRanges; + this.fileRenderer.setFoldRanges(recycledFoldedRanges); + } else { + this.editorFoldedLineIndex = new LineRangeIndex(); + } } // Compute the approximate size of the file using cached line heights. @@ -579,11 +699,52 @@ export class VirtualizedFile< this.height += this.cache.fileAnnotationHeight; if (overflow === 'scroll' && !this.hasLineAnnotations()) { - this.height += lineCount * lineHeight; + this.height += + this.editorFoldedLineIndex.visibleLineCount(lineCount) * lineHeight; } else { - for (let lineIndex = 0; lineIndex < lineCount; lineIndex++) { - this.addLayoutCheckpoint(lineIndex, this.height); - this.height += this.getLineHeight(lineIndex, false); + const codeRegionTop = this.height; + const measuredHeights = [...this.cache.heights] + .filter( + ([lineIndex]) => + lineIndex >= 0 && + lineIndex < lineCount && + !this.editorFoldedLineIndex.isHidden(lineIndex) + ) + .sort(([leftLineIndex], [rightLineIndex]) => { + return leftLineIndex - rightLineIndex; + }); + let measuredHeightDelta = 0; + for (const [, measuredHeight] of measuredHeights) { + measuredHeightDelta += measuredHeight - lineHeight; + } + + this.height += + this.editorFoldedLineIndex.visibleLineCount(lineCount) * lineHeight + + measuredHeightDelta; + + let measuredHeightIndex = 0; + let measuredHeightDeltaBefore = 0; + for ( + let lineIndex = 0; + lineIndex < lineCount; + lineIndex += LAYOUT_CHECKPOINT_INTERVAL + ) { + let measuredHeight = measuredHeights[measuredHeightIndex]; + while (measuredHeight != null && measuredHeight[0] < lineIndex) { + measuredHeightDeltaBefore += measuredHeight[1] - lineHeight; + measuredHeightIndex++; + measuredHeight = measuredHeights[measuredHeightIndex]; + } + + const visibleLinesBefore = + lineIndex - this.editorFoldedLineIndex.hiddenCountBefore(lineIndex); + this.cache.checkpoints.push({ + lineIndex, + top: + codeRegionTop + + visibleLinesBefore * lineHeight + + measuredHeightDeltaBefore, + }); } } @@ -767,11 +928,24 @@ export class VirtualizedFile< return this.virtualizer.type === 'advanced'; } - private addLayoutCheckpoint(lineIndex: number, top: number): void { - if (lineIndex % LAYOUT_CHECKPOINT_INTERVAL !== 0) { - return; + // Locate the first ordered folded range that can contain or follow a raw + // line. Scans then advance through ranges once and jump collapsed bodies. + private getFoldRangeIndexAtOrAfter(lineIndex: number): number { + let low = 0; + let high = this.foldRanges.length; + while (low < high) { + const middle = low + ((high - low) >> 1); + const range = this.foldRanges[middle]; + if (range == null) { + throw new Error('VirtualizedFile: invalid folded range index'); + } + if (range.endLine < lineIndex) { + low = middle + 1; + } else { + high = middle; + } } - this.cache.checkpoints.push({ lineIndex, top }); + return low; } // Find the nearest sparse layout checkpoint at or before a raw file line. @@ -810,7 +984,8 @@ export class VirtualizedFile< // Render-range scans start from this checkpoint so variable-height files // only replay the nearby measured rows. When `hunkLineCount` is provided, // step backward to a hunk boundary so hooks that depend on grouped lines - // still see a complete hunk. + // still see a complete hunk. Folded files use visible-row hunk indexes, so + // they resume at the nearest raw checkpoint and derive the missing offset. private getLayoutCheckpointBeforeTop( top: number, hunkLineCount?: number @@ -833,7 +1008,7 @@ export class VirtualizedFile< } } - if (hunkLineCount == null) { + if (hunkLineCount == null || this.foldRanges.length > 0) { return resultIndex >= 0 ? this.cache.checkpoints[resultIndex] : undefined; } @@ -875,6 +1050,10 @@ export class VirtualizedFile< const { disableFileHeader = false, overflow = 'scroll' } = this.options; const { hunkLineCount, lineHeight } = this.metrics; const lineCount = this.fileRenderer.getLineCount(file); + const hasEditorFolds = this.foldRanges.length > 0; + const visibleLineCount = hasEditorFolds + ? this.editorFoldedLineIndex.visibleLineCount(lineCount) + : lineCount; const fileHeight = this.height; const headerRegion = getVirtualFileHeaderRegion( this.metrics, @@ -951,21 +1130,61 @@ export class VirtualizedFile< // Calculate ideal start centered around viewport const idealStartHunk = centerHunk - Math.floor(totalHunks / 2); - const totalHunksInFile = Math.ceil(lineCount / hunkLineCount); - const startingLine = + const totalHunksInFile = Math.ceil(visibleLineCount / hunkLineCount); + const startingVisibleLine = Math.max(0, Math.min(idealStartHunk, totalHunksInFile)) * hunkLineCount; - const clampedTotalLines = + const clampedVisibleLines = idealStartHunk < 0 ? totalLines + idealStartHunk * hunkLineCount : totalLines; + if (hasEditorFolds) { + const renderedVisibleLines = Math.max( + 0, + Math.min(clampedVisibleLines, visibleLineCount - startingVisibleLine) + ); + const startingLine = this.editorFoldedLineIndex.lineAtVisibleIndex( + startingVisibleLine, + lineCount + ); + const finalLine = + renderedVisibleLines > 0 + ? this.editorFoldedLineIndex.lineAtVisibleIndex( + startingVisibleLine + renderedVisibleLines - 1, + lineCount + ) + : undefined; + if (startingLine == null || finalLine == null) { + return { + startingLine: 0, + totalLines: 0, + bufferBefore: 0, + bufferAfter: codeRowsHeight, + }; + } + return { + startingLine, + totalLines: finalLine - startingLine + 1, + bufferBefore: + startingVisibleLine === 0 + ? 0 + : fileAnnotationHeight + startingVisibleLine * lineHeight, + bufferAfter: Math.max( + 0, + (visibleLineCount - startingVisibleLine - renderedVisibleLines) * + lineHeight + ), + }; + } + + const startingLine = startingVisibleLine; const bufferBefore = startingLine === 0 ? 0 : fileAnnotationHeight + startingLine * lineHeight; const renderedLines = Math.min( - clampedTotalLines, + clampedVisibleLines, lineCount - startingLine ); const bufferAfter = Math.max( @@ -975,7 +1194,7 @@ export class VirtualizedFile< return { startingLine, - totalLines: clampedTotalLines, + totalLines: clampedVisibleLines, bufferBefore, bufferAfter, }; @@ -993,17 +1212,28 @@ export class VirtualizedFile< ); let absoluteLineTop = fileTop + (checkpoint?.top ?? codeRegionTop); - let currentLine = checkpoint?.lineIndex ?? 0; + let currentLine = hasEditorFolds + ? (checkpoint?.lineIndex ?? 0) - + this.editorFoldedLineIndex.hiddenCountBefore(checkpoint?.lineIndex ?? 0) + : (checkpoint?.lineIndex ?? 0); let firstVisibleHunk: number | undefined; let centerHunk: number | undefined; let overflowCounter: number | undefined; - const startingLineIndex = checkpoint?.lineIndex ?? 0; - for ( - let lineIndex = startingLineIndex; - lineIndex < lineCount; - lineIndex++ - ) { + let lineIndex = checkpoint?.lineIndex ?? 0; + const lineAfterStartingFold = + this.editorFoldedLineIndex.lineAfterHiddenRange(lineIndex); + if (lineAfterStartingFold != null) { + lineIndex = lineAfterStartingFold; + } + let foldedRangeIndex = this.getFoldRangeIndexAtOrAfter(lineIndex); + while (lineIndex < lineCount) { + const foldedRange = this.foldRanges[foldedRangeIndex]; + if (foldedRange != null && lineIndex >= foldedRange.startLine) { + lineIndex = foldedRange.endLine + 1; + foldedRangeIndex++; + continue; + } const isAtHunkBoundary = currentLine % hunkLineCount === 0; const currentHunk = Math.floor(currentLine / hunkLineCount); @@ -1018,15 +1248,18 @@ export class VirtualizedFile< } } - const lineHeight = this.getLineHeight(lineIndex, false); + const visibleLineHeight = this.getVisibleLineHeight(lineIndex); // Track visible region - if (absoluteLineTop > top - lineHeight && absoluteLineTop < bottom) { + if ( + absoluteLineTop > top - visibleLineHeight && + absoluteLineTop < bottom + ) { firstVisibleHunk ??= currentHunk; } // Track which hunk contains the viewport center - if (absoluteLineTop + lineHeight > viewportCenter) { + if (absoluteLineTop + visibleLineHeight > viewportCenter) { centerHunk ??= currentHunk; } @@ -1040,7 +1273,8 @@ export class VirtualizedFile< } currentLine++; - absoluteLineTop += lineHeight; + absoluteLineTop += visibleLineHeight; + lineIndex++; } // No visible lines found @@ -1066,19 +1300,48 @@ export class VirtualizedFile< // startHunk back const maxStartHunk = Math.max( 0, - Math.ceil(lineCount / hunkLineCount) - totalHunks + Math.ceil(visibleLineCount / hunkLineCount) - totalHunks ); const startHunk = Math.max(0, Math.min(idealStartHunk, maxStartHunk)); - const startingLine = startHunk * hunkLineCount; + const startingVisibleLine = startHunk * hunkLineCount; + const startingLine = hasEditorFolds + ? (this.editorFoldedLineIndex.lineAtVisibleIndex( + startingVisibleLine, + lineCount + ) ?? lineCount) + : startingVisibleLine; // If we wanted to start before 0, reduce totalLines by the clamped amount const clampedTotalLines = idealStartHunk < 0 ? totalLines + idealStartHunk * hunkLineCount : totalLines; + let rawTotalLines = clampedTotalLines; + if (hasEditorFolds) { + const renderedVisibleLines = Math.max( + 0, + Math.min(clampedTotalLines, visibleLineCount - startingVisibleLine) + ); + const finalLine = + renderedVisibleLines > 0 + ? this.editorFoldedLineIndex.lineAtVisibleIndex( + startingVisibleLine + renderedVisibleLines - 1, + lineCount + ) + : undefined; + rawTotalLines = finalLine == null ? 0 : finalLine - startingLine + 1; + } // Use hunkOffsets array for efficient buffer calculations - const codeBufferBefore = hunkOffsets[startHunk] ?? 0; + const codeBufferBefore = + hunkOffsets[startHunk] ?? + (hasEditorFolds && startingLine < lineCount + ? Math.max( + 0, + (this.getLinePosition(startingLine + 1)?.top ?? codeRegionTop) - + codeRegionTop + ) + : 0); const bufferBefore = startingLine === 0 ? 0 : fileAnnotationHeight + codeBufferBefore; @@ -1091,7 +1354,7 @@ export class VirtualizedFile< return { startingLine, - totalLines: clampedTotalLines, + totalLines: rawTotalLines, bufferBefore, bufferAfter: Math.max(0, bufferAfter), }; diff --git a/packages/diffs/src/editor/editor.css b/packages/diffs/src/editor/editor.css index a65a8bf78..44101e007 100644 --- a/packages/diffs/src/editor/editor.css +++ b/packages/diffs/src/editor/editor.css @@ -65,6 +65,108 @@ box-shadow: inset 0 0 0 1px var(--diffs-editor-line-highlight-border); } +[data-code][data-editor-folding] { + --diffs-editor-fold-gap: 4px; + --diffs-editor-fold-width: 16px; +} + +[data-code][data-editor-folding] [data-column-number] { + position: relative; + padding-right: calc( + var(--diffs-editor-fold-width) + var(--diffs-editor-fold-gap) + ); +} + +[data-file][data-disable-line-numbers] + [data-code][data-editor-folding] + [data-column-number] { + min-width: var(--diffs-editor-fold-width); + padding-right: var(--diffs-editor-fold-width); +} + +[data-fold] { + position: absolute; + top: 0; + right: 0; + width: var(--diffs-editor-fold-width); + height: 1lh; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + z-index: 2; +} + +[data-fold-toggle] { + all: unset; + box-sizing: border-box; + width: var(--diffs-editor-fold-width); + height: 1lh; + display: flex; + align-items: center; + justify-content: center; + color: var(--diffs-fg-number); + cursor: pointer; + opacity: 0; + pointer-events: auto; + transition: opacity 0.2s ease; +} + +[data-fold-toggle][data-folded], +[data-gutter]:hover [data-fold-toggle], +[data-fold]:hover [data-fold-toggle], +[data-fold-toggle]:focus-visible { + opacity: 0.5; +} + +[data-fold] [data-fold-toggle]:hover, +[data-fold] [data-fold-toggle]:focus-visible { + opacity: 0.75; +} + +[data-fold] [data-fold-toggle]:focus-visible { + outline: 1px solid currentColor; + outline-offset: -2px; +} + +[data-fold-indicator] { + display: inline-flex; + align-items: center; + height: 1lh; + margin-inline-start: 1ch; + vertical-align: top; + color: var(--diffs-fg); + user-select: none; +} + +[data-fold-ellipsis] { + all: unset; + box-sizing: border-box; + width: 18px; + height: calc(1lh - 4px); + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 4px; + color: var(--diffs-fg-number); + background-color: color-mix(in lab, var(--diffs-fg) 8%, transparent); + cursor: pointer; + transition: + color 80ms ease, + background-color 80ms ease; +} + +[data-fold-ellipsis]:hover, +[data-fold-ellipsis]:focus-visible { + color: var(--diffs-fg); + background-color: color-mix(in lab, var(--diffs-fg) 14%, transparent); +} + +[data-fold-ellipsis]:focus-visible { + outline: 1px solid currentColor; + outline-offset: 1px; +} + [data-editor-overlay] { display: contents; } diff --git a/packages/diffs/src/editor/editor.ts b/packages/diffs/src/editor/editor.ts index 64c2b4105..6ffd1ef81 100644 --- a/packages/diffs/src/editor/editor.ts +++ b/packages/diffs/src/editor/editor.ts @@ -14,6 +14,7 @@ import type { FileDiffMetadata, HighlightedToken, LineAnnotation, + LineRange, Position, Range, RenderRange, @@ -29,6 +30,12 @@ import { } from './command'; import editorCSS from './editor.css?inline'; import { EditStack } from './editStack'; +import { + computeIndentFoldingRanges, + isFoldingClosingDelimiter, + LineRangeIndex, + mergeHiddenLineRanges, +} from './folding'; import { type LanguageConfigMap, resolveBlockCommentEdits, @@ -103,7 +110,7 @@ import { type SelectionActionContext, SelectionActionWidget, } from './selectionAction'; -import { createSpriteElement } from './sprite'; +import { createSpriteElement, getEditorIconSvg } from './sprite'; import { cloneEditorState, createStateStorage, @@ -200,6 +207,8 @@ export interface EditorOptions { roundedSelection?: boolean; /** Highlight matching brackets near the caret, default is true. */ matchBrackets?: boolean; + /** Show code-fold controls for attached file components, default is true. */ + folding?: boolean; /** * Controls auto-surround when typing quotes or brackets over a selection. * Default is `"default"` (both quotes and brackets). @@ -273,6 +282,7 @@ export class Editor implements DiffsEditor { cacheKey: string; textDocument: TextDocument; documentVersion: number; + foldStateVersion: number; selections: EditorSelection[] | undefined; view: EditorState['view']; completion: Promise; @@ -313,6 +323,7 @@ export class Editor implements DiffsEditor { #themeStyleElement?: HTMLStyleElement; #spriteElement?: SVGSVGElement; #fileContainer?: HTMLElement; + #codeElement?: HTMLElement; #gutterElement?: HTMLElement; #contentElement?: HTMLElement; #overlayElement?: HTMLElement; @@ -374,6 +385,23 @@ export class Editor implements DiffsEditor { #retainSearchPanelFocus = false; #fontRemeasureScheduled = false; #themeSelectionRefreshFrame?: number; + #foldRanges: LineRange[] = []; + #foldRangesByStart = new Map(); + #foldEndLines = new Set(); + #foldClosingDelimiterLines = new Set(); + #foldedStartLines = new Set(); + #hiddenLineRanges: LineRange[] = []; + #hiddenLineIndex = new LineRangeIndex(); + #hiddenLineRangeVersion = 0; + #foldStateVersion = 0; + #syncedFoldRangeHost?: DiffsEditableComponent; + #syncedFoldRangeVersion = -1; + #syncedFoldingEnabled?: boolean; + #foldRangeDocument?: TextDocument; + #foldRangeVersion = -1; + #renderViewGeneration = 0; + #pendingFoldFocus?: { line: number; generation: number }; + #hasFoldIndicators = false; #onDeferTokenize = ( lines: Map>, @@ -395,7 +423,7 @@ export class Editor implements DiffsEditor { if (line >= startingLine && line < endLine) { const lineElement = this.#getLineElement(line); if (lineElement !== undefined) { - lineElement.replaceChildren(...renderLineTokens(tokens)); + this.#replaceLineTokens(lineElement, tokens); } } } @@ -407,6 +435,7 @@ export class Editor implements DiffsEditor { } setOptions(options: EditorOptions): void { + const wasFoldingRequested = this.#options.folding !== false; const previousStorageOption = this.#options.persistStateStorage ?? 'inMemory'; const nextOptions = { @@ -437,6 +466,9 @@ export class Editor implements DiffsEditor { this.#stateStorageOption = undefined; this.#pendingStateWrites.clear(); } + if (wasFoldingRequested !== (this.#options.folding !== false)) { + this.#handleFoldingOptionChange(); + } } // Small typescript hack to prevent UnresolvedFile from being editable. @@ -458,6 +490,12 @@ export class Editor implements DiffsEditor { }); fileInstance.rerender(); } + if ( + fileInstance.type !== 'file' || + fileInstance.__setFoldRanges === undefined + ) { + this.#resetFoldingState(); + } this.#fileInstance = fileInstance; this.#initialize(); this.#detach = fileInstance.attachEditor(this); @@ -479,6 +517,7 @@ export class Editor implements DiffsEditor { if (textDocument == null) { throw new Error('Editor is not attached'); } + this.#unfoldFoldsIntersectingRanges(edits.map((edit) => edit.range)); // Only reposition focus and scroll when the editor already holds focus. A // programmatic edit must not pull focus from another input the user is // typing in; the selection state below is re-anchored either way. @@ -580,8 +619,17 @@ export class Editor implements DiffsEditor { getState(): EditorState { const scrollContainer = this.#fileInstance?.getScrollContainer?.(); + const foldRanges: LineRange[] = []; + if (this.#isFoldingEnabled && this.#foldedStartLines.size > 0) { + for (const range of this.#foldRanges) { + if (this.#foldedStartLines.has(range.startLine)) { + foldRanges.push({ ...range }); + } + } + } return { selections: this.#selections, + foldRanges, view: scrollContainer !== undefined ? { @@ -592,11 +640,16 @@ export class Editor implements DiffsEditor { }; } - setState({ selections, view }: EditorState): void { + setState({ selections, foldRanges, view }: EditorState): void { if (this.#fileInstance === undefined || this.#textDocument === undefined) { throw new Error('Editor is not attached'); } this.#canMountSelectionAction = false; + this.#restoreFoldRanges(foldRanges ?? []); + const primarySelection = selections?.at(-1); + if (primarySelection !== undefined) { + this.#revealLineIfCollapsed(getCaretPosition(primarySelection).line); + } this.#updateSelections(selections ?? []); // When a saved view is present, honor its scroll offsets exactly. Scrolling // the caret into view afterward would overwrite them whenever the caret @@ -704,7 +757,8 @@ export class Editor implements DiffsEditor { if (!recycle) { this.#attachState.delivered = false; } - const hadFileInstance = this.#fileInstance != null; + const fileInstance = this.#fileInstance; + const hadFileInstance = fileInstance != null; const shouldRestoreState = this.#isStatePersistenceEnabled; this.#stateRestoreGeneration++; this.#persistCurrentState(); @@ -737,8 +791,18 @@ export class Editor implements DiffsEditor { this.#editorEventDisposes = undefined; this.#selectEventDisposes?.forEach((dispose) => dispose()); this.#selectEventDisposes = undefined; + this.#removeFoldingControls(); this.#detach?.(recycle); this.#detach = undefined; + if (!recycle) { + fileInstance?.__setFoldRanges?.([]); + this.#resetFoldingState(); + } + this.#fileInstance = undefined; + this.#pendingFoldFocus = undefined; + this.#syncedFoldRangeHost = undefined; + this.#syncedFoldRangeVersion = -1; + this.#syncedFoldingEnabled = undefined; // cache this.#gutterWidthCache = undefined; @@ -758,6 +822,7 @@ export class Editor implements DiffsEditor { this.#spriteElement?.remove(); this.#spriteElement = undefined; this.#fileContainer = undefined; + this.#codeElement = undefined; this.#popoverManager?.cleanUp(); this.#popoverManager = undefined; this.#gutterElement = undefined; @@ -831,6 +896,7 @@ export class Editor implements DiffsEditor { if (fileInstance == null) { return; } + this.#renderViewGeneration++; const shadowRoot = fileContainer.shadowRoot; if (shadowRoot == null) { console.error('[editor] Could not find the shadow root.'); @@ -858,6 +924,7 @@ export class Editor implements DiffsEditor { if (codeElement === undefined || contentEl === undefined) { return; } + this.#codeElement = codeElement; this.#getPopoverManager().setViewportElements(fileContainer, codeElement); @@ -921,6 +988,7 @@ export class Editor implements DiffsEditor { new TextDocument(fileOrDiff.name, contents, languageId, 0, editStack); this.#fileInfo = { name, lang, cacheKey }; this.#textDocument = textDocument; + this.#resetFoldingState(); if (persistedCacheKey !== undefined) { this.#textDocumentCache.set(persistedCacheKey, textDocument); persistedStateTarget = { @@ -1032,6 +1100,9 @@ export class Editor implements DiffsEditor { } } + this.#refreshFoldingRanges(); + this.#syncFoldedRangesToHost(); + this.#renderFoldingControls(); this.#resetCache(); // The tokenizer is created once per attached document and reused across @@ -1163,6 +1234,7 @@ export class Editor implements DiffsEditor { this.#pendingStateRestore = undefined; if ( textDocument.version === pendingRestore.documentVersion && + this.#foldStateVersion === pendingRestore.foldStateVersion && this.#selections === pendingRestore.selections && state.view?.scrollLeft === pendingRestore.view?.scrollLeft && state.view?.scrollTop === pendingRestore.view?.scrollTop @@ -1225,6 +1297,7 @@ export class Editor implements DiffsEditor { ): void { const generation = ++this.#stateRestoreGeneration; const documentVersion = textDocument.version; + const foldStateVersion = this.#foldStateVersion; const selections = this.#selections; const view = this.getState().view; const applyState = (state: EditorState | undefined): void => { @@ -1234,6 +1307,7 @@ export class Editor implements DiffsEditor { generation !== this.#stateRestoreGeneration || this.#textDocument !== textDocument || textDocument.version !== documentVersion || + this.#foldStateVersion !== foldStateVersion || this.#selections !== selections || currentView?.scrollLeft !== view?.scrollLeft || currentView?.scrollTop !== view?.scrollTop || @@ -1268,6 +1342,7 @@ export class Editor implements DiffsEditor { cacheKey, textDocument, documentVersion, + foldStateVersion, selections, view, completion: result.catch(() => {}), @@ -1281,28 +1356,700 @@ export class Editor implements DiffsEditor { } } - // Whether a zero-based document line has (or will have on scroll) a - // rendered row. False only for lines hidden inside a collapsed unchanged - // region of a diff host; hosts without collapsible regions treat every - // line as renderable. + get #isFoldingEnabled(): boolean { + return ( + this.#options.folding !== false && + this.#fileInstance?.type === 'file' && + this.#fileInstance.__setFoldRanges !== undefined + ); + } + + #resetFoldingState(): void { + this.#foldRanges = []; + this.#foldRangesByStart.clear(); + this.#foldEndLines.clear(); + this.#foldClosingDelimiterLines.clear(); + this.#foldedStartLines.clear(); + this.#hiddenLineRanges = []; + this.#hiddenLineIndex = new LineRangeIndex(); + this.#hiddenLineRangeVersion++; + this.#foldStateVersion++; + this.#foldRangeDocument = undefined; + this.#foldRangeVersion = -1; + } + + #setHiddenLineRanges(ranges: LineRange[]): boolean { + if ( + ranges.length === this.#hiddenLineRanges.length && + ranges.every((range, index) => { + const previous = this.#hiddenLineRanges[index]; + return ( + previous?.startLine === range.startLine && + previous.endLine === range.endLine + ); + }) + ) { + return false; + } + this.#hiddenLineRanges = ranges; + this.#hiddenLineIndex = new LineRangeIndex(ranges); + this.#hiddenLineRangeVersion++; + return true; + } + + // Fold candidates are cached by document version and computed in one pass. + // Active folded headers stay separate so nested state survives an outer + // fold being toggled off. + #refreshFoldingRanges(force = false): boolean { + const textDocument = this.#textDocument; + if (!this.#isFoldingEnabled || textDocument === undefined) { + return false; + } + if ( + !force && + this.#foldRangeDocument === textDocument && + this.#foldRangeVersion === textDocument.version + ) { + return false; + } + + const hadFoldedRanges = this.#foldedStartLines.size > 0; + this.#foldRanges = computeIndentFoldingRanges( + textDocument, + this.#metrics.tabSize + ); + const rangesByStart = new Map(); + const endLines = new Set(); + const closingDelimiterLines = new Set(); + for (const range of this.#foldRanges) { + rangesByStart.set(range.startLine, range); + endLines.add(range.endLine); + const closingLine = range.endLine + 1; + if ( + closingLine < textDocument.lineCount && + isFoldingClosingDelimiter(textDocument.getLineText(closingLine)) + ) { + closingDelimiterLines.add(closingLine); + } + } + this.#foldRangesByStart = rangesByStart; + this.#foldEndLines = endLines; + this.#foldClosingDelimiterLines = closingDelimiterLines; + for (const startLine of this.#foldedStartLines) { + if (!this.#foldRangesByStart.has(startLine)) { + this.#foldedStartLines.delete(startLine); + } + } + if (hadFoldedRanges) { + this.#foldStateVersion++; + } + this.#foldRangeDocument = textDocument; + this.#foldRangeVersion = textDocument.version; + return this.#setHiddenLineRanges( + mergeHiddenLineRanges(this.#foldRanges, this.#foldedStartLines) + ); + } + + #restoreFoldRanges(foldRanges: readonly LineRange[]): void { + if (!this.#isFoldingEnabled) { + return; + } + this.#refreshFoldingRanges(); + + const nextFoldedStartLines = new Set(); + for (const range of foldRanges) { + if ( + range == null || + !Number.isInteger(range.startLine) || + !Number.isInteger(range.endLine) + ) { + continue; + } + const candidate = this.#foldRangesByStart.get(range.startLine); + if (candidate?.endLine === range.endLine) { + nextFoldedStartLines.add(range.startLine); + } + } + + let changed = nextFoldedStartLines.size !== this.#foldedStartLines.size; + if (!changed) { + for (const startLine of nextFoldedStartLines) { + if (!this.#foldedStartLines.has(startLine)) { + changed = true; + break; + } + } + } + if (!changed) { + return; + } + + this.#foldedStartLines = nextFoldedStartLines; + this.#foldStateVersion++; + this.#refreshFoldedView(); + } + + #syncFoldedRangesToHost(): void { + const host = this.#fileInstance; + if (host?.type !== 'file' || host.__setFoldRanges === undefined) { + return; + } + const foldingEnabled = this.#isFoldingEnabled; + if ( + this.#syncedFoldRangeHost === host && + this.#syncedFoldRangeVersion === this.#hiddenLineRangeVersion && + this.#syncedFoldingEnabled === foldingEnabled + ) { + return; + } + host.__setFoldRanges(foldingEnabled ? this.#hiddenLineRanges : []); + this.#syncedFoldRangeHost = host; + this.#syncedFoldRangeVersion = this.#hiddenLineRangeVersion; + this.#syncedFoldingEnabled = foldingEnabled; + } + + #removeFoldingControls(): void { + this.#codeElement?.removeAttribute('data-editor-folding'); + this.#gutterElement + ?.querySelectorAll('[data-fold]') + .forEach((element) => element.remove()); + if (this.#hasFoldIndicators) { + this.#contentElement + ?.querySelectorAll('[data-fold-indicator]') + .forEach((element) => element.remove()); + this.#hasFoldIndicators = false; + } + } + + #initializeFoldButton(button: HTMLButtonElement): void { + button.addEventListener('pointerdown', (event) => { + event.preventDefault(); + event.stopPropagation(); + }); + button.addEventListener('keydown', (event) => { + if (event.key !== 'Escape') { + event.stopPropagation(); + } + }); + button.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + const lineIndex = Number( + button.closest('[data-line-index]')?.dataset.lineIndex + ); + if (Number.isInteger(lineIndex)) { + this.#toggleFold(lineIndex, event.detail === 0); + } + }); + } + + #renderFoldingControls(): void { + const codeElement = this.#codeElement; + const gutterElement = this.#gutterElement; + const contentElement = this.#contentElement; + const textDocument = this.#textDocument; + if ( + !this.#isFoldingEnabled || + codeElement === undefined || + gutterElement === undefined || + contentElement === undefined || + textDocument === undefined + ) { + this.#removeFoldingControls(); + return; + } + + codeElement.setAttribute('data-editor-folding', ''); + const foldedLineElements = new Map(); + if (this.#foldedStartLines.size === 0) { + if (this.#hasFoldIndicators) { + contentElement + .querySelectorAll('[data-fold-indicator]') + .forEach((element) => element.remove()); + this.#hasFoldIndicators = false; + } + } else { + let hasFoldIndicators = false; + for (const child of contentElement.children) { + if (!(child instanceof HTMLElement)) { + continue; + } + const lineIndex = Number(child.dataset.lineIndex); + const isFolded = + Number.isInteger(lineIndex) && + this.#foldedStartLines.has(lineIndex) && + this.#foldRangesByStart.has(lineIndex); + const indicator = child.querySelector( + ':scope > [data-fold-indicator]' + ); + if (!isFolded) { + indicator?.remove(); + continue; + } + foldedLineElements.set(lineIndex, child); + hasFoldIndicators ||= indicator !== null; + } + this.#hasFoldIndicators = hasFoldIndicators; + } + + for (const child of gutterElement.children) { + if (!(child instanceof HTMLElement)) { + continue; + } + const lineIndex = Number(child.dataset.lineIndex); + const existingZone = child.querySelector( + ':scope > [data-fold]' + ); + if ( + child.dataset.columnNumber === undefined || + !Number.isInteger(lineIndex) + ) { + existingZone?.remove(); + continue; + } + + const range = this.#foldRangesByStart.get(lineIndex); + if (range === undefined) { + existingZone?.remove(); + continue; + } + + const folded = this.#foldedStartLines.has(lineIndex); + const zone = existingZone ?? h('span', { dataset: 'fold' }, child); + let button = zone.querySelector( + ':scope > [data-fold-toggle]' + ); + let buttonCreated = false; + if (button === null) { + button = h( + 'button', + { + dataset: 'foldToggle', + type: 'button', + }, + zone + ); + buttonCreated = true; + this.#initializeFoldButton(button); + } + + const wasFolded = button.dataset.folded !== undefined; + button.ariaExpanded = folded ? 'false' : 'true'; + button.ariaLabel = `${folded ? 'Unfold' : 'Fold'} line ${lineIndex + 1}`; + button.title = folded ? 'Unfold' : 'Fold'; + button.toggleAttribute('data-folded', folded); + if (buttonCreated || wasFolded !== folded) { + button.innerHTML = getEditorIconSvg( + folded ? 'chevron-right' : 'chevron-down', + 14 + ); + } + + if (!folded) { + continue; + } + + const lineElement = foldedLineElements.get(lineIndex); + if (lineElement === undefined) { + continue; + } + + const indicator = + lineElement.querySelector( + ':scope > [data-fold-indicator]' + ) ?? + h( + 'span', + { + dataset: 'foldIndicator', + contentEditable: 'false', + spellcheck: false, + }, + lineElement + ); + this.#hasFoldIndicators = true; + indicator.setAttribute('contenteditable', 'false'); + indicator.dataset.foldCharacter = textDocument + .getLineLength(lineIndex) + .toString(); + let ellipsis = indicator.querySelector( + ':scope > [data-fold-ellipsis]' + ); + if (ellipsis === null) { + ellipsis = h('button', { + dataset: 'foldEllipsis', + type: 'button', + innerHTML: getEditorIconSvg('ellipsis', 12), + }); + indicator.prepend(ellipsis); + this.#initializeFoldButton(ellipsis); + } + ellipsis.ariaLabel = `Unfold line ${lineIndex + 1}`; + ellipsis.title = 'Unfold'; + } + this.#restorePendingFoldFocus(); + } + + #refreshFoldedView(updateHiddenLineRanges = true): void { + if (updateHiddenLineRanges) { + this.#setHiddenLineRanges( + mergeHiddenLineRanges(this.#foldRanges, this.#foldedStartLines) + ); + } + this.#syncFoldedRangesToHost(); + this.#resetCache(); + this.#renderFoldingControls(); + if (this.#selections !== undefined) { + this.#updateSelections(this.#selections); + } + this.#markerRenderer?.removePopover(); + } + + #toggleFold(startLine: number, restoreFocus = false): void { + this.#refreshFoldingRanges(); + const range = this.#foldRangesByStart.get(startLine); + const textDocument = this.#textDocument; + if (range === undefined || textDocument === undefined) { + return; + } + if (restoreFocus) { + this.#pendingFoldFocus = { + line: startLine, + generation: this.#renderViewGeneration + 1, + }; + } + + if (this.#foldedStartLines.has(startLine)) { + this.#foldedStartLines.delete(startLine); + } else { + this.#foldedStartLines.add(startLine); + } + this.#foldStateVersion++; + this.#setHiddenLineRanges( + mergeHiddenLineRanges(this.#foldRanges, this.#foldedStartLines) + ); + + // A selection whose caret is swallowed by the new fold maps to the end of + // its header, matching the display-map behavior of native editors. + if (this.#selections !== undefined) { + let didMoveCaret = false; + const nextSelections = this.#selections.map( + (selection) => { + if ( + !this.#hiddenLineIndex.isHidden(getCaretPosition(selection).line) + ) { + return selection; + } + didMoveCaret = true; + const caret = { + line: startLine, + character: textDocument.getLineLength(startLine), + }; + return { start: caret, end: caret, direction: DirectionNone }; + } + ); + if (didMoveCaret) { + this.#selections = nextSelections; + } + } + + this.#refreshFoldedView(false); + } + + #restorePendingFoldFocus(): void { + const pending = this.#pendingFoldFocus; + if ( + pending === undefined || + this.#renderViewGeneration < pending.generation + ) { + return; + } + const toggle = this.#gutterElement?.querySelector( + `[data-line-index="${pending.line}"] [data-fold-toggle]` + ); + if (toggle?.isConnected !== true) { + return; + } + toggle.focus({ preventScroll: true }); + this.#pendingFoldFocus = undefined; + } + + #unfoldFoldsIntersectingRanges(ranges: readonly Range[]): void { + if ( + !this.#isFoldingEnabled || + this.#foldedStartLines.size === 0 || + this.#textDocument === undefined + ) { + return; + } + const normalizedRanges = ranges.map((range) => { + const start = this.#textDocument!.normalizePosition(range.start); + const end = this.#textDocument!.normalizePosition(range.end); + return { + startLine: Math.min(start.line, end.line), + endLine: Math.max(start.line, end.line), + }; + }); + let changed = false; + for (const startLine of this.#foldedStartLines) { + const foldRange = this.#foldRangesByStart.get(startLine); + if ( + foldRange !== undefined && + normalizedRanges.some( + (range) => + range.endLine >= foldRange.startLine + 1 && + range.startLine <= foldRange.endLine + ) + ) { + this.#foldedStartLines.delete(startLine); + changed = true; + } + } + if (changed) { + this.#foldStateVersion++; + this.#refreshFoldedView(); + } + } + + #unfoldLine(line: number): boolean { + if (!this.#isFoldingEnabled) { + return false; + } + let changed = false; + for (const startLine of this.#foldedStartLines) { + const range = this.#foldRangesByStart.get(startLine); + if ( + range !== undefined && + line > range.startLine && + line <= range.endLine + ) { + this.#foldedStartLines.delete(startLine); + changed = true; + } + } + if (changed) { + this.#foldStateVersion++; + this.#refreshFoldedView(); + } + return changed; + } + + #remapFoldingAfterChange(change: TextDocumentChange): boolean { + if (!this.#isFoldingEnabled) { + return false; + } + + const shouldRefreshRanges = this.#changeMayAffectFolding(change); + this.#remapFoldedRangesAfterChange(change); + + if (shouldRefreshRanges) { + this.#foldRangeVersion = -1; + this.#refreshFoldingRanges(true); + } else if (this.#textDocument !== undefined) { + this.#foldRangeDocument = this.#textDocument; + this.#foldRangeVersion = this.#textDocument.version; + } + return shouldRefreshRanges; + } + + // Remap active ranges through each line-changing edit in document order. + // Aggregate change bounds are insufficient for multi-edit batches because + // folds between edits can stay valid even when the batch has a net delta. + #remapFoldedRangesAfterChange(change: TextDocumentChange): void { + const lineChanges = + change.changedLineChanges ?? + ([[change.startLine, change.endLine, change.lineDelta]] as const); + if ( + this.#foldedStartLines.size === 0 || + !lineChanges.some(([, , lineDelta]) => lineDelta !== 0) + ) { + return; + } + + let activeRanges = [...this.#foldedStartLines] + .map((startLine) => this.#foldRangesByStart.get(startLine)) + .filter((range): range is LineRange => range !== undefined) + .map((range) => ({ ...range })); + + for (const [ + startLine, + endLine, + lineDelta, + startCharacter, + endCharacter, + ] of lineChanges) { + if (lineDelta === 0) { + continue; + } + const oldEndLine = Math.max(startLine, endLine - lineDelta); + const insertsBeforeLine = + lineDelta > 0 && startCharacter === 0 && endCharacter === 0; + const deletesBeforeLine = lineDelta < 0 && endCharacter === 0; + const nextRanges: LineRange[] = []; + for (const range of activeRanges) { + if (range.endLine < startLine) { + nextRanges.push(range); + } else if ( + range.startLine > oldEndLine || + (insertsBeforeLine && range.startLine >= startLine) || + (deletesBeforeLine && range.startLine === oldEndLine) + ) { + nextRanges.push({ + startLine: range.startLine + lineDelta, + endLine: range.endLine + lineDelta, + }); + } + } + activeRanges = nextRanges; + } + + const nextFoldedStartLines = new Set( + activeRanges.map((range) => range.startLine) + ); + let changed = nextFoldedStartLines.size !== this.#foldedStartLines.size; + if (!changed) { + for (const startLine of nextFoldedStartLines) { + if (!this.#foldedStartLines.has(startLine)) { + changed = true; + break; + } + } + } + if (changed) { + this.#foldedStartLines = nextFoldedStartLines; + this.#foldStateVersion++; + } + } + + // Ordinary character edits cannot change indentation folds. Re-scan only + // when an edit touches indentation, blank headers/ends, closing delimiters, + // or the document's line structure. + #changeMayAffectFolding(change: TextDocumentChange): boolean { + const textDocument = this.#textDocument; + if (textDocument === undefined || change.lineDelta !== 0) { + return true; + } + + const lineChanges = + change.changedLineChanges ?? + ([ + [ + change.startLine, + change.endLine, + change.lineDelta, + change.startCharacter, + change.endCharacter, + ], + ] as const); + for (const [ + startLine, + endLine, + lineDelta, + startCharacter = 0, + endCharacter = startCharacter, + ] of lineChanges) { + if (lineDelta !== 0) { + return true; + } + for (let line = startLine; line <= endLine; line++) { + const text = textDocument.getLineText(line); + const indentationLength = text.length - text.trimStart().length; + const trimmedText = text.trim(); + if ( + startCharacter <= indentationLength || + endCharacter <= indentationLength || + this.#foldClosingDelimiterLines.has(line) || + isFoldingClosingDelimiter(trimmedText) || + (trimmedText.length === 0 && + (this.#foldRangesByStart.has(line) || this.#foldEndLines.has(line))) + ) { + return true; + } + } + } + return false; + } + + #handleFoldingOptionChange(): void { + if (this.#isFoldingEnabled) { + this.#foldRangeVersion = -1; + this.#refreshFoldingRanges(true); + } else { + if (this.#foldedStartLines.size > 0) { + this.#foldStateVersion++; + } + this.#foldRanges = []; + this.#foldRangesByStart.clear(); + this.#foldEndLines.clear(); + this.#foldClosingDelimiterLines.clear(); + this.#foldedStartLines.clear(); + this.#setHiddenLineRanges([]); + this.#foldRangeDocument = undefined; + this.#foldRangeVersion = -1; + } + this.#syncFoldedRangesToHost(); + this.#resetCache(); + this.#gutterWidthCache = undefined; + this.#contentWidthCache = undefined; + this.#renderFoldingControls(); + if (this.#selections !== undefined) { + this.#updateSelections(this.#selections); + } + } + + // Whether a zero-based document line has (or will have on scroll) a rendered + // row. File folds and collapsed diff regions share this visibility gate. #isLineRenderable(line: number): boolean { - return this.#fileInstance?.isLineRenderable?.(line + 1) ?? true; + return ( + (!this.#isFoldingEnabled || !this.#hiddenLineIndex.isHidden(line)) && + (this.#fileInstance?.isLineRenderable?.(line + 1) ?? true) + ); } // Fold-skip resolver for vertical caret motion (zero-based lines), or - // undefined for hosts without collapsible regions so motion stays plain - // line arithmetic. + // undefined when neither the editor nor its host hides document lines. get #resolveRenderableLine(): ResolveRenderableLine | undefined { const fileInstance = this.#fileInstance; - if (fileInstance?.getNearestRenderableLine == null) { + const textDocument = this.#textDocument; + const hasEditorFolds = + this.#isFoldingEnabled && this.#hiddenLineRanges.length > 0; + if (!hasEditorFolds && fileInstance?.getNearestRenderableLine == null) { return undefined; } return (line, direction) => { - const nearest = fileInstance.getNearestRenderableLine!( - line + 1, - direction - ); - return nearest == null ? undefined : nearest - 1; + let candidate = line; + for (let attempt = 0; attempt < 4; attempt++) { + if (hasEditorFolds) { + const nearest = this.#hiddenLineIndex.nearestVisibleLine( + candidate, + direction, + textDocument?.lineCount ?? 0 + ); + if (nearest === undefined) { + return undefined; + } + candidate = nearest; + } + if (fileInstance?.getNearestRenderableLine == null) { + return candidate; + } + const nearest = fileInstance.getNearestRenderableLine( + candidate + 1, + direction + ); + if (nearest == null) { + return undefined; + } + const hostCandidate = nearest - 1; + if ( + hostCandidate === candidate && + !this.#hiddenLineIndex.isHidden(hostCandidate) + ) { + return hostCandidate; + } + candidate = hostCandidate; + } + return this.#isLineRenderable(candidate) ? candidate : undefined; }; } @@ -1310,6 +2057,7 @@ export class Editor implements DiffsEditor { // moves) may land inside a collapsed region; expand it minimally so the // caret's row can render before the scroll below retries toward it. #revealLineIfCollapsed(line: number): void { + this.#unfoldLine(line); if (!this.#isLineRenderable(line)) { this.#fileInstance?.revealLine?.(line + 1); } @@ -2982,6 +3730,36 @@ export class Editor implements DiffsEditor { }); } + // Retokenizing a folded header keeps its focused inline indicator attached. + #replaceLineTokens( + lineElement: HTMLElement, + tokens: Array + ): void { + const lastElement = lineElement.lastElementChild; + const foldIndicator = + lastElement instanceof HTMLElement && + lastElement.dataset.foldIndicator !== undefined + ? lastElement + : undefined; + if (foldIndicator === undefined) { + lineElement.replaceChildren(...renderLineTokens(tokens)); + return; + } + + let child = lineElement.firstChild; + while (child !== null && child !== foldIndicator) { + const next = child.nextSibling; + child.remove(); + child = next; + } + foldIndicator.before(...renderLineTokens(tokens)); + const lineIndex = Number(lineElement.dataset.lineIndex); + if (Number.isInteger(lineIndex)) { + foldIndicator.dataset.foldCharacter = + this.#textDocument?.getLineLength(lineIndex).toString() ?? '0'; + } + } + #rerender( change: TextDocumentChange, newLineAnnotations?: DiffLineAnnotation[], @@ -3038,7 +3816,7 @@ export class Editor implements DiffsEditor { const lineIndex = lineNumber - 1; if (dirtyLineIndexes.has(lineIndex)) { const tokens = dirtyLines.get(lineIndex)!; - child.replaceChildren(...renderLineTokens(tokens)); + this.#replaceLineTokens(child, tokens); dirtyLineIndexes.delete(lineIndex); if (dirtyLineIndexes.size === 0) { break; @@ -4820,6 +5598,8 @@ export class Editor implements DiffsEditor { newLineAnnotations?: DiffLineAnnotation[], options?: { skipSearchRefresh?: boolean; skipFocus?: boolean } ) { + const foldingControlsChanged = this.#remapFoldingAfterChange(change); + const fileRef = this.getFile(); const onChange = this.#options.onChange; if (fileRef !== undefined && onChange !== undefined) { @@ -4947,6 +5727,10 @@ export class Editor implements DiffsEditor { } } this.#rerender(change, newLineAnnotations, renderRange, shouldUpdateBuffer); + this.#syncFoldedRangesToHost(); + if (foldingControlsChanged) { + this.#renderFoldingControls(); + } if ( options?.skipSearchRefresh !== true && diff --git a/packages/diffs/src/editor/folding.ts b/packages/diffs/src/editor/folding.ts new file mode 100644 index 000000000..fe6a399da --- /dev/null +++ b/packages/diffs/src/editor/folding.ts @@ -0,0 +1,294 @@ +import type { LineRange } from '../types'; +import type { TextDocument } from './textDocument'; + +const CLOSING_DELIMITER_ONLY = /^[}\])]+[;,]?$/; + +/** + * Stores hidden ranges as numeric arrays with prefix counts so visibility and + * document-to-visible-line mappings do not scan every folded range. + */ +export class LineRangeIndex { + readonly #starts: number[] = []; + readonly #ends: number[] = []; + readonly #prefixHiddenCounts: number[] = [0]; + readonly #visibleStarts: number[] = []; + + constructor(ranges: readonly LineRange[] = []) { + let previousStart = -Infinity; + let rangesAreOrdered = true; + + for (const range of ranges) { + if (range.startLine < 0 || range.endLine < range.startLine) { + continue; + } + if (range.startLine < previousStart) { + rangesAreOrdered = false; + break; + } + + previousStart = range.startLine; + this.#appendRange(range.startLine, range.endLine); + } + + if (rangesAreOrdered) { + return; + } + + this.#starts.length = 0; + this.#ends.length = 0; + this.#prefixHiddenCounts.length = 1; + this.#visibleStarts.length = 0; + + const sortedRanges = ranges + .filter( + (range) => range.startLine >= 0 && range.endLine >= range.startLine + ) + .sort((left, right) => { + const startDifference = left.startLine - right.startLine; + return startDifference === 0 + ? right.endLine - left.endLine + : startDifference; + }); + + for (const range of sortedRanges) { + this.#appendRange(range.startLine, range.endLine); + } + } + + #appendRange(startLine: number, endLine: number): void { + const previousIndex = this.#ends.length - 1; + if (previousIndex >= 0 && startLine <= this.#ends[previousIndex] + 1) { + const previousEnd = this.#ends[previousIndex]; + if (endLine > previousEnd) { + this.#ends[previousIndex] = endLine; + this.#prefixHiddenCounts[previousIndex + 1] += endLine - previousEnd; + } + return; + } + + const hiddenBefore = + this.#prefixHiddenCounts[this.#prefixHiddenCounts.length - 1]; + this.#starts.push(startLine); + this.#ends.push(endLine); + this.#visibleStarts.push(startLine - hiddenBefore); + this.#prefixHiddenCounts.push(hiddenBefore + endLine - startLine + 1); + } + + #containingRangeIndex(line: number): number { + const index = upperBound(this.#starts, line) - 1; + return index >= 0 && line <= this.#ends[index] ? index : -1; + } + + isHidden(line: number): boolean { + return this.#containingRangeIndex(line) >= 0; + } + + containingRange(line: number): LineRange | undefined { + const index = this.#containingRangeIndex(line); + if (index < 0) { + return undefined; + } + return { + startLine: this.#starts[index], + endLine: this.#ends[index], + }; + } + + lineAfterHiddenRange(line: number): number | undefined { + const index = this.#containingRangeIndex(line); + return index < 0 ? undefined : this.#ends[index] + 1; + } + + hiddenCountBefore(lineExclusive: number): number { + const index = upperBound(this.#starts, lineExclusive - 1) - 1; + if (index < 0) { + return 0; + } + + const hiddenBefore = this.#prefixHiddenCounts[index]; + return ( + hiddenBefore + + Math.min( + this.#ends[index] - this.#starts[index] + 1, + lineExclusive - this.#starts[index] + ) + ); + } + + visibleLineCount(total: number): number { + const normalizedTotal = Math.max(0, Math.trunc(total)); + return normalizedTotal - this.hiddenCountBefore(normalizedTotal); + } + + lineAtVisibleIndex(index: number, total: number): number | undefined { + if ( + !Number.isInteger(index) || + index < 0 || + index >= this.visibleLineCount(total) + ) { + return undefined; + } + + const precedingRangeCount = upperBound(this.#visibleStarts, index); + return index + this.#prefixHiddenCounts[precedingRangeCount]; + } + + nearestVisibleLine( + line: number, + direction: 'up' | 'down', + total: number + ): number | undefined { + const normalizedTotal = Math.max(0, Math.trunc(total)); + if (normalizedTotal === 0) { + return undefined; + } + + let candidate = Math.trunc(line); + if (direction === 'down') { + if (candidate < 0) { + candidate = 0; + } else if (candidate >= normalizedTotal) { + return undefined; + } + } else if (candidate >= normalizedTotal) { + candidate = normalizedTotal - 1; + } else if (candidate < 0) { + return undefined; + } + + const rangeIndex = this.#containingRangeIndex(candidate); + if (rangeIndex < 0) { + return candidate; + } + + const nearest = + direction === 'down' + ? this.#ends[rangeIndex] + 1 + : this.#starts[rangeIndex] - 1; + return nearest >= 0 && nearest < normalizedTotal ? nearest : undefined; + } +} + +export function isFoldingClosingDelimiter(text: string): boolean { + return CLOSING_DELIMITER_ONLY.test(text.trim()); +} + +/** + * Finds indentation folds by comparing adjacent nonblank lines. Blank lines + * inside a block are included, while blank lines after its last content line + * and standalone closing delimiters are left visible. + */ +export function computeIndentFoldingRanges( + textDocument: Pick, 'lineCount' | 'getLineText'>, + tabSize = 2 +): LineRange[] { + const integerTabSize = Math.trunc(tabSize); + const normalizedTabSize = + Number.isFinite(integerTabSize) && integerTabSize > 0 ? integerTabSize : 2; + const ranges: Array<{ startLine: number; endLine: number }> = []; + const openRanges: Array<{ + indent: number; + range: { startLine: number; endLine: number }; + }> = []; + let previousLine = -1; + let previousIndent = 0; + + for (let line = 0; line < textDocument.lineCount; line++) { + const text = textDocument.getLineText(line); + const trimmedText = text.trim(); + if (trimmedText.length === 0) { + continue; + } + + let indent = 0; + for (const character of text) { + if (character === ' ') { + indent++; + } else if (character === '\t') { + indent += normalizedTabSize - (indent % normalizedTabSize); + } else { + break; + } + } + + if (previousLine >= 0) { + while (openRanges.length > 0) { + const openRange = openRanges[openRanges.length - 1]; + if (openRange.indent < indent) { + break; + } + + openRanges.pop(); + openRange.range.endLine = + openRange.indent === indent && + CLOSING_DELIMITER_ONLY.test(trimmedText) + ? line - 1 + : previousLine; + } + + if (indent > previousIndent) { + const range = { + startLine: previousLine, + endLine: line, + }; + ranges.push(range); + openRanges.push({ indent: previousIndent, range }); + } + } + + previousLine = line; + previousIndent = indent; + } + + if (previousLine >= 0) { + for (const openRange of openRanges) { + openRange.range.endLine = previousLine; + } + } + + return ranges; +} + +/** + * Converts folded headers to their hidden bodies and coalesces overlapping or + * adjacent bodies into ranges suitable for visibility queries. + */ +export function mergeHiddenLineRanges( + foldRanges: readonly LineRange[], + foldedStartLines: ReadonlySet +): LineRange[] { + const merged: Array<{ startLine: number; endLine: number }> = []; + + for (const foldRange of foldRanges) { + if ( + !foldedStartLines.has(foldRange.startLine) || + foldRange.endLine <= foldRange.startLine + ) { + continue; + } + + const startLine = foldRange.startLine + 1; + const previous = merged[merged.length - 1]; + if (previous != null && startLine <= previous.endLine + 1) { + previous.endLine = Math.max(previous.endLine, foldRange.endLine); + } else { + merged.push({ startLine, endLine: foldRange.endLine }); + } + } + + return merged; +} + +function upperBound(values: readonly number[], target: number): number { + let low = 0; + let high = values.length; + while (low < high) { + const middle = low + ((high - low) >> 1); + if (values[middle] <= target) { + low = middle + 1; + } else { + high = middle; + } + } + return low; +} diff --git a/packages/diffs/src/editor/selection.ts b/packages/diffs/src/editor/selection.ts index 126c22a95..74cbdca24 100644 --- a/packages/diffs/src/editor/selection.ts +++ b/packages/diffs/src/editor/selection.ts @@ -2409,6 +2409,20 @@ function boundaryToPosition(node: Node, offset: number): Position | null { return null; } + let foldIndicator = host; + while ( + foldIndicator !== null && + foldIndicator.dataset.foldIndicator === undefined + ) { + foldIndicator = foldIndicator.parentElement; + } + if (foldIndicator != null) { + const character = parseInt(foldIndicator.dataset.foldCharacter ?? '', 10); + if (!Number.isNaN(character)) { + return { line, character }; + } + } + if (node.nodeType === 3) { if (node.parentElement === null) { return null; @@ -2638,6 +2652,10 @@ function getLineChildEnd( if (el.tagName !== 'SPAN' && el.tagName !== 'BR') { return 0; } + if (el.dataset.foldIndicator !== undefined) { + const character = parseInt(el.dataset.foldCharacter ?? '', 10); + return Number.isNaN(character) ? 0 : character; + } const base = getCharacterIndex(el); if (base !== undefined) { return base + (el.textContent?.length ?? 0); diff --git a/packages/diffs/src/editor/sprite.ts b/packages/diffs/src/editor/sprite.ts index 71fc0471f..76b608f7d 100644 --- a/packages/diffs/src/editor/sprite.ts +++ b/packages/diffs/src/editor/sprite.ts @@ -2,19 +2,20 @@ export type SVGSpriteNames = | 'arrow-down' | 'arrow-up' | 'case' + | 'chevron-down' + | 'chevron-right' | 'close' + | 'ellipsis' | 'regex' | 'whole-word' | 'replace' | 'replace-all'; -// Icon artwork is sourced from `@pierre/icons` (IconSearch, IconX, -// IconArrowRight, IconType, IconTypeWord, IconRegex, IconReplace, -// IconReplaceAll) so the editor matches the rest of the product. The arrow glyph -// is the full-size right arrow rotated to point up/down for the search -// "previous"/"next" controls. `getEditorIconSvg` omits an outer viewBox so each -// symbol scales to fill the requested size regardless of its intrinsic -// coordinate system. +// Icon artwork is sourced from `@pierre/icons` so the editor matches the rest +// of the product. The arrow glyph is the full-size right arrow rotated to point +// up/down for the search "previous"/"next" controls. `getEditorIconSvg` omits +// an outer viewBox so each symbol scales to fill the requested size regardless +// of its intrinsic coordinate system. export const SVGSpriteSheet = ``; export const createSpriteElement = (): SVGSVGElement => { @@ -57,6 +67,4 @@ export const createSpriteElement = (): SVGSVGElement => { }; export const getEditorIconSvg = (name: SVGSpriteNames, size = 16): string => - ``; + ``; diff --git a/packages/diffs/src/editor/stateStorage.ts b/packages/diffs/src/editor/stateStorage.ts index 88006c6d2..de0163b46 100644 --- a/packages/diffs/src/editor/stateStorage.ts +++ b/packages/diffs/src/editor/stateStorage.ts @@ -18,6 +18,7 @@ export function cloneEditorState(state: EditorState): EditorState { end: { ...selection.end }, direction: selection.direction, })), + foldRanges: state.foldRanges?.map((range) => ({ ...range })), view: state.view === undefined ? undefined : { ...state.view }, }; } diff --git a/packages/diffs/src/renderers/FileRenderer.ts b/packages/diffs/src/renderers/FileRenderer.ts index 7e01e342d..268b8ec3d 100644 --- a/packages/diffs/src/renderers/FileRenderer.ts +++ b/packages/diffs/src/renderers/FileRenderer.ts @@ -21,6 +21,7 @@ import type { FileHeaderRenderMode, HighlightedToken, LineAnnotation, + LineRange, RenderedFileASTCache, RenderFileOptions, RenderFileResult, @@ -110,6 +111,7 @@ export class FileRenderer { private renderCache: RenderedFileASTCache | undefined; private computedLang: SupportedLanguages = 'text'; private lineAnnotations: AnnotationLineMap = {}; + private foldRanges: LineRange[] = []; private lineCache: LineCache | undefined; private textDocumentCache = new WeakMap(); @@ -144,6 +146,10 @@ export class FileRenderer { } } + public setFoldRanges(ranges: LineRange[]): void { + this.foldRanges = ranges; + } + public cleanUp(): void { this.recycle(); this.workerManager = undefined; @@ -154,6 +160,7 @@ export class FileRenderer { this.clearRenderCache(); this.highlighter = undefined; this.workerManager?.cleanUpTasks(this); + this.foldRanges = []; this.lineCache = undefined; // The edited-document cache is only coherent alongside the render cache // it patched. Keeping it across a recycle would let getLineCount report @@ -635,11 +642,35 @@ export class FileRenderer { rowCount++; } + let low = 0; + let high = this.foldRanges.length; + while (low < high) { + const middle = low + ((high - low) >> 1); + if (this.foldRanges[middle].endLine < renderRange.startingLine) { + low = middle + 1; + } else { + high = middle; + } + } + let foldedRangeIndex = low; for ( let lineIndex = renderRange.startingLine; lineIndex < endLine; lineIndex++ ) { + while (this.foldRanges[foldedRangeIndex]?.endLine < lineIndex) { + foldedRangeIndex++; + } + const foldedRange = this.foldRanges[foldedRangeIndex]; + if ( + foldedRange !== undefined && + lineIndex >= foldedRange.startLine && + lineIndex <= foldedRange.endLine + ) { + lineIndex = foldedRange.endLine; + continue; + } + const lineNumber = lineIndex + 1; // Sparse array - directly indexed by lineIndex diff --git a/packages/diffs/src/types.ts b/packages/diffs/src/types.ts index 47cb5d959..f71a97878 100644 --- a/packages/diffs/src/types.ts +++ b/packages/diffs/src/types.ts @@ -1033,6 +1033,11 @@ export interface DiffsEditableComponent< lineNumber: number | null, options?: EditorActiveLineOptions ) => void; + /** + * @internal Hide inclusive zero-based document-line ranges while an editor + * fold is active. FileDiff intentionally leaves this unimplemented. + */ + __setFoldRanges?: (ranges: LineRange[]) => void; /** * Return the position and height of a one-based line relative to this component. * The host uses it to scroll to virtualized lines before their DOM nodes exist. @@ -1212,6 +1217,11 @@ export interface EditorSelection extends Range { export interface EditorState { selections?: EditorSelection[]; + /** + * Active indentation folds. Lines are zero-based; `endLine` is the last + * collapsed body line. A standalone closing delimiter remains visible. + */ + foldRanges?: LineRange[]; view?: { scrollLeft: number; scrollTop: number; @@ -1224,6 +1234,11 @@ export interface DiffsTextDocument { getText: () => string; } +export interface LineRange { + readonly startLine: number; + readonly endLine: number; +} + /** * Options CodeView passes to its `createEditor` factory. A structural subset * of `EditorOptions` from `@pierre/diffs/editor`, so factories can spread diff --git a/packages/diffs/test/VirtualizedFile.folding.test.ts b/packages/diffs/test/VirtualizedFile.folding.test.ts new file mode 100644 index 000000000..ecfd7e84e --- /dev/null +++ b/packages/diffs/test/VirtualizedFile.folding.test.ts @@ -0,0 +1,216 @@ +import { describe, expect, test } from 'bun:test'; + +import { VirtualizedFile } from '../src/components/VirtualizedFile'; +import { DEFAULT_VIRTUAL_FILE_METRICS } from '../src/constants'; +import type { + FileContents, + RenderRange, + RenderWindow, + VirtualFileMetrics, +} from '../src/types'; + +const metrics: VirtualFileMetrics = { + ...DEFAULT_VIRTUAL_FILE_METRICS, + hunkLineCount: 2, + lineHeight: 10, + diffHeaderHeight: 30, + spacing: 4, +}; + +interface InspectableVirtualizedFile { + cache: { + heights: Map; + checkpoints: unknown[]; + fileAnnotationHeight: number; + }; + editorFoldedLineIndex: { + isHidden(lineIndex: number): boolean; + }; + renderRange: RenderRange | undefined; + computeApproximateSize(force?: boolean): void; + computeRenderRangeFromWindow( + file: FileContents, + fileTop: number, + window: RenderWindow + ): RenderRange; +} + +function inspect(instance: VirtualizedFile): InspectableVirtualizedFile { + return instance as unknown as InspectableVirtualizedFile; +} + +function createFile(lineCount: number): FileContents { + return { + name: 'folded.ts', + contents: Array.from( + { length: lineCount }, + (_, lineIndex) => `line ${lineIndex + 1}` + ).join('\n'), + }; +} + +function createVirtualizer(layoutChanges: boolean[]) { + return { + type: 'simple', + config: {}, + connect() {}, + disconnect() {}, + getWindowSpecs() { + return { top: 0, bottom: 1_000 }; + }, + getOffsetInScrollContainer() { + return 0; + }, + instanceChanged(_instance: unknown, layoutChanged: boolean) { + layoutChanges.push(layoutChanged); + }, + isInstanceVisible() { + return true; + }, + } as never; +} + +describe('VirtualizedFile editor folding', () => { + test('removes hidden rows from geometry and invalidates layout on toggles', () => { + const layoutChanges: boolean[] = []; + const file = createFile(20); + const instance = new VirtualizedFile( + {}, + createVirtualizer(layoutChanges), + metrics + ); + instance.prepareCodeViewItem(file, 0); + + expect(instance.getVirtualizedHeight()).toBe(234); + + instance.__setFoldRanges([{ startLine: 3, endLine: 7 }]); + + expect(instance.getVirtualizedHeight()).toBe(184); + expect(instance.getLineHeight(3)).toBe(0); + expect(instance.getLinePosition(4)).toEqual({ top: 60, height: 0 }); + expect(instance.getLinePosition(9)).toEqual({ top: 60, height: 10 }); + expect(layoutChanges).toEqual([true]); + + instance.__setFoldRanges([]); + + expect(instance.getVirtualizedHeight()).toBe(234); + expect(instance.getLinePosition(9)).toEqual({ top: 110, height: 10 }); + expect(layoutChanges).toEqual([true, true]); + + instance.__setFoldRanges([]); + expect(layoutChanges).toEqual([true, true]); + }); + + test('maps uniform-height windows through visible indexes to raw lines', () => { + const file = createFile(20); + const instance = new VirtualizedFile({}, createVirtualizer([]), metrics); + instance.prepareCodeViewItem(file, 0); + instance.__setFoldRanges([{ startLine: 2, endLine: 11 }]); + + const range = inspect(instance).computeRenderRangeFromWindow(file, 0, { + top: 80, + bottom: 90, + }); + + expect(range).toEqual({ + startingLine: 12, + totalLines: 6, + bufferBefore: 20, + bufferAfter: 20, + }); + + inspect(instance).renderRange = range; + expect(instance.getNumericScrollAnchor(51)).toEqual({ + lineNumber: 14, + top: 60, + }); + }); + + test('gives folded rows zero height in variable-height layout', () => { + const file = createFile(20); + const instance = new VirtualizedFile( + { overflow: 'wrap' }, + createVirtualizer([]), + metrics + ); + instance.prepareCodeViewItem(file, 0); + instance.__setFoldRanges([{ startLine: 3, endLine: 7 }]); + + expect(instance.getVirtualizedHeight()).toBe(184); + expect(instance.getLinePosition(6)).toEqual({ top: 60, height: 0 }); + expect(instance.getLinePosition(9)).toEqual({ top: 60, height: 10 }); + }); + + test('preserves measured heights while rebuilding folded layout', () => { + const file = createFile(20); + const instance = new VirtualizedFile( + { overflow: 'wrap' }, + createVirtualizer([]), + metrics + ); + instance.prepareCodeViewItem(file, 0); + const layout = inspect(instance); + layout.cache.heights.set(8, 25); + layout.cache.fileAnnotationHeight = 12; + + instance.__setFoldRanges([{ startLine: 3, endLine: 7 }]); + + expect(layout.cache.heights.get(8)).toBe(25); + expect(layout.cache.fileAnnotationHeight).toBe(12); + expect(instance.getVirtualizedHeight()).toBe(211); + }); + + test('jumps large folded bodies in variable-height layout scans', () => { + const lineCount = 20_000; + const file = createFile(lineCount); + const instance = new VirtualizedFile( + { overflow: 'wrap' }, + createVirtualizer([]), + metrics + ); + instance.prepareCodeViewItem(file, 0); + instance.__setFoldRanges([{ startLine: 1, endLine: 19_990 }]); + + const layout = inspect(instance); + const originalIsHidden = layout.editorFoldedLineIndex.isHidden.bind( + layout.editorFoldedLineIndex + ); + let hiddenChecks = 0; + layout.editorFoldedLineIndex.isHidden = (lineIndex) => { + hiddenChecks++; + return originalIsHidden(lineIndex); + }; + + layout.computeApproximateSize(true); + + expect(instance.getVirtualizedHeight()).toBe(134); + expect(hiddenChecks).toBe(0); + + expect(instance.getLinePosition(15_000)).toEqual({ + top: 40, + height: 0, + }); + expect(hiddenChecks).toBe(1); + + hiddenChecks = 0; + const range = layout.computeRenderRangeFromWindow(file, 0, { + top: 40, + bottom: 60, + }); + expect(range.startingLine).toBe(0); + expect(hiddenChecks).toBeLessThan(10); + + hiddenChecks = 0; + layout.renderRange = { + startingLine: 0, + totalLines: lineCount, + bufferBefore: 0, + bufferAfter: 0, + }; + expect(instance.getNumericScrollAnchor(50)).toEqual({ + lineNumber: 19_993, + top: 50, + }); + expect(hiddenChecks).toBe(0); + }); +}); diff --git a/packages/diffs/test/e2e/e2e-globals.d.ts b/packages/diffs/test/e2e/e2e-globals.d.ts index adb6aac07..759e4bda7 100644 --- a/packages/diffs/test/e2e/e2e-globals.d.ts +++ b/packages/diffs/test/e2e/e2e-globals.d.ts @@ -62,6 +62,7 @@ interface Window { __lineSelectReady?: boolean; __annotationsReady?: boolean; __themeReady?: boolean; + __foldingReady?: boolean; __selectionActionReady?: boolean; __selectionActionEdgesReady?: boolean; @@ -77,6 +78,7 @@ interface Window { // Editor handle exposed by the editable fixtures. __editor?: E2EEditor; + __setFoldingTheme?: () => void; // edit-collapsed.html helpers: rendered new-file line numbers in the // editable column, and the primary caret's zero-based line. diff --git a/packages/diffs/test/e2e/fixtures/folding.html b/packages/diffs/test/e2e/fixtures/folding.html new file mode 100644 index 000000000..658aeed28 --- /dev/null +++ b/packages/diffs/test/e2e/fixtures/folding.html @@ -0,0 +1,94 @@ + + + + + + diffs editor-folding fixture + + + + ← All fixtures +
+ + + + diff --git a/packages/diffs/test/e2e/fixtures/index.html b/packages/diffs/test/e2e/fixtures/index.html index 1ce22bd6a..8eae18871 100644 --- a/packages/diffs/test/e2e/fixtures/index.html +++ b/packages/diffs/test/e2e/fixtures/index.html @@ -116,6 +116,14 @@

@pierre/diffs — E2E fixtures

window.__editableReady.

+
  • + folding.html +

    + A foldable single-file Editor. Drives + folding.pw.ts (gutter and icon hover states, fold/unfold + interaction). Ready flag: window.__foldingReady. +

    +
  • markers.html

    diff --git a/packages/diffs/test/e2e/folding.pw.ts b/packages/diffs/test/e2e/folding.pw.ts new file mode 100644 index 000000000..3af2cec48 --- /dev/null +++ b/packages/diffs/test/e2e/folding.pw.ts @@ -0,0 +1,105 @@ +import { expect, type Locator, type Page, test } from '@playwright/test'; + +const GUTTER = '[data-code][data-editor-folding] [data-gutter]'; +const CONTENT_ROWS = '[data-content] > [data-line]'; +const OUTER_TOGGLE = '[data-column-number="1"] [data-fold-toggle]'; +const OUTER_INDICATOR = + '[data-content] > [data-line="1"] > [data-fold-indicator]'; + +async function openFixture(page: Page): Promise { + await page.goto('/test/e2e/fixtures/folding.html'); + await page.waitForFunction(() => window.__foldingReady === true); +} + +const renderedLineNumbers = (page: Page): Promise => + page + .locator(CONTENT_ROWS) + .evaluateAll((rows) => + rows.map((row) => Number((row as HTMLElement).dataset.line)) + ); + +const color = (toggle: Locator): Promise => + toggle.evaluate((element) => getComputedStyle(element).color); + +const firstTokenColor = (page: Page): Promise => + page + .locator('[data-content] > [data-line="1"] [data-char]') + .first() + .evaluate((element) => getComputedStyle(element).color); + +test.describe('editor folding controls', () => { + test('reveal on hover and fold or unfold the outer block', async ({ + page, + }) => { + await openFixture(page); + + const gutter = page.locator(GUTTER); + let toggle = page.locator(OUTER_TOGGLE); + await expect(toggle.locator('use')).toHaveAttribute( + 'href', + '#diffs-editor-icon-chevron-down' + ); + await expect(toggle).toHaveCSS('opacity', '0'); + + await gutter.hover(); + await expect(toggle).toHaveCSS('opacity', '0.72'); + const gutterHoverColor = await color(toggle); + + await toggle.hover(); + await expect(toggle).toHaveCSS('opacity', '1'); + await expect.poll(() => color(toggle)).not.toBe(gutterHoverColor); + + await toggle.click(); + await expect.poll(() => renderedLineNumbers(page)).toEqual([1, 7, 8]); + + await page.mouse.move(1150, 780); + toggle = page.locator(OUTER_TOGGLE); + await expect(toggle).toHaveAttribute('data-folded', ''); + await expect(toggle.locator('use')).toHaveAttribute( + 'href', + '#diffs-editor-icon-chevron-right' + ); + await expect(toggle).toHaveCSS('opacity', '0.72'); + + const indicator = page.locator(OUTER_INDICATOR); + const ellipsis = indicator.locator('[data-fold-ellipsis]'); + await expect(indicator).toBeVisible(); + await expect(ellipsis.locator('use')).toHaveAttribute( + 'href', + '#diffs-editor-icon-ellipsis' + ); + expect(await indicator.getAttribute('data-fold-end-text')).toBeNull(); + await expect(indicator).toHaveText(''); + await expect(page.locator('[data-content] > [data-line="7"]')).toHaveText( + '}' + ); + + const ellipsisBox = await ellipsis.boundingBox(); + const indicatorBox = await indicator.boundingBox(); + expect(ellipsisBox).not.toBeNull(); + expect(indicatorBox).not.toBeNull(); + expect(indicatorBox!.width).toBeCloseTo(ellipsisBox!.width, 5); + + const darkTokenColor = await firstTokenColor(page); + await ellipsis.focus(); + await expect(ellipsis).toBeFocused(); + await page.evaluate(() => window.__setFoldingTheme?.()); + await expect.poll(() => firstTokenColor(page)).not.toBe(darkTokenColor); + await expect(ellipsis).toBeFocused(); + + await ellipsis.click(); + await expect + .poll(() => renderedLineNumbers(page)) + .toEqual([1, 2, 3, 4, 5, 6, 7, 8]); + await expect(indicator).toHaveCount(0); + + await page.mouse.move(1150, 780); + toggle = page.locator(OUTER_TOGGLE); + await expect(toggle).not.toHaveAttribute('data-folded', ''); + await expect(toggle.locator('use')).toHaveAttribute( + 'href', + '#diffs-editor-icon-chevron-down' + ); + await expect(toggle).toHaveCSS('opacity', '0'); + }); +}); diff --git a/packages/diffs/test/editorFolding.test.ts b/packages/diffs/test/editorFolding.test.ts new file mode 100644 index 000000000..5cf65e2df --- /dev/null +++ b/packages/diffs/test/editorFolding.test.ts @@ -0,0 +1,650 @@ +import { afterAll, describe, expect, test } from 'bun:test'; + +import { File } from '../src/components/File'; +import { FileDiff } from '../src/components/FileDiff'; +import { DEFAULT_THEMES } from '../src/constants'; +import { Editor, type EditorOptions } from '../src/editor/editor'; +import { disposeHighlighter } from '../src/highlighter/shared_highlighter'; +import type { FileContents, LineRange } from '../src/types'; +import { installDom, wait, waitFor } from './domHarness'; + +afterAll(async () => { + await disposeHighlighter(); +}); + +const FOLDABLE_CONTENTS = [ + 'function outer() {', + ' const before = 1;', + ' if (before) {', + ' console.log(before);', + ' }', + ' return before;', + '}', + 'const after = true;', +].join('\n'); + +interface FileEditorFixture { + cleanup(): void; + container: HTMLElement; + editor: Editor; + file: File; +} + +async function waitForEditableContent(container: HTMLElement): Promise { + const hasEditableContent = (): boolean => + [ + ...(container.shadowRoot?.querySelectorAll( + '[data-content]' + ) ?? []), + ].some( + (content) => + content.contentEditable === 'true' || + content.getAttribute('contenteditable') === 'true' + ); + + await waitFor(hasEditableContent, { timeout: 3000 }); + + expect(hasEditableContent()).toBe(true); +} + +async function createFileEditorFixture( + editorOptions?: EditorOptions, + contents = FOLDABLE_CONTENTS +): Promise { + const dom = installDom(); + const container = document.createElement('div'); + document.body.appendChild(container); + + const file = new File({ + disableFileHeader: true, + theme: DEFAULT_THEMES, + }); + const editor = new Editor(editorOptions); + const fileContents: FileContents = { + name: 'foldable.ts', + contents, + }; + + file.render({ + file: fileContents, + fileContainer: container, + forceRender: true, + }); + editor.edit(file); + await waitForEditableContent(container); + + return { + cleanup() { + editor.cleanUp(); + file.cleanUp(); + dom.cleanup(); + }, + container, + editor, + file, + }; +} + +function shadowRoot(container: HTMLElement): ShadowRoot { + const shadow = container.shadowRoot; + if (shadow == null) { + throw new Error('file container has no shadow root'); + } + return shadow; +} + +function renderedLineNumbers(container: HTMLElement): number[] { + return [ + ...shadowRoot(container).querySelectorAll( + '[data-content] > [data-line]' + ), + ].map((line) => Number(line.dataset.line)); +} + +function gutterRow( + container: HTMLElement, + oneIndexedLine: number +): HTMLElement { + const row = shadowRoot(container).querySelector( + `[data-column-number="${oneIndexedLine}"]` + ); + if (row == null) { + throw new Error(`no gutter row found for line ${oneIndexedLine}`); + } + return row; +} + +function foldToggle( + container: HTMLElement, + oneIndexedLine: number +): HTMLButtonElement { + const toggle = gutterRow(container, oneIndexedLine).querySelector( + '[data-fold-toggle]' + ); + if (!(toggle instanceof HTMLButtonElement)) { + throw new Error(`no fold toggle found for line ${oneIndexedLine}`); + } + return toggle; +} + +function foldIconHref(toggle: HTMLButtonElement): string | null { + return toggle.querySelector('use')?.getAttribute('href') ?? null; +} + +function foldIndicator( + container: HTMLElement, + oneIndexedLine: number +): HTMLElement { + const indicator = shadowRoot(container).querySelector( + `[data-content] > [data-line="${oneIndexedLine}"] > [data-fold-indicator]` + ); + if (indicator == null) { + throw new Error(`no fold indicator found for line ${oneIndexedLine}`); + } + return indicator; +} + +function foldEllipsis( + container: HTMLElement, + oneIndexedLine: number +): HTMLButtonElement { + const ellipsis = foldIndicator(container, oneIndexedLine).querySelector( + '[data-fold-ellipsis]' + ); + if (!(ellipsis instanceof HTMLButtonElement)) { + throw new Error(`no fold ellipsis found for line ${oneIndexedLine}`); + } + return ellipsis; +} + +function recordFoldRangeUpdates(file: File): LineRange[][] { + const updates: LineRange[][] = []; + const setFoldRanges = file.__setFoldRanges.bind(file); + file.__setFoldRanges = (ranges) => { + updates.push(ranges.map((range) => ({ ...range }))); + setFoldRanges(ranges); + }; + return updates; +} + +async function waitForLines( + container: HTMLElement, + expected: number[] +): Promise { + await waitFor( + () => + JSON.stringify(renderedLineNumbers(container)) === + JSON.stringify(expected), + { timeout: 3000 } + ); + expect(renderedLineNumbers(container)).toEqual(expected); +} + +describe('editor folding on File', () => { + test('is enabled by default and folds or unfolds a block from the gutter', async () => { + const { cleanup, container } = await createFileEditorFixture(); + try { + const shadow = shadowRoot(container); + const firstGutterRow = gutterRow(container, 1); + const foldZone = firstGutterRow.querySelector(':scope > [data-fold]'); + const initialToggle = foldToggle(container, 1); + + expect(shadow.querySelector('[data-code][data-editor-folding]')).not.toBe( + null + ); + expect(foldZone?.parentElement).toBe(firstGutterRow); + expect(firstGutterRow.closest('[data-gutter]')).not.toBe(null); + expect(foldZone?.contains(initialToggle)).toBe(true); + expect(initialToggle.getAttribute('aria-expanded')).toBe('true'); + expect(foldIconHref(initialToggle)).toBe( + '#diffs-editor-icon-chevron-down' + ); + + initialToggle.focus(); + initialToggle.click(); + await waitForLines(container, [1, 7, 8]); + + const foldedToggle = foldToggle(container, 1); + expect(shadow.activeElement).toBe(foldedToggle); + expect(foldedToggle.hasAttribute('data-folded')).toBe(true); + expect(foldedToggle.getAttribute('aria-expanded')).toBe('false'); + expect(foldIconHref(foldedToggle)).toBe( + '#diffs-editor-icon-chevron-right' + ); + + const indicator = foldIndicator(container, 1); + const ellipsis = foldEllipsis(container, 1); + expect(indicator.parentElement?.dataset.line).toBe('1'); + expect(indicator.getAttribute('contenteditable')).toBe('false'); + expect(ellipsis.ariaLabel).toBe('Unfold line 1'); + expect(foldIconHref(ellipsis)).toBe('#diffs-editor-icon-ellipsis'); + expect(indicator.dataset.foldEndText).toBeUndefined(); + expect(indicator.children.length).toBe(1); + expect(indicator.firstElementChild).toBe(ellipsis); + expect(indicator.textContent).toBe(''); + + ellipsis.click(); + await waitForLines(container, [1, 2, 3, 4, 5, 6, 7, 8]); + expect(shadow.querySelector('[data-fold-indicator]')).toBe(null); + + const unfoldedToggle = foldToggle(container, 1); + expect(shadow.activeElement).toBe(unfoldedToggle); + expect(unfoldedToggle.hasAttribute('data-folded')).toBe(false); + expect(foldIconHref(unfoldedToggle)).toBe( + '#diffs-editor-icon-chevron-down' + ); + } finally { + cleanup(); + } + }); + + test('can be disabled at construction', async () => { + const { cleanup, container } = await createFileEditorFixture({ + folding: false, + }); + try { + const shadow = shadowRoot(container); + expect(shadow.querySelector('[data-code][data-editor-folding]')).toBe( + null + ); + expect(shadow.querySelector('[data-fold]')).toBe(null); + expect(shadow.querySelector('[data-fold-toggle]')).toBe(null); + expect(renderedLineNumbers(container)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]); + } finally { + cleanup(); + } + }); + + test('responds to folding option changes at runtime', async () => { + const { cleanup, container, editor } = await createFileEditorFixture({ + folding: false, + }); + try { + editor.setOptions({ folding: true }); + await waitFor(() => foldToggle(container, 1) != null); + + foldToggle(container, 1).click(); + await waitForLines(container, [1, 7, 8]); + + editor.setOptions({ folding: false }); + await waitForLines(container, [1, 2, 3, 4, 5, 6, 7, 8]); + expect(shadowRoot(container).querySelector('[data-fold-toggle]')).toBe( + null + ); + + editor.setOptions({ folding: true }); + expect(foldToggle(container, 1)).toBeInstanceOf(HTMLButtonElement); + } finally { + cleanup(); + } + }); + + test('preserves a nested fold while its outer fold is toggled', async () => { + const { cleanup, container } = await createFileEditorFixture(); + try { + foldToggle(container, 3).click(); + await waitForLines(container, [1, 2, 3, 5, 6, 7, 8]); + + foldToggle(container, 1).click(); + await waitForLines(container, [1, 7, 8]); + + foldToggle(container, 1).click(); + await waitForLines(container, [1, 2, 3, 5, 6, 7, 8]); + + const nestedToggle = foldToggle(container, 3); + expect(nestedToggle.hasAttribute('data-folded')).toBe(true); + expect(foldIconHref(nestedToggle)).toBe( + '#diffs-editor-icon-chevron-right' + ); + + nestedToggle.click(); + await waitForLines(container, [1, 2, 3, 4, 5, 6, 7, 8]); + } finally { + cleanup(); + } + }); + + test('round-trips nested fold state and ignores stale ranges', async () => { + const { cleanup, container, editor } = await createFileEditorFixture(); + try { + foldToggle(container, 3).click(); + await waitForLines(container, [1, 2, 3, 5, 6, 7, 8]); + foldToggle(container, 1).click(); + await waitForLines(container, [1, 7, 8]); + + expect(editor.getState().foldRanges).toEqual([ + { startLine: 0, endLine: 5 }, + { startLine: 2, endLine: 3 }, + ]); + + editor.setState({ foldRanges: [] }); + await waitForLines(container, [1, 2, 3, 4, 5, 6, 7, 8]); + + editor.setState({ + foldRanges: [ + { startLine: 0, endLine: 6 }, + { startLine: 2, endLine: 3 }, + { startLine: 0, endLine: 5 }, + { startLine: 2, endLine: 4 }, + { startLine: 2, endLine: 3 }, + { startLine: -1, endLine: 5 }, + { startLine: 7, endLine: 9 }, + ], + }); + await waitForLines(container, [1, 7, 8]); + expect(editor.getState().foldRanges).toEqual([ + { startLine: 0, endLine: 5 }, + { startLine: 2, endLine: 3 }, + ]); + + foldToggle(container, 1).click(); + await waitForLines(container, [1, 2, 3, 5, 6, 7, 8]); + expect(foldToggle(container, 3).hasAttribute('data-folded')).toBe(true); + } finally { + cleanup(); + } + }); + + test('reveals a folded caret restored through setState', async () => { + const { cleanup, container, editor } = await createFileEditorFixture(); + try { + foldToggle(container, 1).click(); + await waitForLines(container, [1, 7, 8]); + + editor.setState({ + selections: [ + { + start: { line: 3, character: 4 }, + end: { line: 3, character: 4 }, + direction: 0, + }, + ], + }); + + await waitForLines(container, [1, 2, 3, 4, 5, 6, 7, 8]); + expect(editor.getState().selections?.at(-1)?.start).toEqual({ + line: 3, + character: 4, + }); + } finally { + cleanup(); + } + }); + + test('keeps a fold when restoring a caret on its visible delimiter', async () => { + const { cleanup, container, editor } = await createFileEditorFixture(); + try { + foldToggle(container, 1).click(); + await waitForLines(container, [1, 7, 8]); + + editor.setState({ + foldRanges: [{ startLine: 0, endLine: 5 }], + selections: [ + { + start: { line: 6, character: 0 }, + end: { line: 6, character: 0 }, + direction: 0, + }, + ], + }); + + await waitForLines(container, [1, 7, 8]); + expect(editor.getState().foldRanges).toEqual([ + { startLine: 0, endLine: 5 }, + ]); + expect(editor.getState().selections?.at(-1)?.start).toEqual({ + line: 6, + character: 0, + }); + } finally { + cleanup(); + } + }); + + test('keeps a fold between line-changing edits in a net-zero batch', async () => { + const contents = [ + 'const first = {', + ' value: 1,', + '};', + 'const middle = true;', + 'const second = {', + ' value: 2,', + '};', + 'removeMe();', + 'keepMe();', + ].join('\n'); + const { cleanup, container, editor } = await createFileEditorFixture( + undefined, + contents + ); + try { + foldToggle(container, 5).click(); + await waitForLines(container, [1, 2, 3, 4, 5, 7, 8, 9]); + + editor.applyEdits([ + { + range: { + start: { line: 3, character: 0 }, + end: { line: 3, character: 0 }, + }, + newText: 'inserted();\n', + }, + { + range: { + start: { line: 7, character: 0 }, + end: { line: 8, character: 0 }, + }, + newText: '', + }, + ]); + + await waitForLines(container, [1, 2, 3, 4, 5, 6, 8, 9]); + const shiftedToggle = foldToggle(container, 6); + expect(shiftedToggle.hasAttribute('data-folded')).toBe(true); + expect(foldIconHref(shiftedToggle)).toBe( + '#diffs-editor-icon-chevron-right' + ); + } finally { + cleanup(); + } + }); + + test('shifts a fold when a newline is inserted before its header', async () => { + const { cleanup, container, editor } = await createFileEditorFixture(); + try { + foldToggle(container, 1).click(); + await waitForLines(container, [1, 7, 8]); + let foldRangesDuringChange: LineRange[] | undefined; + editor.setOptions({ + onChange: () => { + foldRangesDuringChange = editor.getState().foldRanges; + }, + }); + + editor.applyEdits([ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 0 }, + }, + newText: '\n', + }, + ]); + + await waitForLines(container, [1, 2, 8, 9]); + const shiftedToggle = foldToggle(container, 2); + expect(shiftedToggle.hasAttribute('data-folded')).toBe(true); + expect(foldIconHref(shiftedToggle)).toBe( + '#diffs-editor-icon-chevron-right' + ); + expect(foldRangesDuringChange).toEqual([{ startLine: 1, endLine: 6 }]); + expect(editor.getState().foldRanges).toEqual([ + { startLine: 1, endLine: 6 }, + ]); + } finally { + cleanup(); + } + }); + + test('shifts a fold when whole lines are deleted before its header', async () => { + const { cleanup, container, editor } = await createFileEditorFixture( + undefined, + `removeMe();\n${FOLDABLE_CONTENTS}` + ); + try { + foldToggle(container, 2).click(); + await waitForLines(container, [1, 2, 8, 9]); + + editor.applyEdits([ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 1, character: 0 }, + }, + newText: '', + }, + ]); + + await waitForLines(container, [1, 7, 8]); + expect(foldToggle(container, 1).hasAttribute('data-folded')).toBe(true); + expect(editor.getState().foldRanges).toEqual([ + { startLine: 0, endLine: 5 }, + ]); + } finally { + cleanup(); + } + }); + + test('retains active fold controls across ordinary character edits', async () => { + const { cleanup, container, editor, file } = + await createFileEditorFixture(); + try { + foldToggle(container, 1).click(); + await waitForLines(container, [1, 7, 8]); + await wait(0); + + const outerToggle = foldToggle(container, 1); + const outerIndicator = foldIndicator(container, 1); + const foldRangeUpdates = recordFoldRangeUpdates(file); + + editor.applyEdits([ + { + range: { + start: { line: 0, character: 9 }, + end: { line: 0, character: 14 }, + }, + newText: 'inner', + }, + ]); + + await waitFor(() => editor.getText().includes('function inner() {')); + expect(foldToggle(container, 1)).toBe(outerToggle); + expect(foldIndicator(container, 1)).toBe(outerIndicator); + expect(outerToggle.isConnected).toBe(true); + expect(outerToggle.hasAttribute('data-folded')).toBe(true); + expect(foldRangeUpdates).toEqual([]); + } finally { + cleanup(); + } + }); + + test('recomputes a fold when its closing delimiter gains a suffix', async () => { + const { cleanup, container, editor } = await createFileEditorFixture( + undefined, + ['section {', ' child', '', '}', 'after'].join('\n') + ); + try { + foldToggle(container, 1).click(); + await waitForLines(container, [1, 4, 5]); + + editor.applyEdits([ + { + range: { + start: { line: 3, character: 1 }, + end: { line: 3, character: 1 }, + }, + newText: ' // comment', + }, + ]); + + await waitForLines(container, [1, 3, 4, 5]); + expect(editor.getState().foldRanges).toEqual([ + { startLine: 0, endLine: 1 }, + ]); + } finally { + cleanup(); + } + }); + + test('syncs each fold toggle to the host once', async () => { + const { cleanup, container, file } = await createFileEditorFixture(); + try { + const foldRangeUpdates = recordFoldRangeUpdates(file); + + foldToggle(container, 1).click(); + await waitForLines(container, [1, 7, 8]); + await wait(0); + expect(foldRangeUpdates).toEqual([[{ startLine: 1, endLine: 5 }]]); + + foldRangeUpdates.length = 0; + foldToggle(container, 1).click(); + await waitForLines(container, [1, 2, 3, 4, 5, 6, 7, 8]); + await wait(0); + expect(foldRangeUpdates).toEqual([[]]); + } finally { + cleanup(); + } + }); +}); + +describe('editor folding on FileDiff', () => { + test('does not render fold controls even when folding is enabled', async () => { + const dom = installDom(); + const container = document.createElement('div'); + document.body.appendChild(container); + const fileDiff = new FileDiff({ + disableFileHeader: true, + diffStyle: 'split', + theme: DEFAULT_THEMES, + }); + const editor = new Editor({ folding: true }); + const oldFile: FileContents = { + name: 'foldable.ts', + contents: FOLDABLE_CONTENTS, + }; + const newFile: FileContents = { + name: 'foldable.ts', + contents: FOLDABLE_CONTENTS.replace( + ' return before;', + ' return before + 1;' + ), + }; + + try { + fileDiff.render({ + oldFile, + newFile, + fileContainer: container, + forceRender: true, + }); + editor.edit(fileDiff); + await waitForEditableContent(container); + + const shadow = shadowRoot(container); + expect(shadow.querySelector('[data-code][data-editor-folding]')).toBe( + null + ); + expect(shadow.querySelector('[data-fold]')).toBe(null); + expect(shadow.querySelector('[data-fold-toggle]')).toBe(null); + + editor.setOptions({ folding: false }); + editor.setOptions({ folding: true }); + expect(shadow.querySelector('[data-fold-toggle]')).toBe(null); + } finally { + await wait(10); + editor.cleanUp(); + fileDiff.cleanUp(); + dom.cleanup(); + } + }); +}); diff --git a/packages/diffs/test/editorFoldingRanges.test.ts b/packages/diffs/test/editorFoldingRanges.test.ts new file mode 100644 index 000000000..d0881aa1a --- /dev/null +++ b/packages/diffs/test/editorFoldingRanges.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, test } from 'bun:test'; + +import { + computeIndentFoldingRanges, + LineRangeIndex, + mergeHiddenLineRanges, +} from '../src/editor/folding'; +import { TextDocument } from '../src/editor/textDocument'; + +function document(text: string) { + return new TextDocument('inmemory://folding', text); +} + +describe('computeIndentFoldingRanges', () => { + test('returns nested ranges in start-line order and excludes standalone closing delimiters', () => { + const ranges = computeIndentFoldingRanges( + document( + ['const value = {', ' nested: {', ' ok: true,', ' },', '};'].join( + '\n' + ) + ) + ); + + expect(ranges).toEqual([ + { startLine: 0, endLine: 3 }, + { startLine: 1, endLine: 2 }, + ]); + }); + + test('uses the next nonblank line and excludes trailing blank lines', () => { + const ranges = computeIndentFoldingRanges( + document(['section', '', ' child', '', 'sibling', '', ''].join('\n')) + ); + + expect(ranges).toEqual([{ startLine: 0, endLine: 2 }]); + }); + + test('includes blank lines before a visible closing delimiter', () => { + const ranges = computeIndentFoldingRanges( + document(['section {', ' child', '', '}'].join('\n')) + ); + + expect(ranges).toEqual([{ startLine: 0, endLine: 2 }]); + }); + + test('uses tab stops when comparing indentation', () => { + const ranges = computeIndentFoldingRanges( + document(['root', '\tchild', ' sibling', 'next'].join('\n')), + 4 + ); + + expect(ranges).toEqual([{ startLine: 0, endLine: 2 }]); + }); + + test('returns no range when nonblank indentation never increases', () => { + expect( + computeIndentFoldingRanges(document(['first', '', 'second'].join('\n'))) + ).toEqual([]); + }); + + test('only emits ranges that contain at least one child line', () => { + const ranges = computeIndentFoldingRanges( + document(['root', ' child', 'next', ' final child'].join('\n')) + ); + + expect(ranges).toEqual([ + { startLine: 0, endLine: 1 }, + { startLine: 2, endLine: 3 }, + ]); + expect(ranges.every((range) => range.endLine > range.startLine)).toBeTrue(); + }); +}); + +describe('mergeHiddenLineRanges', () => { + test('merges the bodies of selected overlapping and adjacent folds', () => { + const hiddenRanges = mergeHiddenLineRanges( + [ + { startLine: 0, endLine: 4 }, + { startLine: 2, endLine: 6 }, + { startLine: 6, endLine: 8 }, + { startLine: 10, endLine: 10 }, + { startLine: 12, endLine: 14 }, + ], + new Set([0, 2, 6, 10]) + ); + + expect(hiddenRanges).toEqual([{ startLine: 1, endLine: 8 }]); + }); +}); + +describe('LineRangeIndex', () => { + const index = new LineRangeIndex([ + { startLine: 7, endLine: 8 }, + { startLine: 3, endLine: 4 }, + { startLine: 2, endLine: 3 }, + ]); + + test('finds hidden lines and their merged containing range', () => { + expect(index.isHidden(1)).toBe(false); + expect(index.isHidden(2)).toBe(true); + expect(index.isHidden(4)).toBe(true); + expect(index.isHidden(5)).toBe(false); + expect(index.containingRange(3)).toEqual({ startLine: 2, endLine: 4 }); + expect(index.containingRange(6)).toBeUndefined(); + }); + + test('jumps past a hidden range without creating a range object', () => { + expect(index.lineAfterHiddenRange(1)).toBeUndefined(); + expect(index.lineAfterHiddenRange(2)).toBe(5); + expect(index.lineAfterHiddenRange(4)).toBe(5); + expect(index.lineAfterHiddenRange(7)).toBe(9); + expect(index.lineAfterHiddenRange(9)).toBeUndefined(); + }); + + test('normalizes ranges that are already ordered', () => { + const orderedIndex = new LineRangeIndex([ + { startLine: 2, endLine: 3 }, + { startLine: 3, endLine: 5 }, + { startLine: 8, endLine: 9 }, + ]); + + expect(orderedIndex.containingRange(4)).toEqual({ + startLine: 2, + endLine: 5, + }); + expect(orderedIndex.lineAtVisibleIndex(2, 12)).toBe(6); + }); + + test('counts hidden and visible lines at boundaries', () => { + expect(index.hiddenCountBefore(0)).toBe(0); + expect(index.hiddenCountBefore(2)).toBe(0); + expect(index.hiddenCountBefore(3)).toBe(1); + expect(index.hiddenCountBefore(5)).toBe(3); + expect(index.hiddenCountBefore(7)).toBe(3); + expect(index.hiddenCountBefore(8)).toBe(4); + expect(index.hiddenCountBefore(9)).toBe(5); + expect(index.visibleLineCount(12)).toBe(7); + }); + + test('maps visible indexes back to document lines', () => { + expect( + Array.from({ length: index.visibleLineCount(12) }, (_, visibleIndex) => + index.lineAtVisibleIndex(visibleIndex, 12) + ) + ).toEqual([0, 1, 5, 6, 9, 10, 11]); + expect(index.lineAtVisibleIndex(-1, 12)).toBeUndefined(); + expect(index.lineAtVisibleIndex(7, 12)).toBeUndefined(); + }); + + test('finds the nearest visible line in the requested direction', () => { + expect(index.nearestVisibleLine(3, 'up', 12)).toBe(1); + expect(index.nearestVisibleLine(3, 'down', 12)).toBe(5); + expect(index.nearestVisibleLine(5, 'up', 12)).toBe(5); + expect(index.nearestVisibleLine(7, 'up', 12)).toBe(6); + expect(index.nearestVisibleLine(7, 'down', 12)).toBe(9); + expect(index.nearestVisibleLine(-1, 'down', 12)).toBe(0); + expect(index.nearestVisibleLine(-1, 'up', 12)).toBeUndefined(); + expect(index.nearestVisibleLine(12, 'up', 12)).toBe(11); + expect(index.nearestVisibleLine(12, 'down', 12)).toBeUndefined(); + }); + + test('returns undefined when a hidden edge has no visible line beyond it', () => { + const hiddenStart = new LineRangeIndex([{ startLine: 0, endLine: 2 }]); + const hiddenEnd = new LineRangeIndex([{ startLine: 9, endLine: 20 }]); + + expect(hiddenStart.nearestVisibleLine(1, 'up', 12)).toBeUndefined(); + expect(hiddenStart.nearestVisibleLine(1, 'down', 12)).toBe(3); + expect(hiddenEnd.nearestVisibleLine(10, 'up', 12)).toBe(8); + expect(hiddenEnd.nearestVisibleLine(10, 'down', 12)).toBeUndefined(); + }); +}); diff --git a/packages/diffs/test/editorPersistStateLifecycle.test.ts b/packages/diffs/test/editorPersistStateLifecycle.test.ts index ae2a13bae..63adb764c 100644 --- a/packages/diffs/test/editorPersistStateLifecycle.test.ts +++ b/packages/diffs/test/editorPersistStateLifecycle.test.ts @@ -4,7 +4,7 @@ import { File } from '../src/components/File'; import { DEFAULT_THEMES } from '../src/constants'; import { Editor, type IStateStorage } from '../src/editor'; import { disposeHighlighter } from '../src/highlighter/shared_highlighter'; -import type { EditorState, FileContents } from '../src/types'; +import type { EditorState, FileContents, LineRange } from '../src/types'; import { installDom, wait, waitFor } from './domHarness'; afterAll(async () => { @@ -17,6 +17,21 @@ const ORIGINAL_FILE: FileContents = { cacheKey: 'persisted.ts', }; +const FOLDABLE_FILE: FileContents = { + name: 'foldable.ts', + contents: [ + 'function outer() {', + ' const before = 1;', + ' if (before) {', + ' console.log(before);', + ' }', + ' return before;', + '}', + 'const after = true;', + ].join('\n'), + cacheKey: 'foldable.ts', +}; + interface AttachedFile { container: HTMLElement; file: File; @@ -96,7 +111,110 @@ function savedCaret(character: number): EditorState { }; } +function foldToggle( + attached: AttachedFile, + oneIndexedLine: number +): HTMLButtonElement { + const toggle = attached.container.shadowRoot?.querySelector( + `[data-column-number="${oneIndexedLine}"] [data-fold-toggle]` + ); + if (!(toggle instanceof HTMLButtonElement)) { + throw new Error(`no fold toggle found for line ${oneIndexedLine}`); + } + return toggle; +} + +function renderedLineNumbers(attached: AttachedFile): number[] { + return [ + ...(attached.container.shadowRoot?.querySelectorAll( + '[data-content] > [data-line]' + ) ?? []), + ].map((line) => Number(line.dataset.line)); +} + +async function waitForFoldRanges( + editor: Editor, + expected: LineRange[] +): Promise { + await waitFor( + () => + JSON.stringify(editor.getState().foldRanges) === JSON.stringify(expected) + ); + expect(editor.getState().foldRanges).toEqual(expected); +} + describe('Editor persisted state lifecycle', () => { + test('restores nested folds after switching files', async () => { + const dom = installDom(); + const editor = new Editor({ persistState: true }); + let attached: AttachedFile | undefined; + const nestedFold = { startLine: 2, endLine: 3 }; + const outerFold = { startLine: 0, endLine: 5 }; + + try { + attached = await attachFile(editor, { ...FOLDABLE_FILE }); + + foldToggle(attached, 3).click(); + await waitForFoldRanges(editor, [nestedFold]); + foldToggle(attached, 1).click(); + await waitForFoldRanges(editor, [outerFold, nestedFold]); + + await renderFile(editor, attached, { + name: 'next.ts', + contents: 'next\n', + cacheKey: 'next.ts', + }); + await renderFile(editor, attached, { ...FOLDABLE_FILE }); + + await waitForFoldRanges(editor, [outerFold, nestedFold]); + expect(renderedLineNumbers(attached)).toEqual([1, 7, 8]); + + foldToggle(attached, 1).click(); + await waitForFoldRanges(editor, [nestedFold]); + expect(renderedLineNumbers(attached)).toEqual([1, 2, 3, 5, 6, 7, 8]); + } finally { + editor.cleanUp(); + attached?.file.cleanUp(); + dom.cleanup(); + } + }); + + test('a pending async restore cannot overwrite a newer fold toggle', async () => { + const dom = installDom(); + const pendingState = createDeferred(); + const storage: IStateStorage = { + get() { + return pendingState.promise; + }, + set() {}, + }; + const editor = new Editor({ + persistState: true, + persistStateStorage: storage, + }); + let attached: AttachedFile | undefined; + const nestedFold = { startLine: 2, endLine: 3 }; + + try { + attached = await attachFile(editor, { ...FOLDABLE_FILE }); + + foldToggle(attached, 3).click(); + await waitForFoldRanges(editor, [nestedFold]); + + pendingState.resolve({ + foldRanges: [{ startLine: 0, endLine: 5 }], + }); + await wait(0); + + expect(editor.getState().foldRanges).toEqual([nestedFold]); + expect(renderedLineNumbers(attached)).toEqual([1, 2, 3, 5, 6, 7, 8]); + } finally { + editor.cleanUp(); + attached?.file.cleanUp(); + dom.cleanup(); + } + }); + test('an async restore survives an unchanged file rerender', async () => { const dom = installDom(); const pendingState = createDeferred(); diff --git a/packages/diffs/test/editorSelection.test.ts b/packages/diffs/test/editorSelection.test.ts index 9b51f65f4..089fa3e26 100644 --- a/packages/diffs/test/editorSelection.test.ts +++ b/packages/diffs/test/editorSelection.test.ts @@ -375,6 +375,30 @@ describe('convertSelection', () => { ); }); + test('maps an inline fold indicator to the folded header end', () => { + const icon = element('SVG', [element('USE')]); + const ellipsis = element('BUTTON', [icon]); + const indicator = element('SPAN', [ellipsis]); + indicator.dataset.foldIndicator = ''; + indicator.dataset.foldCharacter = '18'; + const renderedLine = pre(6, [span('function outer() {', 0), indicator]); + + expect( + convertSelection(composedRange(renderedLine as unknown as Node, 2)) + ).toEqual({ + start: { line: 6, character: 18 }, + end: { line: 6, character: 18 }, + direction: DirectionNone, + }); + expect( + convertSelection(composedRange(ellipsis as unknown as Node, 0)) + ).toEqual({ + start: { line: 6, character: 18 }, + end: { line: 6, character: 18 }, + direction: DirectionNone, + }); + }); + test('maps a text node inside a nested diff-span token', () => { const diffToken = span('_diff', 15); const diff = diffSpan(diffToken, span(':', 20), span(' FileMetadata', 22)); From 5aaaa3751c7689ad4c33ca776c44fd63809b3d5f Mon Sep 17 00:00:00 2001 From: Je Xia Date: Fri, 17 Jul 2026 01:59:28 +0800 Subject: [PATCH 3/5] fix e2e testing --- packages/diffs/test/e2e/folding.pw.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/packages/diffs/test/e2e/folding.pw.ts b/packages/diffs/test/e2e/folding.pw.ts index 3af2cec48..f1e4072e7 100644 --- a/packages/diffs/test/e2e/folding.pw.ts +++ b/packages/diffs/test/e2e/folding.pw.ts @@ -1,4 +1,4 @@ -import { expect, type Locator, type Page, test } from '@playwright/test'; +import { expect, type Page, test } from '@playwright/test'; const GUTTER = '[data-code][data-editor-folding] [data-gutter]'; const CONTENT_ROWS = '[data-content] > [data-line]'; @@ -18,9 +18,6 @@ const renderedLineNumbers = (page: Page): Promise => rows.map((row) => Number((row as HTMLElement).dataset.line)) ); -const color = (toggle: Locator): Promise => - toggle.evaluate((element) => getComputedStyle(element).color); - const firstTokenColor = (page: Page): Promise => page .locator('[data-content] > [data-line="1"] [data-char]') @@ -42,12 +39,10 @@ test.describe('editor folding controls', () => { await expect(toggle).toHaveCSS('opacity', '0'); await gutter.hover(); - await expect(toggle).toHaveCSS('opacity', '0.72'); - const gutterHoverColor = await color(toggle); + await expect(toggle).toHaveCSS('opacity', '0.5'); await toggle.hover(); - await expect(toggle).toHaveCSS('opacity', '1'); - await expect.poll(() => color(toggle)).not.toBe(gutterHoverColor); + await expect(toggle).toHaveCSS('opacity', '0.75'); await toggle.click(); await expect.poll(() => renderedLineNumbers(page)).toEqual([1, 7, 8]); @@ -59,7 +54,7 @@ test.describe('editor folding controls', () => { 'href', '#diffs-editor-icon-chevron-right' ); - await expect(toggle).toHaveCSS('opacity', '0.72'); + await expect(toggle).toHaveCSS('opacity', '0.5'); const indicator = page.locator(OUTER_INDICATOR); const ellipsis = indicator.locator('[data-fold-ellipsis]'); From c8fab1d2e65eeed448ad5460b5c3c5f35b50cb80 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Wed, 22 Jul 2026 10:18:49 +0800 Subject: [PATCH 4/5] fix codex --- packages/diffs/src/renderers/FileRenderer.ts | 66 ++++++++++---- packages/diffs/src/types.ts | 1 + .../src/utils/renderFileWithHighlighter.ts | 91 +++++++++++++------ .../diffs/src/worker/WorkerPoolManager.ts | 12 ++- .../test/VirtualizedFile.folding.test.ts | 70 ++++++++++++-- 5 files changed, 184 insertions(+), 56 deletions(-) diff --git a/packages/diffs/src/renderers/FileRenderer.ts b/packages/diffs/src/renderers/FileRenderer.ts index 268b8ec3d..075787c2e 100644 --- a/packages/diffs/src/renderers/FileRenderer.ts +++ b/packages/diffs/src/renderers/FileRenderer.ts @@ -148,6 +148,10 @@ export class FileRenderer { public setFoldRanges(ranges: LineRange[]): void { this.foldRanges = ranges; + if (this.renderCache?.highlighted === false) { + this.renderCache.result = undefined; + this.renderCache.renderRange = undefined; + } } public cleanUp(): void { @@ -491,7 +495,8 @@ export class FileRenderer { file, renderRange.startingLine, renderRange.totalLines, - lines + lines, + this.foldRanges ); } this.renderCache.renderRange = renderRange; @@ -521,20 +526,23 @@ export class FileRenderer { hasThemes && (forceHighlight || forcePlainText || + (!this.renderCache.highlighted && newRenderRange) || (!this.renderCache.highlighted && canHighlight) || this.renderCache.result == null) ) { + const renderedPlainText = forcePlainText || !hasLangs; const { result, options } = this.renderFileWithHighlighter( file, this.highlighter, - forcePlainText || !hasLangs + renderedPlainText, + renderedPlainText ? renderRange : undefined ); this.renderCache = { file, options, highlighted: canHighlight, result, - renderRange: undefined, + renderRange: renderedPlainText ? renderRange : undefined, }; } @@ -542,14 +550,22 @@ export class FileRenderer { // process which will involve initializing the highlighter with new themes // and languages if (!hasThemes || (!forcePlainText && !hasLangs)) { - void this.asyncHighlight(file).then(({ result, options }) => { - // In this case we need to force a re-render, so we can do that by - // reaching into renderCache - if (this.renderCache != null) { - this.renderCache.highlighted = false; + void this.asyncHighlight(file, renderRange).then( + ({ result, options }) => { + // In this case we need to force a re-render, so we can do that by + // reaching into renderCache + if (this.renderCache != null) { + this.renderCache.highlighted = false; + } + this.onHighlightSuccess( + file, + result, + options, + !forcePlainText, + renderRange + ); } - this.onHighlightSuccess(file, result, options, !forcePlainText); - }); + ); } } @@ -566,16 +582,19 @@ export class FileRenderer { file: FileContents, renderRange: RenderRange = DEFAULT_RENDER_RANGE ): Promise { - const { result } = await this.asyncHighlight(file); + const { result } = await this.asyncHighlight(file, renderRange); return this.processFileResult(file, renderRange, result); } - private async asyncHighlight(file: FileContents): Promise { + private async asyncHighlight( + file: FileContents, + renderRange?: RenderRange + ): Promise { const lines = this.getOrCreateLineCache(file); - const forcePlainText = isFileMassive( - lines.length, - this.getTokenizeMaxLength() - ); + const forcePlainText = + file.contents.length === 0 || + isFilePlainText(file) || + isFileMassive(lines.length, this.getTokenizeMaxLength()); this.computedLang = forcePlainText ? 'text' : (file.lang ?? getFiletypeFromFileName(file.name)); @@ -593,18 +612,24 @@ export class FileRenderer { return this.renderFileWithHighlighter( file, this.highlighter, - forcePlainText + forcePlainText, + forcePlainText ? renderRange : undefined ); } private renderFileWithHighlighter( file: FileContents, highlighter: DiffsHighlighter, - forcePlainText = false + forcePlainText = false, + renderRange?: RenderRange ): RenderFileResult { const { options } = this.getRenderOptions(file); const result = renderFileWithHighlighter(file, highlighter, options, { forcePlainText, + startingLine: renderRange?.startingLine, + totalLines: renderRange?.totalLines, + lines: renderRange == null ? undefined : this.getOrCreateLineCache(file), + hiddenLineRanges: renderRange == null ? undefined : this.foldRanges, }); return { result, options }; } @@ -792,7 +817,8 @@ export class FileRenderer { file: FileContents, result: ThemedFileResult, options: RenderFileOptions, - highlighted = true + highlighted = true, + renderRange?: RenderRange ): void { if (this.renderCache == null) { return; @@ -807,7 +833,7 @@ export class FileRenderer { options, highlighted, result, - renderRange: undefined, + renderRange: highlighted ? undefined : renderRange, }; if (triggerRenderUpdate) { diff --git a/packages/diffs/src/types.ts b/packages/diffs/src/types.ts index f71a97878..08ce4ce13 100644 --- a/packages/diffs/src/types.ts +++ b/packages/diffs/src/types.ts @@ -793,6 +793,7 @@ export interface ForceFilePlainTextOptions { totalLines?: number; // Pre-split lines for caching in windowing scenarios lines?: string[]; + hiddenLineRanges?: readonly LineRange[]; } export interface RenderFileOptions { diff --git a/packages/diffs/src/utils/renderFileWithHighlighter.ts b/packages/diffs/src/utils/renderFileWithHighlighter.ts index cfcd23c7f..b50195063 100644 --- a/packages/diffs/src/utils/renderFileWithHighlighter.ts +++ b/packages/diffs/src/utils/renderFileWithHighlighter.ts @@ -32,6 +32,7 @@ export function renderFileWithHighlighter( startingLine, totalLines, lines, + hiddenLineRanges, }: ForceFilePlainTextOptions = DEFAULT_PLAIN_TEXT_OPTIONS ): ThemedFileResult { if (forcePlainText) { @@ -46,6 +47,45 @@ export function renderFileWithHighlighter( totalLines = Infinity; } const isWindowedHighlight = startingLine > 0 || totalLines < Infinity; + let windowEndLine = startingLine; + let renderedLineIndexes: number[] | undefined; + let contents = file.contents; + if (isWindowedHighlight) { + const sourceLines = lines ?? linesFromFileContents(file.contents); + windowEndLine = Math.min(startingLine + totalLines, sourceLines.length); + if (hiddenLineRanges != null && hiddenLineRanges.length > 0) { + renderedLineIndexes = []; + const renderedLines: string[] = []; + let low = 0; + let high = hiddenLineRanges.length; + while (low < high) { + const middle = low + ((high - low) >> 1); + if (hiddenLineRanges[middle].endLine < startingLine) { + low = middle + 1; + } else { + high = middle; + } + } + let foldedRangeIndex = low; + for (let lineIndex = startingLine; lineIndex < windowEndLine; ) { + while (hiddenLineRanges[foldedRangeIndex]?.endLine < lineIndex) { + foldedRangeIndex++; + } + const foldedRange = hiddenLineRanges[foldedRangeIndex]; + if (foldedRange != null && lineIndex >= foldedRange.startLine) { + lineIndex = foldedRange.endLine + 1; + foldedRangeIndex++; + continue; + } + renderedLineIndexes.push(lineIndex); + renderedLines.push(sourceLines[lineIndex] ?? ''); + lineIndex++; + } + contents = renderedLines.join(''); + } else { + contents = sourceLines.slice(startingLine, windowEndLine).join(''); + } + } const { state, transformers } = createTransformerWithState(useTokenTransformer); const lang = forcePlainText @@ -57,11 +97,18 @@ export function renderFileWithHighlighter( theme, highlighter, }); - state.lineInfo = (shikiLineNumber: number) => ({ - type: 'context', - lineIndex: shikiLineNumber - 1 + startingLine, - lineNumber: shikiLineNumber + startingLine, - }); + state.lineInfo = (shikiLineNumber: number) => { + const lineIndex = + renderedLineIndexes?.[shikiLineNumber - 1] ?? + (renderedLineIndexes == null + ? shikiLineNumber - 1 + startingLine + : windowEndLine); + return { + type: 'context', + lineIndex, + lineNumber: lineIndex + 1, + }; + }; // tokenizeTimeLimit: 0 disables shiki's silent 500ms-per-line tokenization // abort. When it trips (slow devices, cold JS-regex-engine compile), the // rest of the line collapses to the enclosing scope's color — and since @@ -91,35 +138,23 @@ export function renderFileWithHighlighter( }; })(); const highlightedLines = getLineNodes( - highlighter.codeToHast( - isWindowedHighlight - ? extractWindowedFileContent( - lines ?? linesFromFileContents(file.contents), - startingLine, - totalLines - ) - : file.contents, - hastConfig - ) + highlighter.codeToHast(contents, hastConfig) ); // Create sparse array for windowed rendering const code = isWindowedHighlight ? new Array(startingLine) : highlightedLines; if (isWindowedHighlight) { - code.push(...highlightedLines); + if (renderedLineIndexes == null) { + code.push(...highlightedLines); + } else { + for (let index = 0; index < renderedLineIndexes.length; index++) { + const line = highlightedLines[index]; + if (line != null) { + code[renderedLineIndexes[index]] = line; + } + } + } } return { code, themeStyles, baseThemeType }; } - -function extractWindowedFileContent( - lines: string[], - startingLine: number, - totalLines: number -): string { - if (lines.length === 0) { - return ''; - } - const endLine = Math.min(startingLine + totalLines, lines.length); - return lines.slice(startingLine, endLine).join(''); -} diff --git a/packages/diffs/src/worker/WorkerPoolManager.ts b/packages/diffs/src/worker/WorkerPoolManager.ts index 5cc968fef..de24ca5bf 100644 --- a/packages/diffs/src/worker/WorkerPoolManager.ts +++ b/packages/diffs/src/worker/WorkerPoolManager.ts @@ -15,6 +15,7 @@ import type { FileDiffMetadata, HighlighterTypes, HunkExpansionRegion, + LineRange, RenderDiffOptions, RenderDiffResult, RenderFileOptions, @@ -633,7 +634,8 @@ export class WorkerPoolManager { file: FileContents, startingLine: number, totalLines: number, - lines?: string[] + lines?: string[], + hiddenLineRanges?: readonly LineRange[] ): ThemedFileResult | undefined { if (this.highlighter == null) { this.queueInitialization(); @@ -643,7 +645,13 @@ export class WorkerPoolManager { file, this.highlighter, this.renderOptions, - { forcePlainText: true, startingLine, totalLines, lines } + { + forcePlainText: true, + startingLine, + totalLines, + lines, + hiddenLineRanges, + } ); } diff --git a/packages/diffs/test/VirtualizedFile.folding.test.ts b/packages/diffs/test/VirtualizedFile.folding.test.ts index ecfd7e84e..0ce88739f 100644 --- a/packages/diffs/test/VirtualizedFile.folding.test.ts +++ b/packages/diffs/test/VirtualizedFile.folding.test.ts @@ -1,13 +1,18 @@ -import { describe, expect, test } from 'bun:test'; +import { afterAll, describe, expect, test } from 'bun:test'; import { VirtualizedFile } from '../src/components/VirtualizedFile'; -import { DEFAULT_VIRTUAL_FILE_METRICS } from '../src/constants'; +import { DEFAULT_THEMES, DEFAULT_VIRTUAL_FILE_METRICS } from '../src/constants'; +import { + disposeHighlighter, + getSharedHighlighter, +} from '../src/highlighter/shared_highlighter'; import type { FileContents, RenderRange, RenderWindow, VirtualFileMetrics, } from '../src/types'; +import { WorkerPoolManager } from '../src/worker/WorkerPoolManager'; const metrics: VirtualFileMetrics = { ...DEFAULT_VIRTUAL_FILE_METRICS, @@ -26,6 +31,13 @@ interface InspectableVirtualizedFile { editorFoldedLineIndex: { isHidden(lineIndex: number): boolean; }; + fileRenderer: { + renderCache?: { result?: { code: unknown[] } }; + renderFile( + file: FileContents, + renderRange: RenderRange + ): { rowCount: number } | undefined; + }; renderRange: RenderRange | undefined; computeApproximateSize(force?: boolean): void; computeRenderRangeFromWindow( @@ -35,6 +47,10 @@ interface InspectableVirtualizedFile { ): RenderRange; } +afterAll(async () => { + await disposeHighlighter(); +}); + function inspect(instance: VirtualizedFile): InspectableVirtualizedFile { return instance as unknown as InspectableVirtualizedFile; } @@ -160,13 +176,31 @@ describe('VirtualizedFile editor folding', () => { expect(instance.getVirtualizedHeight()).toBe(211); }); - test('jumps large folded bodies in variable-height layout scans', () => { + test('jumps large folded bodies in layout and plain render windows', async () => { const lineCount = 20_000; const file = createFile(lineCount); + const renderOptions = { + theme: DEFAULT_THEMES, + useTokenTransformer: false, + tokenizeMaxLineLength: 1_000, + }; + const workerManager = { + highlighter: await getSharedHighlighter({ + themes: Object.values(DEFAULT_THEMES), + langs: ['text'], + }), + renderOptions, + getPlainFileAST: WorkerPoolManager.prototype.getPlainFileAST, + getFileRenderOptions: () => renderOptions, + getFileResultCache: () => undefined, + isWorkingPool: () => true, + subscribeToThemeChanges() {}, + } as unknown as WorkerPoolManager; const instance = new VirtualizedFile( - { overflow: 'wrap' }, + { overflow: 'wrap', tokenizeMaxLength: 1 }, createVirtualizer([]), - metrics + metrics, + workerManager ); instance.prepareCodeViewItem(file, 0); instance.__setFoldRanges([{ startLine: 1, endLine: 19_990 }]); @@ -197,9 +231,26 @@ describe('VirtualizedFile editor folding', () => { top: 40, bottom: 60, }); - expect(range.startingLine).toBe(0); + expect(range).toEqual({ + startingLine: 0, + totalLines: 19_996, + bufferBefore: 0, + bufferAfter: 40, + }); expect(hiddenChecks).toBeLessThan(10); + const result = layout.fileRenderer.renderFile(file, range); + const code = layout.fileRenderer.renderCache?.result?.code; + expect(result?.rowCount).toBe(6); + expect(Object.keys(code ?? [])).toEqual([ + '0', + '19991', + '19992', + '19993', + '19994', + '19995', + ]); + hiddenChecks = 0; layout.renderRange = { startingLine: 0, @@ -212,5 +263,12 @@ describe('VirtualizedFile editor folding', () => { top: 50, }); expect(hiddenChecks).toBe(0); + + instance.__setFoldRanges([]); + const unfoldedResult = layout.fileRenderer.renderFile(file, range); + const unfoldedCode = layout.fileRenderer.renderCache?.result?.code; + expect(unfoldedResult?.rowCount).toBe(19_996); + expect(unfoldedCode?.[1]).toBeDefined(); + expect(unfoldedCode?.[19_995]).toBeDefined(); }); }); From 37d9faee4350ed6101038eab1ba07c9dafbbfad2 Mon Sep 17 00:00:00 2001 From: Je Xia Date: Fri, 24 Jul 2026 00:45:24 +0800 Subject: [PATCH 5/5] Add FoldingManager --- apps/demo/src/codeViewDemo.ts | 1 + apps/demo/src/main.ts | 2 + .../app/(diffs)/_examples/Folding/Folding.tsx | 92 +++ .../(diffs)/_examples/Folding/constants.ts | 87 +++ apps/docs/app/(diffs)/_home/Home.tsx | 20 + .../app/(diffs)/docs/CodeView/content.mdx | 2 + apps/docs/app/(diffs)/docs/Editor/content.mdx | 4 + .../app/(diffs)/docs/ReactAPI/constants.ts | 11 + .../app/(diffs)/docs/ReactAPI/content.mdx | 7 + .../app/(diffs)/docs/VanillaAPI/constants.ts | 11 + .../app/(diffs)/docs/VanillaAPI/content.mdx | 7 + packages/diffs/README.md | 1 + packages/diffs/src/components/CodeView.ts | 3 + packages/diffs/src/components/File.ts | 60 ++ packages/diffs/src/components/FileDiff.ts | 219 ++++++- .../diffs/src/components/UnresolvedFile.ts | 2 +- .../diffs/src/components/VirtualizedFile.ts | 33 +- .../src/components/VirtualizedFileDiff.ts | 482 +++++++++++++-- packages/diffs/src/editor/editor.css | 102 --- packages/diffs/src/editor/editor.ts | 62 +- packages/diffs/src/managers/FoldingManager.ts | 509 +++++++++++++++ .../diffs/src/renderers/DiffHunksRenderer.ts | 133 +++- packages/diffs/src/sprite.ts | 12 + packages/diffs/src/style.css | 102 +++ packages/diffs/src/types.ts | 8 +- .../test/VirtualizedFile.folding.test.ts | 45 ++ packages/diffs/test/e2e/fixtures/folding.html | 125 +++- packages/diffs/test/e2e/fixtures/index.html | 7 +- packages/diffs/test/e2e/folding.pw.ts | 155 ++++- packages/diffs/test/editorFolding.test.ts | 584 ++++++++++++++++-- ...irtualizedFileDiffEstimatedHeights.test.ts | 90 +++ 31 files changed, 2625 insertions(+), 353 deletions(-) create mode 100644 apps/docs/app/(diffs)/_examples/Folding/Folding.tsx create mode 100644 apps/docs/app/(diffs)/_examples/Folding/constants.ts create mode 100644 packages/diffs/src/managers/FoldingManager.ts diff --git a/apps/demo/src/codeViewDemo.ts b/apps/demo/src/codeViewDemo.ts index 27ba2d8e7..382625dbc 100644 --- a/apps/demo/src/codeViewDemo.ts +++ b/apps/demo/src/codeViewDemo.ts @@ -98,6 +98,7 @@ export function renderDemoCodeView( theme, themeType, diffStyle, + folding: true, overflow, loadDiffFiles, renderAnnotation(annotation) { diff --git a/apps/demo/src/main.ts b/apps/demo/src/main.ts index df8115565..ff053ef46 100644 --- a/apps/demo/src/main.ts +++ b/apps/demo/src/main.ts @@ -421,6 +421,7 @@ function renderDiff(parsedPatches: ParsedPatch[], manager?: WorkerPoolManager) { theme: DEMO_THEME, themeType, diffStyle: unified ? 'unified' : 'split', + folding: true, overflow: wrap ? 'wrap' : 'scroll', renderAnnotation: renderDiffAnnotation, ...(RENDER_FILENAME_SUFFIX @@ -1064,6 +1065,7 @@ if (renderFileButton != null) { overflow: wrap ? 'wrap' : 'scroll', theme: DEMO_THEME, themeType: getThemeType(), + folding: true, renderAnnotation, ...(RENDER_FILENAME_SUFFIX ? { diff --git a/apps/docs/app/(diffs)/_examples/Folding/Folding.tsx b/apps/docs/app/(diffs)/_examples/Folding/Folding.tsx new file mode 100644 index 000000000..f34123546 --- /dev/null +++ b/apps/docs/app/(diffs)/_examples/Folding/Folding.tsx @@ -0,0 +1,92 @@ +'use client'; + +import { File, FileDiff } from '@pierre/diffs/react'; +import type { + PreloadedFileResult, + PreloadFileDiffResult, +} from '@pierre/diffs/ssr'; +import { IconDiffSplit, IconDiffUnified } from '@pierre/icons'; +import { useState } from 'react'; + +import { FeatureHeader } from '@/components/FeatureHeader'; +import { ButtonGroup, ButtonGroupItem } from '@/components/ui/button-group'; + +interface FoldingProps { + prerenderedFile: PreloadedFileResult; + prerenderedDiff: PreloadFileDiffResult; +} + +type Surface = 'file' | 'diff'; +type DiffLayout = 'unified' | 'split'; + +export function Folding({ prerenderedFile, prerenderedDiff }: FoldingProps) { + const [surface, setSurface] = useState('file'); + const [diffLayout, setDiffLayout] = useState( + prerenderedDiff.options?.diffStyle === 'split' ? 'split' : 'unified' + ); + + return ( +

    + + Enable folding on a read-only File or{' '} + FileDiff. Hover the gutter and use the chevrons to + collapse nested blocks in either unified or split layouts. + + } + /> + +
    + setSurface(value as Surface)} + aria-label="Surface" + > + + File + + + FileDiff + + + + setDiffLayout(value as DiffLayout)} + aria-label="Diff layout" + size="icon" + > + + + + + + + +
    + + {surface === 'file' ? ( + + ) : ( + + )} +
    + ); +} diff --git a/apps/docs/app/(diffs)/_examples/Folding/constants.ts b/apps/docs/app/(diffs)/_examples/Folding/constants.ts new file mode 100644 index 000000000..879574b94 --- /dev/null +++ b/apps/docs/app/(diffs)/_examples/Folding/constants.ts @@ -0,0 +1,87 @@ +import { + DEFAULT_THEMES, + type FileContents, + parseDiffFromFile, +} from '@pierre/diffs'; +import type { + PreloadFileDiffOptions, + PreloadFileOptions, +} from '@pierre/diffs/ssr'; + +import { CustomScrollbarCSS } from '@/components/CustomScrollbarCSS'; + +const FOLDING_OLD_FILE: FileContents = { + name: 'tasks.ts', + contents: `export interface Task { + project: string; + title: string; + done: boolean; +} + +export function groupOpenTasks(tasks: Task[]) { + const groups = new Map(); + + for (const task of tasks) { + if (task.done) { + continue; + } + + const titles = groups.get(task.project) ?? []; + titles.push(task.title); + groups.set(task.project, titles); + } + + return groups; +} +`, +}; + +const FOLDING_NEW_FILE: FileContents = { + name: 'tasks.ts', + contents: `export interface Task { + project: string; + title: string; + done: boolean; + priority: number; +} + +export function groupOpenTasks(tasks: Task[]) { + const groups = new Map(); + + for (const task of tasks) { + if (task.done || task.priority < 1) { + continue; + } + + const group = groups.get(task.project) ?? []; + group.push(task); + groups.set(task.project, group); + } + + return groups; +} +`, +}; + +const FOLDING_OPTIONS = { + theme: DEFAULT_THEMES, + themeType: 'dark', + folding: true, + unsafeCSS: CustomScrollbarCSS, + useTokenTransformer: true, +} as const; + +export const FOLDING_FILE_EXAMPLE: PreloadFileOptions = { + file: FOLDING_NEW_FILE, + options: FOLDING_OPTIONS, +}; + +export const FOLDING_DIFF_EXAMPLE: PreloadFileDiffOptions = { + fileDiff: parseDiffFromFile(FOLDING_OLD_FILE, FOLDING_NEW_FILE, { + context: 20, + }), + options: { + ...FOLDING_OPTIONS, + diffStyle: 'unified', + }, +}; diff --git a/apps/docs/app/(diffs)/_home/Home.tsx b/apps/docs/app/(diffs)/_home/Home.tsx index 83efd95dc..8b1754715 100644 --- a/apps/docs/app/(diffs)/_home/Home.tsx +++ b/apps/docs/app/(diffs)/_home/Home.tsx @@ -1,4 +1,5 @@ import { + preloadFile, preloadFileDiff, preloadMultiFileDiff, preloadUnresolvedFile, @@ -17,6 +18,11 @@ import { CUSTOM_HUNK_SEPARATORS_EXAMPLE } from '../_examples/CustomHunkSeparator import { CustomHunkSeparators } from '../_examples/CustomHunkSeparators/CustomHunkSeparators'; import { DIFF_STYLES } from '../_examples/DiffStyles/constants'; import { DiffStyles } from '../_examples/DiffStyles/DiffStyles'; +import { + FOLDING_DIFF_EXAMPLE, + FOLDING_FILE_EXAMPLE, +} from '../_examples/Folding/constants'; +import { Folding } from '../_examples/Folding/Folding'; import { FONT_STYLES } from '../_examples/FontStyles/constants'; import { FontStyles } from '../_examples/FontStyles/FontStyles'; import { LINE_SELECTION_EXAMPLE } from '../_examples/LineSelection/constants'; @@ -55,6 +61,7 @@ export default function Home() { +
    @@ -86,6 +93,19 @@ async function SplitUnifiedSection() { ); } +async function FoldingSection() { + const [prerenderedFile, prerenderedDiff] = await Promise.all([ + preloadFile(FOLDING_FILE_EXAMPLE), + preloadFileDiff(FOLDING_DIFF_EXAMPLE), + ]); + return ( + + ); +} + async function ShikiThemesSection() { return ( diff --git a/apps/docs/app/(diffs)/docs/CodeView/content.mdx b/apps/docs/app/(diffs)/docs/CodeView/content.mdx index fe54c5a54..f4774b2c5 100644 --- a/apps/docs/app/(diffs)/docs/CodeView/content.mdx +++ b/apps/docs/app/(diffs)/docs/CodeView/content.mdx @@ -55,6 +55,8 @@ immutability or deep equality checks, which can quickly become expensive. - CodeView-level options such as `layout`, `itemMetrics`, `stickyHeaders`, `pointerEventsOnScroll`, and `smoothScrollSettings` allow you to configure the scroll view. All other options are shared between all files and diffs. +- `folding` is one of those shared options. Set it to `true` to enable + indentation-based folding for read-only file and diff items. - `loadDiffFiles` is one of those shared diff options. It applies to diff items rendered inside `CodeView`, which is useful for large patch-driven review UIs where full file contents should be fetched only when users expand unchanged diff --git a/apps/docs/app/(diffs)/docs/Editor/content.mdx b/apps/docs/app/(diffs)/docs/Editor/content.mdx index b0b781cb4..dd3556d56 100644 --- a/apps/docs/app/(diffs)/docs/Editor/content.mdx +++ b/apps/docs/app/(diffs)/docs/Editor/content.mdx @@ -260,6 +260,10 @@ independent editor instances. Pass these when constructing `new Editor({ ... })`, or update them later with [`setOptions`](#edit-mode-api-reference). +Code folding is a `File` or `FileDiff` surface option, not an `Editor` option. +Editable surfaces enable folding by default; set `folding: false` on the surface +to disable it. Read-only surfaces require `folding: true`. + #### Persisting file state diff --git a/apps/docs/app/(diffs)/docs/ReactAPI/constants.ts b/apps/docs/app/(diffs)/docs/ReactAPI/constants.ts index d827448a4..6b5e26377 100644 --- a/apps/docs/app/(diffs)/docs/ReactAPI/constants.ts +++ b/apps/docs/app/(diffs)/docs/ReactAPI/constants.ts @@ -152,6 +152,13 @@ interface DiffOptions { // Show colored backgrounds on changed lines (default: false) disableBackground: false, + // Enable indentation-based code folding. Read-only diffs opt in with true; + // editable diffs fold by default unless this surface option is false. + // Split and unified layouts are supported. Partial changed/renamed patch + // diffs need loadDiffFiles to supply complete lines, and expanded unchanged + // context remains visible instead of being hidden by a fold. + folding: true, + // ───────────────────────────────────────────────────────────── // HUNK SEPARATORS // ───────────────────────────────────────────────────────────── @@ -948,6 +955,10 @@ interface FileOptions { // Hide the file header with filename disableFileHeader: false, + // Enable indentation-based code folding. Read-only files opt in with true; + // editable files fold by default unless this surface option is false. + folding: true, + // Rethrow rendering errors instead of catching and displaying them // in the DOM. Useful for testing or custom error handling. // (default: false) diff --git a/apps/docs/app/(diffs)/docs/ReactAPI/content.mdx b/apps/docs/app/(diffs)/docs/ReactAPI/content.mdx index 957a3abdb..6035be583 100644 --- a/apps/docs/app/(diffs)/docs/ReactAPI/content.mdx +++ b/apps/docs/app/(diffs)/docs/ReactAPI/content.mdx @@ -75,6 +75,13 @@ common set of props for configuration, annotations, and styling. The `File` component has similar props, but uses `LineAnnotation` instead of `DiffLineAnnotation` (no `side` property). +Code folding belongs to the `File` or diff component's `options`, not +`editOptions`. Set `options.folding: true` to enable indentation-based folding +on a read-only surface. Editable surfaces enable folding by default; set the +same surface option to `false` to disable it. `FileDiff` supports both split and +unified layouts, and folding does not hide rows revealed by expanded unchanged +context. + When one of these components is attached to an `Editor`, keep its annotations in application-owned state and replace them with the current collection emitted by `Editor.onChange`. See diff --git a/apps/docs/app/(diffs)/docs/VanillaAPI/constants.ts b/apps/docs/app/(diffs)/docs/VanillaAPI/constants.ts index 69db1906f..2b1d46cdf 100644 --- a/apps/docs/app/(diffs)/docs/VanillaAPI/constants.ts +++ b/apps/docs/app/(diffs)/docs/VanillaAPI/constants.ts @@ -378,6 +378,13 @@ const instance = new FileDiff({ // Show colored backgrounds on changed lines (default: false) disableBackground: false, + // Enable indentation-based code folding. Read-only diffs opt in with true; + // editable diffs fold by default unless this surface option is false. + // Split and unified layouts are supported. Partial changed/renamed patch + // diffs need loadDiffFiles to supply complete lines, and expanded unchanged + // context remains visible instead of being hidden by a fold. + folding: true, + // ───────────────────────────────────────────────────────────── // HUNK SEPARATORS // ───────────────────────────────────────────────────────────── @@ -729,6 +736,10 @@ const instance = new File({ // Hide the file header with filename disableFileHeader: false, + // Enable indentation-based code folding. Read-only files opt in with true; + // editable files fold by default unless this surface option is false. + folding: true, + // Rethrow rendering errors instead of catching and displaying them // in the DOM. Useful for testing or custom error handling. // (default: false) diff --git a/apps/docs/app/(diffs)/docs/VanillaAPI/content.mdx b/apps/docs/app/(diffs)/docs/VanillaAPI/content.mdx index d102a0c4b..e63742b1f 100644 --- a/apps/docs/app/(diffs)/docs/VanillaAPI/content.mdx +++ b/apps/docs/app/(diffs)/docs/VanillaAPI/content.mdx @@ -40,6 +40,13 @@ Both `FileDiff` and `File` accept an options object in their constructor. The `File` component has similar options, but excludes diff-specific settings and uses `LineAnnotation` instead of `DiffLineAnnotation` (no `side` property). +Code folding belongs to the `File` or `FileDiff` constructor options, not +`new Editor(...)`. Set `folding: true` to enable indentation-based folding on a +read-only surface. Editable surfaces enable folding by default; set the same +surface option to `false` to disable it. `FileDiff` supports both split and +unified layouts, and folding does not hide rows revealed by expanded unchanged +context. + When one of these components is attached to an `Editor`, keep its annotations in an external variable or store and replace them with the current collection emitted by `Editor.onChange`. See diff --git a/packages/diffs/README.md b/packages/diffs/README.md index cc61b819c..aa5456fb3 100644 --- a/packages/diffs/README.md +++ b/packages/diffs/README.md @@ -20,6 +20,7 @@ JavaScript and React components. - Flexible annotation framework for injecting comments, annotations, and more - Add your own accept/reject changes UI - Select and highlight lines +- Fold indentation blocks in read-only or editable files and diffs ## Install diff --git a/packages/diffs/src/components/CodeView.ts b/packages/diffs/src/components/CodeView.ts index ce1dafc9b..c2d185e52 100644 --- a/packages/diffs/src/components/CodeView.ts +++ b/packages/diffs/src/components/CodeView.ts @@ -278,6 +278,7 @@ export const CODE_VIEW_DIFF_OPTION_KEYS = [ 'tokenizeMaxLineLength', 'tokenizeMaxLength', 'unsafeCSS', + 'folding', 'diffStyle', 'diffIndicators', 'disableBackground', @@ -311,6 +312,7 @@ export const CODE_VIEW_FILE_OPTION_KEYS = [ 'tokenizeMaxLineLength', 'tokenizeMaxLength', 'unsafeCSS', + 'folding', 'lineHoverHighlight', 'enableTokenInteractionsOnWhitespace', 'enableGutterUtility', @@ -4074,6 +4076,7 @@ function hasItemLayoutOptionChanged( (previousOptions.disableFileHeader ?? false) !== (nextOptions.disableFileHeader ?? false) || previousOptions.unsafeCSS !== nextOptions.unsafeCSS || + (previousOptions.folding ?? false) !== (nextOptions.folding ?? false) || (previousOptions.diffStyle ?? 'split') !== (nextOptions.diffStyle ?? 'split') || (previousOptions.diffIndicators ?? 'bars') !== diff --git a/packages/diffs/src/components/File.ts b/packages/diffs/src/components/File.ts index 95b2cfdeb..47b7941aa 100644 --- a/packages/diffs/src/components/File.ts +++ b/packages/diffs/src/components/File.ts @@ -13,6 +13,7 @@ import { THEME_CSS_ATTRIBUTE, UNSAFE_CSS_ATTRIBUTE, } from '../constants'; +import { FoldingManager } from '../managers/FoldingManager'; import { type GetHoveredLineResult, InteractionManager, @@ -89,6 +90,11 @@ export interface FileHydrateProps extends Omit< export interface FileOptions extends BaseCodeOptions, InteractionManagerBaseOptions<'file'> { + /** + * Enable indentation-based code folding. Read-only files require `true`; + * attached editors remain enabled by default unless set to `false`. + */ + folding?: boolean; disableFileHeader?: boolean; renderHeaderPrefix?: RenderFileMetadata; renderHeaderFilenameSuffix?: RenderFileMetadata; @@ -178,6 +184,9 @@ export class File< protected renderRange: RenderRange | undefined; protected enabled = true; protected foldRanges: LineRange[] = []; + protected foldingManager: FoldingManager = new FoldingManager({ + onChange: (ranges) => this.__setFoldRanges(ranges), + }); protected editor: DiffsEditor | undefined; @@ -240,9 +249,12 @@ export class File< } this.foldRanges = ranges.map((range) => ({ ...range })); this.fileRenderer.setFoldRanges(this.foldRanges); + this.handleFoldRangesChanged(); return true; } + protected handleFoldRangesChanged(): void {} + public onThemeChange(): void { this.fileRenderer.clearRenderCache(); this.rerender(); @@ -403,6 +415,7 @@ export class File< this.file = undefined; } this.foldRanges = []; + this.foldingManager.cleanUp(); this.enabled = false; @@ -511,6 +524,7 @@ export class File< }: HydrationSetup): void { this.lineAnnotations = lineAnnotations ?? this.lineAnnotations; this.file = file; + this.syncReadOnlyFolding(file, true); this.fileRenderer.setOptions(getFileRendererOptions(this.options)); this.syncInteractionOptions(); if (this.pre == null) { @@ -522,6 +536,7 @@ export class File< this.injectUnsafeCSS(); this.managersDirty = true; this.flushManagers(); + this.setupReadOnlyFolding(); } public getOrCreateLineCache( @@ -567,6 +582,9 @@ export class File< public attachEditor(editor: DiffsEditor): () => void { this.editor?.cleanUp(); + this.foldingManager.cleanUp(); + const clearedReadOnlyFolds = + this.foldRanges.length > 0 && this.updateFoldRanges([]); this.editor = editor; const preparedFile = this.file == null ? undefined : editor.__prepareFile?.(this.file); @@ -577,11 +595,16 @@ export class File< preventEmit: true, renderRange: this.renderRange, }); + } else if (clearedReadOnlyFolds) { + this.rerender(); } else { this.syncRenderViewToEditor(); } return () => { this.editor = undefined; + if (this.options.folding === true) { + this.rerender(); + } }; } @@ -666,6 +689,7 @@ export class File< this.cachedHeaderHTML = undefined; } this.file = file; + this.syncReadOnlyFolding(file, didFileChange); this.fileRenderer.setOptions(getFileRendererOptions(this.options)); this.syncInteractionOptions(); if (lineAnnotations != null) { @@ -766,6 +790,8 @@ export class File< this.renderGutterUtility(); if (this.editor != null) { this.syncRenderViewToEditor(); + } else { + this.setupReadOnlyFolding(); } } catch (error: unknown) { if (disableErrorHandling) { @@ -782,6 +808,40 @@ export class File< return true; } + protected syncReadOnlyFolding( + file: FileContents, + fileChanged: boolean + ): void { + if (this.editor != null) { + this.foldingManager.cleanUp(); + return; + } + if (this.options.folding !== true) { + this.foldingManager.cleanUp(); + if (this.foldRanges.length > 0) { + this.updateFoldRanges([]); + } + return; + } + if (fileChanged && this.foldRanges.length > 0) { + this.updateFoldRanges([]); + this.foldingManager.cleanUp(); + } + this.foldingManager.setLines( + this.fileRenderer.getOrCreateLineCache(file), + 2, + fileChanged + ); + } + + private setupReadOnlyFolding(): void { + this.foldingManager.setup( + this.options.folding === true && this.editor == null + ? this.code + : undefined + ); + } + private emitPostRender(unmount = false) { const { fileContainer, diff --git a/packages/diffs/src/components/FileDiff.ts b/packages/diffs/src/components/FileDiff.ts index 7a0cdb8f4..c938e4477 100644 --- a/packages/diffs/src/components/FileDiff.ts +++ b/packages/diffs/src/components/FileDiff.ts @@ -14,6 +14,7 @@ import { THEME_CSS_ATTRIBUTE, UNSAFE_CSS_ATTRIBUTE, } from '../constants'; +import { FoldingManager } from '../managers/FoldingManager'; import { type GetHoveredLineResult, type GetLineIndexUtility, @@ -49,6 +50,7 @@ import type { HighlightedToken, HunkData, HunkSeparators, + LineRange, MaybeDiffFileInput, PostRenderPhase, PrePropertiesConfig, @@ -128,6 +130,16 @@ function canHydrateDiff(fileDiff: FileDiffMetadata): boolean { ); } +// Added/deleted patches already contain their only file side; changed partial +// patches need hydration before indentation ranges cover the whole file. +function hasCompleteFoldingLines(fileDiff: FileDiffMetadata): boolean { + return ( + !fileDiff.isPartial || + fileDiff.type === 'new' || + fileDiff.type === 'deleted' + ); +} + export interface FileDiffRenderBaseProps { fileDiff?: FileDiffMetadata; deferManagers?: boolean; @@ -158,6 +170,11 @@ export interface FileDiffOptions extends Omit, InteractionManagerBaseOptions<'diff'> { + /** + * Enable indentation-based code folding. Read-only diffs require `true`; + * attached editors remain enabled by default unless set to `false`. + */ + folding?: boolean; hunkSeparators?: | Exclude /** * @deprecated Custom hunk separator functions are deprecated and will be @@ -292,6 +309,28 @@ export class FileDiff< private mounted = false; protected enabled = true; + protected additionFoldRanges: LineRange[] = []; + protected deletionFoldRanges: LineRange[] = []; + protected additionFoldingManager: FoldingManager = new FoldingManager({ + side: 'additions', + onChange: (ranges) => this.__setFoldRanges(ranges, 'additions'), + onToggle: (startLine, counterpartStartLine) => + this.handleReadOnlyFoldToggle( + 'additions', + startLine, + counterpartStartLine + ), + }); + protected deletionFoldingManager: FoldingManager = new FoldingManager({ + side: 'deletions', + onChange: (ranges) => this.__setFoldRanges(ranges, 'deletions'), + onToggle: (startLine, counterpartStartLine) => + this.handleReadOnlyFoldToggle( + 'deletions', + startLine, + counterpartStartLine + ), + }); protected editor: DiffsEditor | undefined; protected refreshViewTimeout: ReturnType | undefined; @@ -506,6 +545,88 @@ export class FileDiff< return this.interactionManager.getHoveredLine(); }; + public __setFoldRanges( + ranges: LineRange[], + side: SelectionSide = 'additions' + ): void { + if (!this.updateFoldRanges(ranges, side)) { + return; + } + if (this.enabled && this.fileDiff != null) { + this.rerender(); + } + } + + private handleReadOnlyFoldToggle( + side: SelectionSide, + startLine: number, + counterpartStartLine: number | undefined + ): boolean { + const manager = + side === 'additions' + ? this.additionFoldingManager + : this.deletionFoldingManager; + const counterpartManager = + side === 'additions' + ? this.deletionFoldingManager + : this.additionFoldingManager; + const nextFolded = !( + manager.isFolded(startLine) || + (counterpartStartLine !== undefined && + counterpartManager.isFolded(counterpartStartLine)) + ); + + manager.setFolded(startLine, nextFolded, false); + if (counterpartStartLine !== undefined) { + counterpartManager.setFolded(counterpartStartLine, nextFolded, false); + } + + const changed = this.updateFoldRanges(manager.getHiddenLineRanges(), side); + const counterpartChanged = this.updateFoldRanges( + counterpartManager.getHiddenLineRanges(), + side === 'additions' ? 'deletions' : 'additions' + ); + if ( + (changed || counterpartChanged) && + this.enabled && + this.fileDiff != null + ) { + this.rerender(); + } + return true; + } + + protected updateFoldRanges( + ranges: LineRange[], + side: SelectionSide + ): boolean { + const previous = + side === 'additions' ? this.additionFoldRanges : this.deletionFoldRanges; + if ( + ranges.length === previous.length && + ranges.every((range, index) => { + const previousRange = previous[index]; + return ( + previousRange?.startLine === range.startLine && + previousRange.endLine === range.endLine + ); + }) + ) { + return false; + } + const nextRanges = ranges.map((range) => ({ ...range })); + if (side === 'additions') { + this.additionFoldRanges = nextRanges; + } else { + this.deletionFoldRanges = nextRanges; + } + this.hunksRenderer.setFoldRanges(nextRanges, side); + this.handleFoldRangesChanged(); + return true; + } + + protected handleFoldRangesChanged(): void {} + public setLineAnnotations( lineAnnotations: DiffLineAnnotation[] ): void { @@ -521,6 +642,7 @@ export class FileDiff< forceRender || annotationsChanged || didContentChange || + this.hunksRenderer.hasFoldRanges() || typeof this.options.hunkSeparators === 'function' ) { return false; @@ -671,6 +793,10 @@ export class FileDiff< this.deletionFile = undefined; this.additionFile = undefined; } + this.additionFoldRanges = []; + this.deletionFoldRanges = []; + this.additionFoldingManager.cleanUp(); + this.deletionFoldingManager.cleanUp(); this.enabled = false; @@ -833,6 +959,9 @@ export class FileDiff< this.syncInteractionOptions(); this.hunksRenderer.hydrate(this.fileDiff); + if (this.fileDiff != null) { + this.syncReadOnlyFolding(this.fileDiff, true); + } // FIXME(amadeus): not sure how to handle this yet... // this.renderSeparators(); this.renderAnnotations(); @@ -840,6 +969,7 @@ export class FileDiff< this.injectUnsafeCSS(); this.managersDirty = true; this.flushManagers(); + this.setupReadOnlyFolding(); } public rerender(): void { @@ -1055,6 +1185,7 @@ export class FileDiff< if (this.fileDiff == null) { return false; } + this.syncReadOnlyFolding(this.fileDiff, diffDidChange || filesDidChange); // Backstop for sessions that ended without their exit hook running (e.g. // session-shaped metadata reused after a host teardown): restore // recompute-shaped hunks before rendering. @@ -1065,7 +1196,10 @@ export class FileDiff< finishEditSessionForDiff(this.fileDiff, this.options.parseDiffOptions); void this.hunksRenderer.refreshHighlightedResult(); } - if (expandUnchanged) { + if ( + expandUnchanged || + (this.options.folding === true && !hasCompleteFoldingLines(this.fileDiff)) + ) { this.loadFilesIfNecessary(); } this.hunksRenderer.setOptions(this.getHunksRendererOptions(this.options)); @@ -1192,6 +1326,8 @@ export class FileDiff< if (this.editor != null) { this.syncRenderViewToEditor(); + } else { + this.setupReadOnlyFolding(); } } catch (error: unknown) { if (disableErrorHandling) { @@ -1208,6 +1344,70 @@ export class FileDiff< return true; } + protected syncReadOnlyFolding( + fileDiff: FileDiffMetadata, + targetChanged: boolean + ): void { + if (this.editor != null) { + this.additionFoldingManager.cleanUp(); + this.deletionFoldingManager.cleanUp(); + return; + } + if (this.options.folding !== true || !hasCompleteFoldingLines(fileDiff)) { + this.additionFoldingManager.cleanUp(); + this.deletionFoldingManager.cleanUp(); + if (this.additionFoldRanges.length > 0) { + this.updateFoldRanges([], 'additions'); + } + if (this.deletionFoldRanges.length > 0) { + this.updateFoldRanges([], 'deletions'); + } + return; + } + if (targetChanged) { + if (this.additionFoldRanges.length > 0) { + this.updateFoldRanges([], 'additions'); + } + if (this.deletionFoldRanges.length > 0) { + this.updateFoldRanges([], 'deletions'); + } + this.additionFoldingManager.cleanUp(); + this.deletionFoldingManager.cleanUp(); + } + this.additionFoldingManager.setLines( + fileDiff.additionLines, + 2, + targetChanged + ); + this.deletionFoldingManager.setLines( + fileDiff.deletionLines, + 2, + targetChanged + ); + } + + private setupReadOnlyFolding(): void { + const fileDiff = this.fileDiffCache; + if ( + this.options.folding !== true || + this.editor != null || + fileDiff == null || + !hasCompleteFoldingLines(fileDiff) + ) { + this.additionFoldingManager.setup(undefined); + this.deletionFoldingManager.setup(undefined); + return; + } + const diffStyle = this.options.diffStyle ?? 'split'; + if (diffStyle === 'split') { + this.additionFoldingManager.setup(this.codeAdditions, { diffStyle }); + this.deletionFoldingManager.setup(this.codeDeletions, { diffStyle }); + } else { + this.additionFoldingManager.setup(this.codeUnified, { diffStyle }); + this.deletionFoldingManager.setup(this.codeUnified, { diffStyle }); + } + } + protected emitPostRender(unmount = false): void { const { fileContainer, @@ -1333,6 +1533,14 @@ export class FileDiff< ); } this.editor?.cleanUp(); + this.additionFoldingManager.cleanUp(); + this.deletionFoldingManager.cleanUp(); + const clearedAdditionFolds = + this.additionFoldRanges.length > 0 && + this.updateFoldRanges([], 'additions'); + const clearedDeletionFolds = + this.deletionFoldRanges.length > 0 && + this.updateFoldRanges([], 'deletions'); this.editor = editor; this.hunksRenderer.beginEditSession(); // The editor sync below refuses partial diffs (it needs the full file @@ -1340,7 +1548,11 @@ export class FileDiff< if (this.fileDiff?.isPartial === true) { this.loadFilesIfNecessary(); } - this.syncRenderViewToEditor(); + if (clearedAdditionFolds || clearedDeletionFolds) { + this.rerender(); + } else { + this.syncRenderViewToEditor(); + } return (recycle?: boolean) => { this.editor = undefined; // A recycle detach is a virtualized unmount mid-session: the session @@ -1349,6 +1561,9 @@ export class FileDiff< if (recycle !== true) { this.finishEditSession(); } + if (this.options.folding === true && recycle !== true) { + this.rerender(); + } }; } diff --git a/packages/diffs/src/components/UnresolvedFile.ts b/packages/diffs/src/components/UnresolvedFile.ts index 30fba4741..2757b8cc3 100644 --- a/packages/diffs/src/components/UnresolvedFile.ts +++ b/packages/diffs/src/components/UnresolvedFile.ts @@ -46,7 +46,7 @@ export type MergeConflictActionsTypeOption = export interface UnresolvedFileOptions extends Omit< FileDiffOptions, - 'diffStyle' | 'onPostRender' + 'diffStyle' | 'folding' | 'onPostRender' > { onPostRender?( node: HTMLElement, diff --git a/packages/diffs/src/components/VirtualizedFile.ts b/packages/diffs/src/components/VirtualizedFile.ts index f820e301e..dc61c857b 100644 --- a/packages/diffs/src/components/VirtualizedFile.ts +++ b/packages/diffs/src/components/VirtualizedFile.ts @@ -64,6 +64,7 @@ function hasFileLayoutOptionChanged( (nextOptions.disableLineNumbers ?? false) || (previousOptions.disableFileHeader ?? false) !== (nextOptions.disableFileHeader ?? false) || + (previousOptions.folding ?? false) !== (nextOptions.folding ?? false) || previousOptions.unsafeCSS !== nextOptions.unsafeCSS ); } @@ -200,14 +201,17 @@ export class VirtualizedFile< return; } - this.editorFoldedLineIndex = new LineRangeIndex(this.foldRanges); - this.forceRenderOverride = true; - this.invalidateEditorFoldingLayout(); if (this.enabled && this.file != null) { this.virtualizer.instanceChanged(this, true); } } + protected override handleFoldRangesChanged(): void { + this.editorFoldedLineIndex = new LineRangeIndex(this.foldRanges); + this.forceRenderOverride = true; + this.invalidateEditorFoldingLayout(); + } + // Folding only changes which rows participate in layout. Preserve measured // wrap/annotation heights and rebuild the derived total and checkpoints. private invalidateEditorFoldingLayout(): void { @@ -355,13 +359,15 @@ export class VirtualizedFile< reset?: PendingCodeViewLayoutReset, lineAnnotations?: LineAnnotation[] ): number { - const annotationsChanged = this.syncLineAnnotations(lineAnnotations); - const unkeyedContentsChanged = + const targetChanged = + this.file == null || + !areFilesEqual(this.file, file) || this.fileRenderer.hasUnkeyedFileContentsChanged(file); + const annotationsChanged = this.syncLineAnnotations(lineAnnotations); let shouldResetLayoutCache = reset?.resetFileLayoutCache === true || annotationsChanged || - unkeyedContentsChanged; + targetChanged; if (reset?.metrics != null) { this.metrics = reset.metrics; shouldResetLayoutCache = true; @@ -377,10 +383,11 @@ export class VirtualizedFile< this.resetLayoutCache(); } - if (this.file !== file) { + if (targetChanged) { this.layoutDirty = true; } this.file = file; + this.syncReadOnlyFolding(file, targetChanged); this.top = top; this.computeApproximateSize(); return this.height; @@ -645,6 +652,9 @@ export class VirtualizedFile< override cleanUp(recycle = false): void { const recycledFoldedRanges = recycle ? this.foldRanges : []; + const recycledReadOnlyFoldRanges = recycle + ? this.foldingManager.getFoldRanges() + : []; if (this.fileContainer != null && this.isSimpleMode()) { this.getSimpleVirtualizer()?.disconnect(this.fileContainer); } @@ -659,6 +669,14 @@ export class VirtualizedFile< } else { this.editorFoldedLineIndex = new LineRangeIndex(); } + if (recycle && recycledReadOnlyFoldRanges.length > 0 && this.file != null) { + this.foldingManager.setLines( + this.fileRenderer.getOrCreateLineCache(this.file), + 2, + true + ); + this.foldingManager.setFoldRanges(recycledReadOnlyFoldRanges); + } } // Compute the approximate size of the file using cached line heights. @@ -845,6 +863,7 @@ export class VirtualizedFile< } this.file = file; + this.syncReadOnlyFolding(file, didFileChange); fileContainer = this.getOrCreateFileContainerNode(fileContainer); diff --git a/packages/diffs/src/components/VirtualizedFileDiff.ts b/packages/diffs/src/components/VirtualizedFileDiff.ts index 05282ed9f..450b5828b 100644 --- a/packages/diffs/src/components/VirtualizedFileDiff.ts +++ b/packages/diffs/src/components/VirtualizedFileDiff.ts @@ -8,6 +8,7 @@ import type { FileDiffMetadata, Hunk, HunkSeparators, + LineRange, NumericScrollLineAnchor, PendingCodeViewLayoutReset, RenderRange, @@ -64,6 +65,14 @@ interface DiffLayoutCheckpoint { top: number; } +interface FoldedDiffLayout { + hunkOffsets: number[]; + rawHunkStarts: number[]; + rawLineCount: number; + totalHeight: number; + visibleLineCount: number; +} + interface DiffLayoutCache { // Sparse map: view-specific line index -> measured height delta from the // baseline line height. Only stores lines that differ from the estimate. @@ -81,6 +90,8 @@ interface DiffLayoutCache { checkpoints: DiffLayoutCheckpoint[]; // Total renderable diff rows for the current diff style and expansion state. totalLines: number; + // Fold-aware visible hunk boundaries mapped back to raw render rows. + foldedLayout: FoldedDiffLayout | undefined; // Measured height for the file annotation row. Starts at 0 so // unmeasured annotations behave like all other unmeasured annotations. fileAnnotationHeight: number; @@ -123,6 +134,7 @@ export class VirtualizedFileDiff< estimatedUnifiedHeight: undefined, checkpoints: [], totalLines: 0, + foldedLayout: undefined, fileAnnotationHeight: 0, }; private isVisible: boolean = false; @@ -168,6 +180,25 @@ export class VirtualizedFileDiff< } } + public override __setFoldRanges( + ranges: LineRange[], + side: SelectionSide = 'additions' + ): void { + if (!this.updateFoldRanges(ranges, side)) { + return; + } + this.virtualizer.instanceChanged(this, true); + } + + protected override handleFoldRangesChanged(): void { + this.getSimpleVirtualizer()?.markDOMDirty(); + this.invalidateDerivedLayoutCache(false); + if (this.isSimpleMode()) { + this.computeApproximateSize(); + } + this.forceRenderOverride = true; + } + private syncLineAnnotations( lineAnnotations: DiffLineAnnotation[] | undefined ): boolean { @@ -307,6 +338,7 @@ export class VirtualizedFileDiff< if (this.cache.totalLines !== 0) { this.cache.totalLines = 0; } + this.cache.foldedLayout = undefined; if (includeEstimatedHeights) { this.cache.estimatedSplitHeight = undefined; this.cache.estimatedUnifiedHeight = undefined; @@ -408,6 +440,7 @@ export class VirtualizedFileDiff< } if (hasHeightChange || this.isResizeDebuggingEnabled()) { + this.cache.foldedLayout = undefined; this.computeApproximateSize(true); } return hasHeightChange; @@ -481,6 +514,7 @@ export class VirtualizedFileDiff< this.resetLayoutCache({ includeEstimatedHeights }); } this.fileDiff = fileDiff; + this.syncReadOnlyFolding(fileDiff, targetChanged); this.top = top; this.computeApproximateSize(); return this.height; @@ -531,6 +565,7 @@ export class VirtualizedFileDiff< : this.hunksRenderer.getExpandedHunksMap(), collapsedContextThreshold, callback: ({ + type, hunkIndex, hunk, collapsedBefore, @@ -572,18 +607,31 @@ export class VirtualizedFileDiff< } } - const lineHeight = this.getLineHeight( - lineIndex, - (additionLine?.noEOFCR ?? false) || (deletionLine?.noEOFCR ?? false) - ); - if (lineIndex === targetLineIndex) { - position = { - top, - height: lineHeight, - }; - return true; + const folded = this.hunksRenderer.isLineFolded({ + type, + additionLine, + deletionLine, + }); + if (folded) { + const targetLine = side === 'deletions' ? deletionLine : additionLine; + if (targetLine?.lineNumber === lineNumber) { + position = { top, height: 0 }; + return true; + } + } else { + const lineHeight = this.getLineHeight( + lineIndex, + (additionLine?.noEOFCR ?? false) || (deletionLine?.noEOFCR ?? false) + ); + if (lineIndex === targetLineIndex) { + position = { + top, + height: lineHeight, + }; + return true; + } + top += lineHeight; } - top += lineHeight; if (collapsedAfter > 0) { const separator = getTrailingHunkSeparatorLayout({ @@ -638,11 +686,30 @@ export class VirtualizedFileDiff< const hunkSeparators = this.getHunkSeparatorType(); this.approximateLayoutCheckpoints(); - const checkpoint = this.getLayoutCheckpointBeforeTop(localViewportTop); + const foldedLayout = this.hunksRenderer.hasFoldRanges() + ? this.getFoldedLayout(this.fileDiff) + : undefined; + const foldedHunk = + foldedLayout == null + ? undefined + : this.getFoldedHunkBeforeOffset( + foldedLayout, + localViewportTop - + getVirtualFileHeaderRegion(this.metrics, disableFileHeader) - + this.cache.fileAnnotationHeight + ); + const checkpoint = + foldedHunk == null + ? this.getLayoutCheckpointBeforeTop(localViewportTop) + : undefined; let top = - checkpoint?.top ?? - getVirtualFileHeaderRegion(this.metrics, disableFileHeader) + - this.cache.fileAnnotationHeight; + foldedHunk == null || foldedLayout == null + ? (checkpoint?.top ?? + getVirtualFileHeaderRegion(this.metrics, disableFileHeader) + + this.cache.fileAnnotationHeight) + : getVirtualFileHeaderRegion(this.metrics, disableFileHeader) + + this.cache.fileAnnotationHeight + + (foldedLayout.hunkOffsets[foldedHunk] ?? 0); let anchor: NumericScrollLineAnchor | undefined; // This may end up being quite expensive on extremely large files, we may @@ -651,12 +718,16 @@ export class VirtualizedFileDiff< iterateOverDiff({ diff: this.fileDiff, diffStyle, - startingLine: checkpoint?.renderedLineIndex ?? 0, + startingLine: + foldedHunk == null || foldedLayout == null + ? (checkpoint?.renderedLineIndex ?? 0) + : (foldedLayout.rawHunkStarts[foldedHunk] ?? 0), expandedHunks: expandUnchanged ? true : this.hunksRenderer.getExpandedHunksMap(), collapsedContextThreshold, callback: ({ + type, hunkIndex, hunk, collapsedBefore, @@ -687,30 +758,36 @@ export class VirtualizedFileDiff< } } - if (top >= localViewportTop) { - if (deletionLine != null) { - anchor = { - lineNumber: deletionLine.lineNumber, - side: 'deletions', - top, - }; - } else if (additionLine != null) { - anchor = { - lineNumber: additionLine.lineNumber, - side: 'additions', - top, - }; - } - if (anchor != null) { - return true; + const folded = this.hunksRenderer.isLineFolded({ + type, + additionLine, + deletionLine, + }); + if (!folded) { + if (top >= localViewportTop) { + if (deletionLine != null) { + anchor = { + lineNumber: deletionLine.lineNumber, + side: 'deletions', + top, + }; + } else if (additionLine != null) { + anchor = { + lineNumber: additionLine.lineNumber, + side: 'additions', + top, + }; + } + if (anchor != null) { + return true; + } } - } - const lineHeight = this.getLineHeight( - lineIndex, - (additionLine?.noEOFCR ?? false) || (deletionLine?.noEOFCR ?? false) - ); - top += lineHeight; + top += this.getLineHeight( + lineIndex, + (additionLine?.noEOFCR ?? false) || (deletionLine?.noEOFCR ?? false) + ); + } if (collapsedAfter > 0) { const separator = getTrailingHunkSeparatorLayout({ @@ -780,6 +857,14 @@ export class VirtualizedFileDiff< } override cleanUp(recycle = false): void { + const recycledAdditionRanges = recycle ? this.additionFoldRanges : []; + const recycledDeletionRanges = recycle ? this.deletionFoldRanges : []; + const recycledAdditionFolds = recycle + ? this.additionFoldingManager.getFoldRanges() + : []; + const recycledDeletionFolds = recycle + ? this.deletionFoldingManager.getFoldRanges() + : []; if (this.fileContainer != null && this.isSimpleMode()) { this.getSimpleVirtualizer()?.disconnect(this.fileContainer); } @@ -790,6 +875,26 @@ export class VirtualizedFileDiff< } this.isSetup = false; super.cleanUp(recycle); + if (recycle) { + this.additionFoldRanges = recycledAdditionRanges; + this.deletionFoldRanges = recycledDeletionRanges; + this.hunksRenderer.setFoldRanges(recycledAdditionRanges, 'additions'); + this.hunksRenderer.setFoldRanges(recycledDeletionRanges, 'deletions'); + if (this.fileDiff != null) { + this.additionFoldingManager.setLines( + this.fileDiff.additionLines, + 2, + true + ); + this.deletionFoldingManager.setLines( + this.fileDiff.deletionLines, + 2, + true + ); + this.additionFoldingManager.setFoldRanges(recycledAdditionFolds); + this.deletionFoldingManager.setFoldRanges(recycledDeletionFolds); + } + } } override expandHunk = ( @@ -1115,7 +1220,47 @@ export class VirtualizedFileDiff< 'VirtualizedFileDiff.getActiveEstimatedHeight: missing estimated height' ); } - return estimatedHeight; + if (!this.hunksRenderer.hasFoldRanges() || fileDiff == null) { + return estimatedHeight; + } + const diffStyle = this.getDiffStyle(); + let foldedHeight = 0; + iterateOverDiff({ + diff: fileDiff, + diffStyle, + expandedHunks: + this.options.expandUnchanged === true + ? true + : this.hunksRenderer.getExpandedHunksMap(), + collapsedContextThreshold: + this.options.collapsedContextThreshold ?? + DEFAULT_COLLAPSED_CONTEXT_THRESHOLD, + callback: ({ type, hunk, additionLine, deletionLine }) => { + if ( + this.hunksRenderer.isLineFolded({ type, additionLine, deletionLine }) + ) { + const splitLineIndex = + additionLine?.splitLineIndex ?? deletionLine?.splitLineIndex; + const unifiedLineIndex = + additionLine?.unifiedLineIndex ?? deletionLine?.unifiedLineIndex; + if (splitLineIndex == null || unifiedLineIndex == null) { + return; + } + const isFinalSplitHunkRow = + diffStyle === 'split' && + hunk != null && + splitLineIndex === hunk.splitLineStart + hunk.splitLineCount - 1; + foldedHeight += this.getLineHeight( + diffStyle === 'split' ? splitLineIndex : unifiedLineIndex, + (additionLine?.noEOFCR ?? false) || + (deletionLine?.noEOFCR ?? false) || + (isFinalSplitHunkRow && + (hunk.noEOFCRAdditions || hunk.noEOFCRDeletions)) + ); + } + }, + }); + return Math.max(0, estimatedHeight - foldedHeight); } private ensureEstimatedDiffHeights( @@ -1231,6 +1376,7 @@ export class VirtualizedFileDiff< ); return false; } + this.syncReadOnlyFolding(nextFileDiff, targetChanged); if (!isSetup) { this.computeApproximateSize(false, nextFileDiff); @@ -1348,7 +1494,8 @@ export class VirtualizedFileDiff< (!this.layoutDirty && this.cache.checkpoints.length > 0) || fileDiff == null || fileDiff.hunks.length === 0 || - this.options.collapsed === true + this.options.collapsed === true || + this.hunksRenderer.hasFoldRanges() ) { return; } @@ -1680,6 +1827,147 @@ export class VirtualizedFileDiff< ); } + private getFoldedLayout(fileDiff: FileDiffMetadata): FoldedDiffLayout { + if (this.cache.foldedLayout != null) { + return this.cache.foldedLayout; + } + + const { + expandUnchanged = false, + collapsedContextThreshold = DEFAULT_COLLAPSED_CONTEXT_THRESHOLD, + } = this.options; + const { hunkLineCount } = this.metrics; + const diffStyle = this.getDiffStyle(); + const hunkSeparators = this.getHunkSeparatorType(); + const canHydratePartialDiff = canHydrateCollapsedContext( + fileDiff, + this.options.loadDiffFiles != null + ); + const hunkOffsets: number[] = []; + const rawHunkStarts: number[] = []; + let rawLine = 0; + let top = 0; + let visibleLine = 0; + + iterateOverDiff({ + diff: fileDiff, + diffStyle, + expandedHunks: expandUnchanged + ? true + : this.hunksRenderer.getExpandedHunksMap(), + collapsedContextThreshold, + callback: ({ + type, + hunkIndex, + hunk, + collapsedBefore, + collapsedAfter, + deletionLine, + additionLine, + }) => { + const visibleHunk = Math.floor(visibleLine / hunkLineCount); + if ( + visibleLine % hunkLineCount === 0 && + rawHunkStarts[visibleHunk] == null + ) { + rawHunkStarts[visibleHunk] = rawLine; + hunkOffsets[visibleHunk] = top; + } + + if (collapsedBefore > 0) { + top += + getLeadingHunkSeparatorLayout({ + type: hunkSeparators, + metrics: this.metrics, + hunkIndex, + hunkSpecs: hunk?.hunkSpecs, + })?.totalHeight ?? 0; + } + + const splitLineIndex = + additionLine?.splitLineIndex ?? deletionLine?.splitLineIndex; + const unifiedLineIndex = + additionLine?.unifiedLineIndex ?? deletionLine?.unifiedLineIndex; + if (splitLineIndex == null || unifiedLineIndex == null) { + throw new Error( + 'VirtualizedFileDiff.getFoldedLayout: missing line index data' + ); + } + const isFinalSplitHunkRow = + diffStyle === 'split' && + hunk != null && + splitLineIndex === hunk.splitLineStart + hunk.splitLineCount - 1; + const isFinalHunkRow = + hunkIndex === fileDiff.hunks.length - 1 && + hunk != null && + (diffStyle === 'split' + ? isFinalSplitHunkRow + : unifiedLineIndex === + hunk.unifiedLineStart + hunk.unifiedLineCount - 1); + + if ( + !this.hunksRenderer.isLineFolded({ type, additionLine, deletionLine }) + ) { + top += this.getLineHeight( + diffStyle === 'split' ? splitLineIndex : unifiedLineIndex, + (additionLine?.noEOFCR ?? false) || + (deletionLine?.noEOFCR ?? false) || + (isFinalSplitHunkRow && + hunk != null && + (hunk.noEOFCRAdditions || hunk.noEOFCRDeletions)) + ); + visibleLine++; + } + rawLine++; + + if (collapsedAfter > 0 || (isFinalHunkRow && canHydratePartialDiff)) { + top += + getTrailingHunkSeparatorLayout({ + type: hunkSeparators, + metrics: this.metrics, + })?.totalHeight ?? 0; + } + }, + }); + + if (visibleLine % hunkLineCount === 0) { + const finalHunk = visibleLine / hunkLineCount; + rawHunkStarts[finalHunk] ??= rawLine; + hunkOffsets[finalHunk] ??= top; + } + + return (this.cache.foldedLayout = { + hunkOffsets, + rawHunkStarts, + rawLineCount: rawLine, + totalHeight: top, + visibleLineCount: visibleLine, + }); + } + + private getFoldedHunkBeforeOffset( + layout: FoldedDiffLayout, + offset: number + ): number { + if (layout.visibleLineCount === 0) { + return 0; + } + let low = 0; + let high = layout.hunkOffsets.length; + while (low < high) { + const middle = low + ((high - low) >> 1); + if ((layout.hunkOffsets[middle] ?? 0) <= Math.max(0, offset)) { + low = middle + 1; + } else { + high = middle; + } + } + return Math.min( + Math.ceil(layout.visibleLineCount / this.metrics.hunkLineCount) - 1, + Math.max(0, low - 1) + ); + } + private computeRenderRangeFromWindow( fileDiff: FileDiffMetadata, fileTop: number, @@ -1750,6 +2038,60 @@ export class VirtualizedFileDiff< Math.ceil(estimatedTargetLines / hunkLineCount) * hunkLineCount + hunkLineCount; const totalHunks = totalLines / hunkLineCount; + if (this.hunksRenderer.hasFoldRanges()) { + const foldedLayout = this.getFoldedLayout(fileDiff); + if (foldedLayout.visibleLineCount === 0) { + return { + startingLine: 0, + totalLines: 0, + bufferBefore: 0, + bufferAfter: codeHeight, + }; + } + + const visibleHunkCount = Math.ceil( + foldedLayout.visibleLineCount / hunkLineCount + ); + const viewportCenter = (top + bottom) / 2; + const centerOffset = Math.max( + 0, + viewportCenter - fileTop - codeRegionTop + ); + const centerHunk = this.getFoldedHunkBeforeOffset( + foldedLayout, + centerOffset + ); + const idealStartHunk = centerHunk - Math.floor(totalHunks / 2); + const maxStartHunk = Math.max(0, visibleHunkCount - totalHunks); + const startHunk = Math.max(0, Math.min(idealStartHunk, maxStartHunk)); + const startingVisibleLine = startHunk * hunkLineCount; + const clampedVisibleLines = Math.max( + 0, + Math.min( + idealStartHunk < 0 + ? totalLines + idealStartHunk * hunkLineCount + : totalLines, + foldedLayout.visibleLineCount - startingVisibleLine + ) + ); + const endHunk = Math.ceil( + (startingVisibleLine + clampedVisibleLines) / hunkLineCount + ); + const startingLine = foldedLayout.rawHunkStarts[startHunk] ?? 0; + const finalLine = + foldedLayout.rawHunkStarts[endHunk] ?? foldedLayout.rawLineCount; + const startOffset = foldedLayout.hunkOffsets[startHunk] ?? 0; + const endOffset = + foldedLayout.hunkOffsets[endHunk] ?? foldedLayout.totalHeight; + + return { + startingLine, + totalLines: Math.max(0, finalLine - startingLine), + bufferBefore: + startingLine === 0 ? 0 : fileAnnotationHeight + startOffset, + bufferAfter: Math.max(0, codeHeight - endOffset), + }; + } const overflowHunks = totalHunks; const hunkOffsets: number[] = []; // Halfway between top & bottom, represented as an absolute position @@ -1777,6 +2119,7 @@ export class VirtualizedFileDiff< : this.hunksRenderer.getExpandedHunksMap(), collapsedContextThreshold, callback: ({ + type, hunkIndex, hunk, collapsedBefore, @@ -1831,37 +2174,45 @@ export class VirtualizedFileDiff< } } - const lineHeight = this.getLineHeight( - diffStyle === 'split' ? splitLineIndex : unifiedLineIndex, - hasMetadata - ); + const folded = this.hunksRenderer.isLineFolded({ + type, + additionLine, + deletionLine, + }); + if (!folded) { + const lineHeight = this.getLineHeight( + diffStyle === 'split' ? splitLineIndex : unifiedLineIndex, + hasMetadata + ); - // Track visible region - if (absoluteLineTop > top - lineHeight && absoluteLineTop < bottom) { - firstVisibleHunk ??= currentHunk; - } + // Track visible region + if (absoluteLineTop > top - lineHeight && absoluteLineTop < bottom) { + firstVisibleHunk ??= currentHunk; + } - // Track which hunk contains the viewport center - // If viewport center is above this line and we haven't set centerHunk yet, - // this is the first line at or past the center - if ( - centerHunk == null && - absoluteLineTop + lineHeight > viewportCenter - ) { - centerHunk = currentHunk; - } + // Track which hunk contains the viewport center + // If viewport center is above this line and we haven't set centerHunk yet, + // this is the first line at or past the center + if ( + centerHunk == null && + absoluteLineTop + lineHeight > viewportCenter + ) { + centerHunk = currentHunk; + } - // Start overflow when we are out of the viewport at a hunk boundary - if ( - overflowCounter == null && - absoluteLineTop >= bottom && - isAtHunkBoundary - ) { - overflowCounter = overflowHunks; + // Start overflow when we are out of the viewport at a hunk boundary + if ( + overflowCounter == null && + absoluteLineTop >= bottom && + isAtHunkBoundary + ) { + overflowCounter = overflowHunks; + } + + absoluteLineTop += lineHeight; } currentLine++; - absoluteLineTop += lineHeight; if (collapsedAfter > 0 || (isFinalHunkRow && canHydratePartialDiff)) { const trailingSeparator = getTrailingHunkSeparatorLayout({ @@ -2091,6 +2442,7 @@ function hasDiffLayoutOptionChanged( (nextOptions.disableLineNumbers ?? false) || (previousOptions.disableFileHeader ?? false) !== (nextOptions.disableFileHeader ?? false) || + (previousOptions.folding ?? false) !== (nextOptions.folding ?? false) || (previousOptions.diffIndicators ?? 'bars') !== (nextOptions.diffIndicators ?? 'bars') || (previousOptions.hunkSeparators ?? 'line-info') !== diff --git a/packages/diffs/src/editor/editor.css b/packages/diffs/src/editor/editor.css index 44101e007..a65a8bf78 100644 --- a/packages/diffs/src/editor/editor.css +++ b/packages/diffs/src/editor/editor.css @@ -65,108 +65,6 @@ box-shadow: inset 0 0 0 1px var(--diffs-editor-line-highlight-border); } -[data-code][data-editor-folding] { - --diffs-editor-fold-gap: 4px; - --diffs-editor-fold-width: 16px; -} - -[data-code][data-editor-folding] [data-column-number] { - position: relative; - padding-right: calc( - var(--diffs-editor-fold-width) + var(--diffs-editor-fold-gap) - ); -} - -[data-file][data-disable-line-numbers] - [data-code][data-editor-folding] - [data-column-number] { - min-width: var(--diffs-editor-fold-width); - padding-right: var(--diffs-editor-fold-width); -} - -[data-fold] { - position: absolute; - top: 0; - right: 0; - width: var(--diffs-editor-fold-width); - height: 1lh; - display: flex; - align-items: center; - justify-content: center; - pointer-events: none; - z-index: 2; -} - -[data-fold-toggle] { - all: unset; - box-sizing: border-box; - width: var(--diffs-editor-fold-width); - height: 1lh; - display: flex; - align-items: center; - justify-content: center; - color: var(--diffs-fg-number); - cursor: pointer; - opacity: 0; - pointer-events: auto; - transition: opacity 0.2s ease; -} - -[data-fold-toggle][data-folded], -[data-gutter]:hover [data-fold-toggle], -[data-fold]:hover [data-fold-toggle], -[data-fold-toggle]:focus-visible { - opacity: 0.5; -} - -[data-fold] [data-fold-toggle]:hover, -[data-fold] [data-fold-toggle]:focus-visible { - opacity: 0.75; -} - -[data-fold] [data-fold-toggle]:focus-visible { - outline: 1px solid currentColor; - outline-offset: -2px; -} - -[data-fold-indicator] { - display: inline-flex; - align-items: center; - height: 1lh; - margin-inline-start: 1ch; - vertical-align: top; - color: var(--diffs-fg); - user-select: none; -} - -[data-fold-ellipsis] { - all: unset; - box-sizing: border-box; - width: 18px; - height: calc(1lh - 4px); - display: inline-flex; - align-items: center; - justify-content: center; - border-radius: 4px; - color: var(--diffs-fg-number); - background-color: color-mix(in lab, var(--diffs-fg) 8%, transparent); - cursor: pointer; - transition: - color 80ms ease, - background-color 80ms ease; -} - -[data-fold-ellipsis]:hover, -[data-fold-ellipsis]:focus-visible { - color: var(--diffs-fg); - background-color: color-mix(in lab, var(--diffs-fg) 14%, transparent); -} - -[data-fold-ellipsis]:focus-visible { - outline: 1px solid currentColor; - outline-offset: 1px; -} - [data-editor-overlay] { display: contents; } diff --git a/packages/diffs/src/editor/editor.ts b/packages/diffs/src/editor/editor.ts index 6ffd1ef81..1c33ecc32 100644 --- a/packages/diffs/src/editor/editor.ts +++ b/packages/diffs/src/editor/editor.ts @@ -207,8 +207,6 @@ export interface EditorOptions { roundedSelection?: boolean; /** Highlight matching brackets near the caret, default is true. */ matchBrackets?: boolean; - /** Show code-fold controls for attached file components, default is true. */ - folding?: boolean; /** * Controls auto-surround when typing quotes or brackets over a selection. * Default is `"default"` (both quotes and brackets). @@ -435,7 +433,6 @@ export class Editor implements DiffsEditor { } setOptions(options: EditorOptions): void { - const wasFoldingRequested = this.#options.folding !== false; const previousStorageOption = this.#options.persistStateStorage ?? 'inMemory'; const nextOptions = { @@ -466,9 +463,6 @@ export class Editor implements DiffsEditor { this.#stateStorageOption = undefined; this.#pendingStateWrites.clear(); } - if (wasFoldingRequested !== (this.#options.folding !== false)) { - this.#handleFoldingOptionChange(); - } } // Small typescript hack to prevent UnresolvedFile from being editable. @@ -490,10 +484,7 @@ export class Editor implements DiffsEditor { }); fileInstance.rerender(); } - if ( - fileInstance.type !== 'file' || - fileInstance.__setFoldRanges === undefined - ) { + if (fileInstance.__setFoldRanges === undefined) { this.#resetFoldingState(); } this.#fileInstance = fileInstance; @@ -1100,6 +1091,13 @@ export class Editor implements DiffsEditor { } } + if (this.#syncedFoldingEnabled === true && !this.#isFoldingEnabled) { + if (this.#foldedStartLines.size > 0) { + this.#foldedStartLines.clear(); + this.#foldStateVersion++; + } + this.#setHiddenLineRanges([]); + } this.#refreshFoldingRanges(); this.#syncFoldedRangesToHost(); this.#renderFoldingControls(); @@ -1357,10 +1355,11 @@ export class Editor implements DiffsEditor { } get #isFoldingEnabled(): boolean { + const fileInstance = this.#fileInstance; return ( - this.#options.folding !== false && - this.#fileInstance?.type === 'file' && - this.#fileInstance.__setFoldRanges !== undefined + fileInstance != null && + fileInstance.options.folding !== false && + fileInstance.__setFoldRanges !== undefined ); } @@ -1491,7 +1490,7 @@ export class Editor implements DiffsEditor { #syncFoldedRangesToHost(): void { const host = this.#fileInstance; - if (host?.type !== 'file' || host.__setFoldRanges === undefined) { + if (host?.__setFoldRanges === undefined) { return; } const foldingEnabled = this.#isFoldingEnabled; @@ -1574,8 +1573,10 @@ export class Editor implements DiffsEditor { if (!(child instanceof HTMLElement)) { continue; } - const lineIndex = Number(child.dataset.lineIndex); + const lineIndex = Number(child.dataset.line) - 1; const isFolded = + child.dataset.lineType !== 'change-deletion' && + child.dataset.lineType !== 'context-expanded' && Number.isInteger(lineIndex) && this.#foldedStartLines.has(lineIndex) && this.#foldRangesByStart.has(lineIndex); @@ -1596,11 +1597,13 @@ export class Editor implements DiffsEditor { if (!(child instanceof HTMLElement)) { continue; } - const lineIndex = Number(child.dataset.lineIndex); + const lineIndex = Number(child.dataset.columnNumber) - 1; const existingZone = child.querySelector( ':scope > [data-fold]' ); if ( + child.dataset.lineType === 'change-deletion' || + child.dataset.lineType === 'context-expanded' || child.dataset.columnNumber === undefined || !Number.isInteger(lineIndex) ) { @@ -1970,33 +1973,6 @@ export class Editor implements DiffsEditor { return false; } - #handleFoldingOptionChange(): void { - if (this.#isFoldingEnabled) { - this.#foldRangeVersion = -1; - this.#refreshFoldingRanges(true); - } else { - if (this.#foldedStartLines.size > 0) { - this.#foldStateVersion++; - } - this.#foldRanges = []; - this.#foldRangesByStart.clear(); - this.#foldEndLines.clear(); - this.#foldClosingDelimiterLines.clear(); - this.#foldedStartLines.clear(); - this.#setHiddenLineRanges([]); - this.#foldRangeDocument = undefined; - this.#foldRangeVersion = -1; - } - this.#syncFoldedRangesToHost(); - this.#resetCache(); - this.#gutterWidthCache = undefined; - this.#contentWidthCache = undefined; - this.#renderFoldingControls(); - if (this.#selections !== undefined) { - this.#updateSelections(this.#selections); - } - } - // Whether a zero-based document line has (or will have on scroll) a rendered // row. File folds and collapsed diff regions share this visibility gate. #isLineRenderable(line: number): boolean { diff --git a/packages/diffs/src/managers/FoldingManager.ts b/packages/diffs/src/managers/FoldingManager.ts new file mode 100644 index 000000000..1d22f7c9f --- /dev/null +++ b/packages/diffs/src/managers/FoldingManager.ts @@ -0,0 +1,509 @@ +import { + computeIndentFoldingRanges, + mergeHiddenLineRanges, +} from '../editor/folding'; +import type { LineRange } from '../types'; + +export type FoldingSide = 'additions' | 'deletions'; + +export interface FoldingManagerOptions { + side?: FoldingSide; + onChange(ranges: LineRange[]): void; + onToggle?( + startLine: number, + counterpartStartLine: number | undefined + ): boolean; +} + +export interface FoldingManagerSetupOptions { + diffStyle?: 'unified' | 'split'; +} + +/** + * Owns indentation-fold state and controls for one file or diff side. + */ +export class FoldingManager { + private readonly side: FoldingSide | undefined; + private readonly sideKey: FoldingSide | 'file'; + private readonly onChange: (ranges: LineRange[]) => void; + private readonly onToggle: + | ((startLine: number, counterpartStartLine: number | undefined) => boolean) + | undefined; + private lines: readonly string[] = []; + private tabSize = 2; + private codeElement: HTMLElement | undefined; + private unified = false; + private foldRanges: LineRange[] = []; + private foldRangesByStart = new Map(); + private foldedStartLines = new Set(); + private hiddenLineRanges: LineRange[] = []; + private pendingFocusLine: number | undefined; + + constructor({ side, onChange, onToggle }: FoldingManagerOptions) { + this.side = side; + this.sideKey = side ?? 'file'; + this.onChange = onChange; + this.onToggle = onToggle; + } + + public setLines(lines: readonly string[], tabSize = 2, force = false): void { + if (!force && this.lines === lines && this.tabSize === tabSize) { + return; + } + + this.lines = lines; + this.tabSize = tabSize; + this.foldRanges = computeIndentFoldingRanges( + { + lineCount: lines.length, + getLineText: (line) => lines[line] ?? '', + }, + tabSize + ); + this.foldRangesByStart = new Map( + this.foldRanges.map((range) => [range.startLine, range]) + ); + + for (const startLine of this.foldedStartLines) { + if (!this.foldRangesByStart.has(startLine)) { + this.foldedStartLines.delete(startLine); + } + } + + this.updateHiddenLineRanges(); + this.renderControls(); + } + + public setup( + codeElement: HTMLElement | undefined, + { diffStyle }: FoldingManagerSetupOptions = {} + ): void { + this.unified = diffStyle === 'unified'; + const eligible = + codeElement !== undefined && + (this.side === undefined || + (diffStyle === 'unified' && + codeElement.dataset.unified !== undefined) || + (diffStyle !== 'unified' && + codeElement.dataset[this.side] !== undefined)); + const nextCodeElement = eligible ? codeElement : undefined; + + if (this.codeElement !== nextCodeElement) { + this.removeControls(this.codeElement); + } + if (!eligible) { + this.removeControls(codeElement); + } + + this.codeElement = nextCodeElement; + if (nextCodeElement !== undefined) { + const tabSize = Math.trunc( + Number.parseFloat( + getComputedStyle(nextCodeElement).getPropertyValue('tab-size') + ) + ); + if (Number.isFinite(tabSize) && tabSize > 0 && tabSize !== this.tabSize) { + this.setLines(this.lines, tabSize, true); + return; + } + } + this.renderControls(); + } + + public getFoldRanges(): LineRange[] { + const ranges: LineRange[] = []; + for (const range of this.foldRanges) { + if (this.foldedStartLines.has(range.startLine)) { + ranges.push({ ...range }); + } + } + return ranges; + } + + public getHiddenLineRanges(): LineRange[] { + return this.hiddenLineRanges.map((range) => ({ ...range })); + } + + public setFoldRanges(ranges: readonly LineRange[]): void { + const nextFoldedStartLines = new Set(); + for (const range of ranges) { + const candidate = this.foldRangesByStart.get(range.startLine); + if (candidate?.endLine === range.endLine) { + nextFoldedStartLines.add(range.startLine); + } + } + + let changed = nextFoldedStartLines.size !== this.foldedStartLines.size; + if (!changed) { + for (const line of nextFoldedStartLines) { + if (!this.foldedStartLines.has(line)) { + changed = true; + break; + } + } + } + if (!changed) { + return; + } + + this.foldedStartLines = nextFoldedStartLines; + this.updateHiddenLineRanges(); + this.renderControls(); + } + + public toggleFold(startLine: number): boolean { + if (!this.foldRangesByStart.has(startLine)) { + return false; + } + + return this.setFolded(startLine, !this.foldedStartLines.has(startLine)); + } + + public setFolded(startLine: number, folded: boolean, notify = true): boolean { + if ( + !this.foldRangesByStart.has(startLine) || + this.foldedStartLines.has(startLine) === folded + ) { + return false; + } + + if (folded) { + this.foldedStartLines.add(startLine); + } else { + this.foldedStartLines.delete(startLine); + } + this.updateHiddenLineRanges(notify); + this.renderControls(); + return true; + } + + public isFolded(startLine: number): boolean { + return this.foldedStartLines.has(startLine); + } + + public unfoldLine(line: number): boolean { + let changed = false; + for (const startLine of this.foldedStartLines) { + const range = this.foldRangesByStart.get(startLine); + if (range !== undefined && line > startLine && line <= range.endLine) { + this.foldedStartLines.delete(startLine); + changed = true; + } + } + if (changed) { + this.updateHiddenLineRanges(); + this.renderControls(); + } + return changed; + } + + public reset(): void { + if (this.foldedStartLines.size === 0) { + this.renderControls(); + return; + } + this.foldedStartLines.clear(); + this.updateHiddenLineRanges(); + this.renderControls(); + } + + public cleanUp(): void { + this.removeControls(this.codeElement); + this.codeElement = undefined; + this.lines = []; + this.foldRanges = []; + this.foldRangesByStart.clear(); + this.foldedStartLines.clear(); + this.hiddenLineRanges = []; + this.pendingFocusLine = undefined; + this.unified = false; + } + + private updateHiddenLineRanges(notify = true): void { + const ranges = mergeHiddenLineRanges( + this.foldRanges, + this.foldedStartLines + ); + if ( + ranges.length === this.hiddenLineRanges.length && + ranges.every((range, index) => { + const previous = this.hiddenLineRanges[index]; + return ( + previous?.startLine === range.startLine && + previous.endLine === range.endLine + ); + }) + ) { + return; + } + + this.hiddenLineRanges = ranges; + if (notify) { + this.onChange(ranges.map((range) => ({ ...range }))); + } + } + + private getRowLineNumber(row: HTMLElement): number | undefined { + const value = + this.unified && this.side !== undefined + ? row.dataset[ + this.side === 'additions' ? 'additionLine' : 'deletionLine' + ] + : (row.dataset.columnNumber ?? row.dataset.line); + const lineNumber = Number(value); + return Number.isInteger(lineNumber) && lineNumber > 0 + ? lineNumber + : undefined; + } + + private getCounterpartLineNumber(row: HTMLElement): number | undefined { + if (!this.unified || this.side === undefined) { + return undefined; + } + const lineNumber = Number( + row.dataset[this.side === 'additions' ? 'deletionLine' : 'additionLine'] + ); + return Number.isInteger(lineNumber) && lineNumber > 0 + ? lineNumber + : undefined; + } + + private isRowEligible(row: HTMLElement): boolean { + if (row.dataset.lineType === 'context-expanded') { + return false; + } + if (this.side === 'additions') { + return row.dataset.lineType !== 'change-deletion'; + } + if (this.side === 'deletions') { + return row.dataset.lineType !== 'change-addition'; + } + return true; + } + + private initializeButton(button: HTMLButtonElement): void { + button.addEventListener('pointerdown', (event) => { + event.preventDefault(); + event.stopPropagation(); + }); + button.addEventListener('keydown', (event) => { + if (event.key !== 'Escape') { + event.stopPropagation(); + } + }); + button.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + const row = button.closest( + '[data-column-number], [data-line]' + ); + if (row === null) { + return; + } + const lineNumber = this.getRowLineNumber(row); + if (lineNumber === undefined) return; + const counterpartLineNumber = this.getCounterpartLineNumber(row); + this.pendingFocusLine = event.detail === 0 ? lineNumber - 1 : undefined; + if ( + this.onToggle?.( + lineNumber - 1, + counterpartLineNumber === undefined + ? undefined + : counterpartLineNumber - 1 + ) !== true + ) { + this.toggleFold(lineNumber - 1); + } + this.restorePendingFocus(); + }); + } + + private renderControls(): void { + const codeElement = this.codeElement; + if (codeElement === undefined) { + return; + } + + codeElement.setAttribute('data-folding', ''); + const contentElement = codeElement.querySelector( + ':scope > [data-content]' + ); + const gutterElement = codeElement.querySelector( + ':scope > [data-gutter]' + ); + if (contentElement === null || gutterElement === null) { + this.removeControls(codeElement); + return; + } + + const foldedLineElements = new Map(); + for (const child of contentElement.children) { + if (!(child instanceof HTMLElement) || !this.isRowEligible(child)) { + continue; + } + const lineNumber = this.getRowLineNumber(child); + if ( + lineNumber !== undefined && + this.foldedStartLines.has(lineNumber - 1) + ) { + foldedLineElements.set(lineNumber - 1, child); + } + } + + for (const indicator of contentElement.querySelectorAll( + `:scope > [data-line] > [data-fold-indicator][data-fold-side="${this.sideKey}"]` + )) { + const lineNumber = + indicator.parentElement instanceof HTMLElement + ? this.getRowLineNumber(indicator.parentElement) + : undefined; + if ( + lineNumber === undefined || + foldedLineElements.get(lineNumber - 1) !== indicator.parentElement + ) { + indicator.remove(); + } + } + + for (const child of gutterElement.children) { + if (!(child instanceof HTMLElement)) { + continue; + } + const lineNumber = this.getRowLineNumber(child); + const existingZone = child.querySelector( + `:scope > [data-fold][data-fold-side="${this.sideKey}"]` + ); + if (lineNumber === undefined || !this.isRowEligible(child)) { + existingZone?.remove(); + continue; + } + + const lineIndex = lineNumber - 1; + if (!this.foldRangesByStart.has(lineIndex)) { + existingZone?.remove(); + continue; + } + if ( + existingZone === null && + child.querySelector(':scope > [data-fold]') !== null + ) { + continue; + } + + const folded = this.foldedStartLines.has(lineIndex); + const zone = existingZone ?? document.createElement('span'); + if (existingZone === null) { + zone.setAttribute('data-fold', ''); + zone.dataset.foldSide = this.sideKey; + child.appendChild(zone); + } + + let button = zone.querySelector( + ':scope > [data-fold-toggle]' + ); + const previousFolded = button?.dataset.folded !== undefined; + if (button === null) { + button = document.createElement('button'); + button.type = 'button'; + button.setAttribute('data-fold-toggle', ''); + button.dataset.foldSide = this.sideKey; + zone.appendChild(button); + this.initializeButton(button); + } + + button.ariaExpanded = folded ? 'false' : 'true'; + button.ariaLabel = `${folded ? 'Unfold' : 'Fold'} line ${lineNumber}`; + button.title = folded ? 'Unfold' : 'Fold'; + button.toggleAttribute('data-folded', folded); + if (previousFolded !== folded || button.childElementCount === 0) { + button.innerHTML = + ``; + } + + const lineElement = foldedLineElements.get(lineIndex); + if (!folded || lineElement === undefined) { + continue; + } + + let indicator = lineElement.querySelector( + `:scope > [data-fold-indicator][data-fold-side="${this.sideKey}"]` + ); + if (indicator === null) { + indicator = document.createElement('span'); + indicator.setAttribute('data-fold-indicator', ''); + indicator.dataset.foldSide = this.sideKey; + indicator.contentEditable = 'false'; + indicator.spellcheck = false; + lineElement.appendChild(indicator); + } + + const text = this.lines[lineIndex] ?? ''; + indicator.dataset.foldCharacter = ( + text.length - (text.endsWith('\r\n') ? 2 : /[\r\n]$/.test(text) ? 1 : 0) + ).toString(); + + let ellipsis = indicator.querySelector( + ':scope > [data-fold-ellipsis]' + ); + if (ellipsis === null) { + ellipsis = document.createElement('button'); + ellipsis.type = 'button'; + ellipsis.setAttribute('data-fold-ellipsis', ''); + ellipsis.dataset.foldSide = this.sideKey; + ellipsis.innerHTML = + ''; + indicator.appendChild(ellipsis); + this.initializeButton(ellipsis); + } + ellipsis.ariaLabel = `Unfold line ${lineNumber}`; + ellipsis.title = 'Unfold'; + } + + this.restorePendingFocus(); + } + + private restorePendingFocus(): void { + const line = this.pendingFocusLine; + const gutterElement = this.codeElement?.querySelector( + ':scope > [data-gutter]' + ); + if (line === undefined || gutterElement == null) { + return; + } + for (const child of gutterElement.children) { + if ( + child instanceof HTMLElement && + this.getRowLineNumber(child) === line + 1 && + this.isRowEligible(child) + ) { + const toggle = child.querySelector( + `:scope > [data-fold][data-fold-side="${this.sideKey}"] > [data-fold-toggle]` + ); + if (toggle?.isConnected === true) { + toggle.focus({ preventScroll: true }); + this.pendingFocusLine = undefined; + } + return; + } + } + } + + private removeControls(codeElement: HTMLElement | undefined): void { + if (codeElement === undefined) { + return; + } + codeElement + .querySelectorAll( + `[data-fold][data-fold-side="${this.sideKey}"], ` + + `[data-fold-indicator][data-fold-side="${this.sideKey}"]` + ) + .forEach((element) => element.remove()); + if ( + codeElement.querySelector('[data-fold], [data-fold-indicator]') === null + ) { + codeElement.removeAttribute('data-folding'); + } + } +} diff --git a/packages/diffs/src/renderers/DiffHunksRenderer.ts b/packages/diffs/src/renderers/DiffHunksRenderer.ts index 82dd77588..ae58bdf90 100644 --- a/packages/diffs/src/renderers/DiffHunksRenderer.ts +++ b/packages/diffs/src/renderers/DiffHunksRenderer.ts @@ -8,6 +8,7 @@ import { DEFAULT_THEMES, DEFAULT_TOKENIZE_MAX_LENGTH, } from '../constants'; +import { LineRangeIndex } from '../editor/folding'; import { areLanguagesAttached } from '../highlighter/languages/areLanguagesAttached'; import { getHighlighterIfLoaded, @@ -31,6 +32,7 @@ import type { HunkData, HunkExpansionRegion, HunkSeparators, + LineRange, LineTypes, RenderDiffOptions, RenderDiffResult, @@ -233,6 +235,10 @@ export class DiffHunksRenderer { private deletionAnnotations: AnnotationLineMap = {}; private additionAnnotations: AnnotationLineMap = {}; + private additionFoldRanges: LineRange[] = []; + private additionFoldRangeIndex = new LineRangeIndex(); + private deletionFoldRanges: LineRange[] = []; + private deletionFoldRangeIndex = new LineRangeIndex(); private computedLang: SupportedLanguages = 'text'; private renderCache: RenderedDiffASTCache | undefined; @@ -266,6 +272,10 @@ export class DiffHunksRenderer { this.clearRenderCache(); this.additionAnnotations = {}; this.deletionAnnotations = {}; + this.additionFoldRanges = []; + this.additionFoldRangeIndex = new LineRangeIndex(); + this.deletionFoldRanges = []; + this.deletionFoldRangeIndex = new LineRangeIndex(); this.workerManager?.cleanUpTasks(this); // Session hunks and the metadata dirty marker survive recycle in the // shared FileDiffMetadata; the renderer-local state re-seeds on the next @@ -273,6 +283,46 @@ export class DiffHunksRenderer { this.endEditSession(); } + public setFoldRanges( + ranges: readonly LineRange[], + side: 'additions' | 'deletions' = 'additions' + ): void { + const nextRanges = ranges.map((range) => ({ ...range })); + if (side === 'additions') { + this.additionFoldRanges = nextRanges; + this.additionFoldRangeIndex = new LineRangeIndex(nextRanges); + } else { + this.deletionFoldRanges = nextRanges; + this.deletionFoldRangeIndex = new LineRangeIndex(nextRanges); + } + } + + public hasFoldRanges(): boolean { + return ( + this.additionFoldRanges.length > 0 || this.deletionFoldRanges.length > 0 + ); + } + + public isLineFolded({ + type, + additionLine, + deletionLine, + }: Pick< + RenderedLineContext, + 'type' | 'additionLine' | 'deletionLine' + >): boolean { + if (type === 'context-expanded') { + return false; + } + const additionFolded = + additionLine != null && + this.additionFoldRangeIndex.isHidden(additionLine.lineIndex); + const deletionFolded = + deletionLine != null && + this.deletionFoldRangeIndex.isHidden(deletionLine.lineIndex); + return additionFolded || deletionFolded; + } + /** * Enter edit-session mode: hunk updates preserve the current region * skeleton instead of recomputing hunks. Called on every editor attach, @@ -1257,6 +1307,17 @@ export class DiffHunksRenderer { additionLine != null ? additionLine.unifiedLineIndex : deletionLine.unifiedLineIndex; + const isFinalSplitHunkRow = + diffStyle === 'split' && + hunk != null && + splitLineIndex === hunk.splitLineStart + hunk.splitLineCount - 1; + const isFinalHunkRow = + hunkIndex === fileDiff.hunks.length - 1 && + hunk != null && + (diffStyle === 'split' + ? isFinalSplitHunkRow + : unifiedLineIndex === + hunk.unifiedLineStart + hunk.unifiedLineCount - 1); if (diffStyle === 'split' && type !== 'change') { pendingSplitContext.flush(); @@ -1274,6 +1335,29 @@ export class DiffHunksRenderer { }); } + if (this.isLineFolded({ type, additionLine, deletionLine })) { + if ( + hunkSeparators !== 'simple' && + hunkSeparators !== 'metadata' && + (collapsedAfter > 0 || (isFinalHunkRow && canHydrateContext)) + ) { + pushSeparators({ + hunkIndex: + type === 'context-expanded' ? hunkIndex : hunkIndex + 1, + collapsedLines: + isFinalHunkRow && canHydrateContext + ? 'unknown' + : collapsedAfter, + rangeSize: trailingRangeSize, + hunkSpecs: undefined, + isFirstHunk: false, + isLastHunk: true, + isExpandable: isExpandableDiff, + }); + } + return; + } + const lineIndex = diffStyle === 'unified' ? unifiedLineIndex : splitLineIndex; const renderedLineContext: RenderedLineContext = { @@ -1320,6 +1404,12 @@ export class DiffHunksRenderer { additionLineIndex: additionLine?.lineIndex, deletionLineIndex: deletionLine?.lineIndex, }); + const sideProperties = { + 'data-addition-line': additionLine?.lineNumber, + 'data-addition-line-index': additionLine?.lineIndex, + 'data-deletion-line': deletionLine?.lineNumber, + 'data-deletion-line-index': deletionLine?.lineIndex, + }; pushGutterLineNumber( 'unified', lineDecoration.gutterLineType, @@ -1327,29 +1417,35 @@ export class DiffHunksRenderer { ? additionLine.lineNumber : deletionLine.lineNumber, `${unifiedLineIndex},${splitLineIndex}`, - lineDecoration.gutterProperties + { ...lineDecoration.gutterProperties, ...sideProperties } ); if (additionLineContent != null) { additionLineContent = withContentProperties( additionLineContent, lineDecoration.contentProperties, - isRenderCacheDirty && additionLine != null - ? { - 'data-line': additionLine.lineNumber, - 'data-line-index': `${unifiedLineIndex},${splitLineIndex}`, - } - : undefined + { + ...sideProperties, + ...(isRenderCacheDirty && additionLine != null + ? { + 'data-line': additionLine.lineNumber, + 'data-line-index': `${unifiedLineIndex},${splitLineIndex}`, + } + : undefined), + } ); } else if (deletionLineContent != null) { deletionLineContent = withContentProperties( deletionLineContent, lineDecoration.contentProperties, - isRenderCacheDirty && deletionLine != null - ? { - 'data-line': deletionLine.lineNumber, - 'data-line-index': `${unifiedLineIndex},${splitLineIndex}`, - } - : undefined + { + ...sideProperties, + ...(isRenderCacheDirty && deletionLine != null + ? { + 'data-line': deletionLine.lineNumber, + 'data-line-index': `${unifiedLineIndex},${splitLineIndex}`, + } + : undefined), + } ); } pushLineWithAnnotation({ @@ -1509,17 +1605,6 @@ export class DiffHunksRenderer { } } - const isFinalSplitHunkRow = - diffStyle === 'split' && - hunk != null && - splitLineIndex === hunk.splitLineStart + hunk.splitLineCount - 1; - const isFinalHunkRow = - hunkIndex === fileDiff.hunks.length - 1 && - hunk != null && - (diffStyle === 'split' - ? splitLineIndex === hunk.splitLineStart + hunk.splitLineCount - 1 - : unifiedLineIndex === - hunk.unifiedLineStart + hunk.unifiedLineCount - 1); const splitNoEOFCRDeletion = isFinalSplitHunkRow ? hunk.noEOFCRDeletions : false; diff --git a/packages/diffs/src/sprite.ts b/packages/diffs/src/sprite.ts index da56b833f..51f6ac3c4 100644 --- a/packages/diffs/src/sprite.ts +++ b/packages/diffs/src/sprite.ts @@ -2,9 +2,12 @@ export type SVGSpriteNames = | 'diffs-icon-arrow-right-short' | 'diffs-icon-brand-github' | 'diffs-icon-chevron' + | 'diffs-icon-chevron-down' + | 'diffs-icon-chevron-right' | 'diffs-icon-chevrons-narrow' | 'diffs-icon-diff-split' | 'diffs-icon-diff-unified' + | 'diffs-icon-ellipsis' | 'diffs-icon-expand' | 'diffs-icon-expand-all' | 'diffs-icon-file-code' @@ -27,6 +30,12 @@ export const SVGSpriteSheet = ` - diffs editor-folding fixture + diffs read-only folding fixture @@ -30,15 +44,37 @@ " >← All fixtures -
    +
    +

    File

    +
    +
    +
    +

    FileDiff

    +
    + + +
    +
    +
    diff --git a/packages/diffs/test/e2e/fixtures/index.html b/packages/diffs/test/e2e/fixtures/index.html index 8eae18871..35507533b 100644 --- a/packages/diffs/test/e2e/fixtures/index.html +++ b/packages/diffs/test/e2e/fixtures/index.html @@ -119,9 +119,10 @@

    @pierre/diffs — E2E fixtures

  • folding.html

    - A foldable single-file Editor. Drives - folding.pw.ts (gutter and icon hover states, fold/unfold - interaction). Ready flag: window.__foldingReady. + Read-only File and FileDiff folding, + including split/unified layout switching. Drives + folding.pw.ts. Ready flag: + window.__foldingReady.

  • diff --git a/packages/diffs/test/e2e/folding.pw.ts b/packages/diffs/test/e2e/folding.pw.ts index f1e4072e7..d6c3de4aa 100644 --- a/packages/diffs/test/e2e/folding.pw.ts +++ b/packages/diffs/test/e2e/folding.pw.ts @@ -1,40 +1,41 @@ import { expect, type Page, test } from '@playwright/test'; -const GUTTER = '[data-code][data-editor-folding] [data-gutter]'; -const CONTENT_ROWS = '[data-content] > [data-line]'; -const OUTER_TOGGLE = '[data-column-number="1"] [data-fold-toggle]'; -const OUTER_INDICATOR = - '[data-content] > [data-line="1"] > [data-fold-indicator]'; +const FILE = '[data-file-mount]'; +const FILE_DIFF = '[data-file-diff-mount]'; +const FILE_GUTTER = `${FILE} [data-code][data-folding] [data-gutter]`; +const FILE_CONTENT_ROWS = `${FILE} [data-content] > [data-line]`; +const OUTER_TOGGLE = `${FILE} [data-column-number="1"] [data-fold-toggle]`; +const OUTER_INDICATOR = `${FILE} [data-content] > [data-line="1"] > [data-fold-indicator]`; async function openFixture(page: Page): Promise { await page.goto('/test/e2e/fixtures/folding.html'); await page.waitForFunction(() => window.__foldingReady === true); } -const renderedLineNumbers = (page: Page): Promise => +const renderedLineNumbers = (page: Page, selector: string): Promise => page - .locator(CONTENT_ROWS) + .locator(selector) .evaluateAll((rows) => rows.map((row) => Number((row as HTMLElement).dataset.line)) ); const firstTokenColor = (page: Page): Promise => page - .locator('[data-content] > [data-line="1"] [data-char]') + .locator(`${FILE} [data-content] > [data-line="1"] [data-char]`) .first() .evaluate((element) => getComputedStyle(element).color); -test.describe('editor folding controls', () => { - test('reveal on hover and fold or unfold the outer block', async ({ +test.describe('read-only folding controls', () => { + test('File reveals controls and folds or unfolds the outer block', async ({ page, }) => { await openFixture(page); - const gutter = page.locator(GUTTER); + const gutter = page.locator(FILE_GUTTER); let toggle = page.locator(OUTER_TOGGLE); await expect(toggle.locator('use')).toHaveAttribute( 'href', - '#diffs-editor-icon-chevron-down' + '#diffs-icon-chevron-down' ); await expect(toggle).toHaveCSS('opacity', '0'); @@ -45,14 +46,20 @@ test.describe('editor folding controls', () => { await expect(toggle).toHaveCSS('opacity', '0.75'); await toggle.click(); - await expect.poll(() => renderedLineNumbers(page)).toEqual([1, 7, 8]); + await expect + .poll(() => renderedLineNumbers(page, FILE_CONTENT_ROWS)) + .toEqual([1, 7, 8]); - await page.mouse.move(1150, 780); + await page.locator('[data-fixtures-index]').hover(); toggle = page.locator(OUTER_TOGGLE); + await expect(toggle).not.toBeFocused(); + expect( + await toggle.evaluate((element) => element.matches(':focus-visible')) + ).toBe(false); await expect(toggle).toHaveAttribute('data-folded', ''); await expect(toggle.locator('use')).toHaveAttribute( 'href', - '#diffs-editor-icon-chevron-right' + '#diffs-icon-chevron-right' ); await expect(toggle).toHaveCSS('opacity', '0.5'); @@ -61,13 +68,13 @@ test.describe('editor folding controls', () => { await expect(indicator).toBeVisible(); await expect(ellipsis.locator('use')).toHaveAttribute( 'href', - '#diffs-editor-icon-ellipsis' + '#diffs-icon-ellipsis' ); expect(await indicator.getAttribute('data-fold-end-text')).toBeNull(); await expect(indicator).toHaveText(''); - await expect(page.locator('[data-content] > [data-line="7"]')).toHaveText( - '}' - ); + await expect( + page.locator(`${FILE} [data-content] > [data-line="7"]`) + ).toHaveText('}'); const ellipsisBox = await ellipsis.boundingBox(); const indicatorBox = await indicator.boundingBox(); @@ -84,17 +91,121 @@ test.describe('editor folding controls', () => { await ellipsis.click(); await expect - .poll(() => renderedLineNumbers(page)) + .poll(() => renderedLineNumbers(page, FILE_CONTENT_ROWS)) .toEqual([1, 2, 3, 4, 5, 6, 7, 8]); await expect(indicator).toHaveCount(0); - await page.mouse.move(1150, 780); + await page.locator('[data-fixtures-index]').hover(); toggle = page.locator(OUTER_TOGGLE); + await expect(toggle).not.toBeFocused(); await expect(toggle).not.toHaveAttribute('data-folded', ''); await expect(toggle.locator('use')).toHaveAttribute( 'href', - '#diffs-editor-icon-chevron-down' + '#diffs-icon-chevron-down' ); await expect(toggle).toHaveCSS('opacity', '0'); }); + + test('shows the fold outline only for keyboard focus', async ({ page }) => { + await openFixture(page); + + const toggle = page.locator(OUTER_TOGGLE); + await toggle.click(); + await expect(toggle).not.toBeFocused(); + expect( + await toggle.evaluate((element) => element.matches(':focus-visible')) + ).toBe(false); + + await page.locator('[data-fixtures-index]').focus(); + await page.keyboard.press('Tab'); + await expect(toggle).toBeFocused(); + expect( + await toggle.evaluate((element) => element.matches(':focus-visible')) + ).toBe(true); + await expect(toggle).toHaveCSS('outline-style', 'solid'); + + await page.keyboard.press('Enter'); + await expect(toggle).toBeFocused(); + expect( + await toggle.evaluate((element) => element.matches(':focus-visible')) + ).toBe(true); + }); + + test('FileDiff folds split and unified layouts', async ({ page }) => { + await openFixture(page); + + const additions = `${FILE_DIFF} [data-code][data-additions]`; + const deletions = `${FILE_DIFF} [data-code][data-deletions]`; + const additionsRows = `${additions} [data-content] > [data-line]`; + const deletionsRows = `${deletions} [data-content] > [data-line]`; + const splitToggle = page.locator( + `${additions} [data-column-number="1"] ` + + '[data-fold-toggle][data-fold-side="additions"]' + ); + + await expect(page.locator('[data-current-diff-style]')).toHaveText('split'); + await expect(page.locator(additions)).toHaveAttribute('data-folding', ''); + await expect(page.locator(deletions)).toHaveAttribute('data-folding', ''); + await expect + .poll(() => renderedLineNumbers(page, additionsRows)) + .toEqual([1, 2, 3, 4, 5, 6, 7, 8]); + await expect + .poll(() => renderedLineNumbers(page, deletionsRows)) + .toEqual([1, 2, 3, 4, 5, 6, 7, 8]); + + await splitToggle.click(); + await expect + .poll(() => renderedLineNumbers(page, additionsRows)) + .toEqual([1, 7, 8]); + await expect + .poll(() => renderedLineNumbers(page, deletionsRows)) + .toEqual([1, 7, 8]); + + await page + .locator( + `${additions} [data-line="1"] ` + + '[data-fold-indicator][data-fold-side="additions"] ' + + '[data-fold-ellipsis]' + ) + .click(); + await expect + .poll(() => renderedLineNumbers(page, additionsRows)) + .toEqual([1, 2, 3, 4, 5, 6, 7, 8]); + await expect + .poll(() => renderedLineNumbers(page, deletionsRows)) + .toEqual([1, 2, 3, 4, 5, 6, 7, 8]); + + await page.locator('[data-toggle-diff-style]').click(); + await expect(page.locator('[data-current-diff-style]')).toHaveText( + 'unified' + ); + await expect(page.locator(additions)).toHaveCount(0); + await expect(page.locator(deletions)).toHaveCount(0); + + const unified = `${FILE_DIFF} [data-code][data-unified]`; + const unifiedRows = `${unified} [data-content] > [data-line]`; + const unifiedToggle = page.locator( + `${unified} [data-addition-line="1"] ` + + '[data-fold-toggle][data-fold-side="additions"]' + ); + await expect(page.locator(unified)).toHaveAttribute('data-folding', ''); + await expect + .poll(() => renderedLineNumbers(page, unifiedRows)) + .toEqual([1, 2, 3, 4, 5, 6, 6, 7, 8]); + + await unifiedToggle.click(); + await expect + .poll(() => renderedLineNumbers(page, unifiedRows)) + .toEqual([1, 7, 8]); + await expect(page.locator(`${unified} [data-fold-indicator]`)).toHaveCount( + 1 + ); + + await page + .locator(`${unified} [data-fold-indicator] [data-fold-ellipsis]`) + .click(); + await expect + .poll(() => renderedLineNumbers(page, unifiedRows)) + .toEqual([1, 2, 3, 4, 5, 6, 6, 7, 8]); + }); }); diff --git a/packages/diffs/test/editorFolding.test.ts b/packages/diffs/test/editorFolding.test.ts index 5cf65e2df..8e38a93df 100644 --- a/packages/diffs/test/editorFolding.test.ts +++ b/packages/diffs/test/editorFolding.test.ts @@ -1,9 +1,9 @@ import { afterAll, describe, expect, test } from 'bun:test'; -import { File } from '../src/components/File'; +import { File, type FileOptions } from '../src/components/File'; import { FileDiff } from '../src/components/FileDiff'; import { DEFAULT_THEMES } from '../src/constants'; -import { Editor, type EditorOptions } from '../src/editor/editor'; +import { Editor } from '../src/editor/editor'; import { disposeHighlighter } from '../src/highlighter/shared_highlighter'; import type { FileContents, LineRange } from '../src/types'; import { installDom, wait, waitFor } from './domHarness'; @@ -48,8 +48,9 @@ async function waitForEditableContent(container: HTMLElement): Promise { } async function createFileEditorFixture( - editorOptions?: EditorOptions, - contents = FOLDABLE_CONTENTS + fileOptions: Pick, 'folding'> = { folding: true }, + contents = FOLDABLE_CONTENTS, + editing = true ): Promise { const dom = installDom(); const container = document.createElement('div'); @@ -58,8 +59,9 @@ async function createFileEditorFixture( const file = new File({ disableFileHeader: true, theme: DEFAULT_THEMES, + ...fileOptions, }); - const editor = new Editor(editorOptions); + const editor = new Editor(); const fileContents: FileContents = { name: 'foldable.ts', contents, @@ -70,8 +72,21 @@ async function createFileEditorFixture( fileContainer: container, forceRender: true, }); - editor.edit(file); - await waitForEditableContent(container); + if (editing) { + editor.edit(file); + await waitForEditableContent(container); + } else { + await waitFor( + () => container.shadowRoot?.querySelector('[data-content]') != null, + { timeout: 3000 } + ); + if (fileOptions.folding === true) { + await waitFor( + () => container.shadowRoot?.querySelector('[data-fold-toggle]') != null, + { timeout: 3000 } + ); + } + } return { cleanup() { @@ -101,6 +116,58 @@ function renderedLineNumbers(container: HTMLElement): number[] { ].map((line) => Number(line.dataset.line)); } +function codeColumn( + container: HTMLElement, + side: 'additions' | 'deletions' | 'unified' +): HTMLElement { + const code = shadowRoot(container).querySelector( + `[data-code][data-${side}]` + ); + if (code == null) { + throw new Error(`no ${side} code column found`); + } + return code; +} + +function renderedColumnLineNumbers(code: HTMLElement): number[] { + return [ + ...code.querySelectorAll('[data-content] > [data-line]'), + ].map((line) => Number(line.dataset.line)); +} + +function columnFoldToggle( + code: HTMLElement, + oneIndexedLine: number, + side: 'additions' | 'deletions' +): HTMLButtonElement { + const toggle = code.querySelector( + `[data-column-number="${oneIndexedLine}"] ` + + `[data-fold-toggle][data-fold-side="${side}"]` + ); + if (!(toggle instanceof HTMLButtonElement)) { + throw new Error(`no ${side} fold toggle found for line ${oneIndexedLine}`); + } + return toggle; +} + +function columnFoldEllipsis( + code: HTMLElement, + oneIndexedLine: number, + side: 'additions' | 'deletions' +): HTMLButtonElement { + const ellipsis = code.querySelector( + `[data-content] > [data-line="${oneIndexedLine}"] ` + + `[data-fold-indicator][data-fold-side="${side}"] ` + + '[data-fold-ellipsis]' + ); + if (!(ellipsis instanceof HTMLButtonElement)) { + throw new Error( + `no ${side} fold ellipsis found for line ${oneIndexedLine}` + ); + } + return ellipsis; +} + function gutterRow( container: HTMLElement, oneIndexedLine: number @@ -180,25 +247,38 @@ async function waitForLines( expect(renderedLineNumbers(container)).toEqual(expected); } +async function waitForColumnLines( + code: HTMLElement, + expected: number[] +): Promise { + await waitFor( + () => + JSON.stringify(renderedColumnLineNumbers(code)) === + JSON.stringify(expected), + { timeout: 3000 } + ); + expect(renderedColumnLineNumbers(code)).toEqual(expected); +} + describe('editor folding on File', () => { - test('is enabled by default and folds or unfolds a block from the gutter', async () => { - const { cleanup, container } = await createFileEditorFixture(); + test('folds or unfolds a read-only block from the gutter', async () => { + const { cleanup, container } = await createFileEditorFixture( + undefined, + FOLDABLE_CONTENTS, + false + ); try { const shadow = shadowRoot(container); const firstGutterRow = gutterRow(container, 1); const foldZone = firstGutterRow.querySelector(':scope > [data-fold]'); const initialToggle = foldToggle(container, 1); - expect(shadow.querySelector('[data-code][data-editor-folding]')).not.toBe( - null - ); + expect(shadow.querySelector('[data-code][data-folding]')).not.toBe(null); expect(foldZone?.parentElement).toBe(firstGutterRow); expect(firstGutterRow.closest('[data-gutter]')).not.toBe(null); expect(foldZone?.contains(initialToggle)).toBe(true); expect(initialToggle.getAttribute('aria-expanded')).toBe('true'); - expect(foldIconHref(initialToggle)).toBe( - '#diffs-editor-icon-chevron-down' - ); + expect(foldIconHref(initialToggle)).toBe('#diffs-icon-chevron-down'); initialToggle.focus(); initialToggle.click(); @@ -208,16 +288,14 @@ describe('editor folding on File', () => { expect(shadow.activeElement).toBe(foldedToggle); expect(foldedToggle.hasAttribute('data-folded')).toBe(true); expect(foldedToggle.getAttribute('aria-expanded')).toBe('false'); - expect(foldIconHref(foldedToggle)).toBe( - '#diffs-editor-icon-chevron-right' - ); + expect(foldIconHref(foldedToggle)).toBe('#diffs-icon-chevron-right'); const indicator = foldIndicator(container, 1); const ellipsis = foldEllipsis(container, 1); expect(indicator.parentElement?.dataset.line).toBe('1'); - expect(indicator.getAttribute('contenteditable')).toBe('false'); + expect(indicator.contentEditable).toBe('false'); expect(ellipsis.ariaLabel).toBe('Unfold line 1'); - expect(foldIconHref(ellipsis)).toBe('#diffs-editor-icon-ellipsis'); + expect(foldIconHref(ellipsis)).toBe('#diffs-icon-ellipsis'); expect(indicator.dataset.foldEndText).toBeUndefined(); expect(indicator.children.length).toBe(1); expect(indicator.firstElementChild).toBe(ellipsis); @@ -230,9 +308,7 @@ describe('editor folding on File', () => { const unfoldedToggle = foldToggle(container, 1); expect(shadow.activeElement).toBe(unfoldedToggle); expect(unfoldedToggle.hasAttribute('data-folded')).toBe(false); - expect(foldIconHref(unfoldedToggle)).toBe( - '#diffs-editor-icon-chevron-down' - ); + expect(foldIconHref(unfoldedToggle)).toBe('#diffs-icon-chevron-down'); } finally { cleanup(); } @@ -256,24 +332,43 @@ describe('editor folding on File', () => { }); test('responds to folding option changes at runtime', async () => { - const { cleanup, container, editor } = await createFileEditorFixture({ - folding: false, - }); + const { cleanup, container, file } = await createFileEditorFixture( + { folding: false }, + FOLDABLE_CONTENTS, + false + ); try { - editor.setOptions({ folding: true }); - await waitFor(() => foldToggle(container, 1) != null); + file.setOptions({ ...file.options, folding: true }); + file.rerender(); + await waitFor( + () => + shadowRoot(container).querySelector( + '[data-column-number="1"] [data-fold-toggle]' + ) != null + ); + expect(renderedLineNumbers(container)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]); + expect(foldToggle(container, 1).hasAttribute('data-folded')).toBe(false); foldToggle(container, 1).click(); await waitForLines(container, [1, 7, 8]); - editor.setOptions({ folding: false }); + file.setOptions({ ...file.options, folding: false }); + file.rerender(); await waitForLines(container, [1, 2, 3, 4, 5, 6, 7, 8]); expect(shadowRoot(container).querySelector('[data-fold-toggle]')).toBe( null ); - editor.setOptions({ folding: true }); - expect(foldToggle(container, 1)).toBeInstanceOf(HTMLButtonElement); + file.setOptions({ ...file.options, folding: true }); + file.rerender(); + await waitFor( + () => + shadowRoot(container).querySelector( + '[data-column-number="1"] [data-fold-toggle]' + ) != null + ); + expect(renderedLineNumbers(container)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]); + expect(foldToggle(container, 1).hasAttribute('data-folded')).toBe(false); } finally { cleanup(); } @@ -597,54 +692,425 @@ describe('editor folding on File', () => { }); }); -describe('editor folding on FileDiff', () => { - test('does not render fold controls even when folding is enabled', async () => { +describe('folding on FileDiff', () => { + for (const diffStyle of ['split', 'unified'] as const) { + test(`${diffStyle} folds and unfolds a read-only additions block`, async () => { + const dom = installDom(); + const container = document.createElement('div'); + document.body.appendChild(container); + const fileDiff = new FileDiff({ + disableFileHeader: true, + diffStyle, + folding: true, + parseDiffOptions: { context: 20 }, + theme: DEFAULT_THEMES, + }); + const oldFile: FileContents = { + name: 'foldable.ts', + contents: FOLDABLE_CONTENTS, + }; + const newFile: FileContents = { + name: 'foldable.ts', + contents: FOLDABLE_CONTENTS.replace( + ' return before;', + ' return before + 1;' + ), + }; + + try { + fileDiff.render({ + oldFile, + newFile, + fileContainer: container, + forceRender: true, + }); + + await waitFor( + () => { + const code = container.shadowRoot?.querySelector( + `[data-code][data-${diffStyle === 'split' ? 'additions' : 'unified'}]` + ); + return ( + code?.querySelector( + '[data-column-number="1"] ' + + '[data-fold-toggle][data-fold-side="additions"]' + ) != null + ); + }, + { timeout: 3000 } + ); + + const additions = codeColumn( + container, + diffStyle === 'split' ? 'additions' : 'unified' + ); + expect( + additions.querySelector('[data-content]') + ?.contentEditable + ).not.toBe('true'); + + const initialLines = + diffStyle === 'split' + ? [1, 2, 3, 4, 5, 6, 7, 8] + : [1, 2, 3, 4, 5, 6, 6, 7, 8]; + await waitForColumnLines(additions, initialLines); + + columnFoldToggle(additions, 1, 'additions').click(); + await waitForColumnLines(additions, [1, 7, 8]); + + const deletions = + diffStyle === 'split' + ? codeColumn(container, 'deletions') + : undefined; + if (deletions !== undefined) { + await waitForColumnLines(deletions, [1, 7, 8]); + } + + columnFoldEllipsis(additions, 1, 'additions').click(); + await waitForColumnLines(additions, initialLines); + if (deletions !== undefined) { + await waitForColumnLines(deletions, [1, 2, 3, 4, 5, 6, 7, 8]); + columnFoldToggle(deletions, 1, 'deletions').click(); + await waitForColumnLines(additions, [1, 7, 8]); + await waitForColumnLines(deletions, [1, 7, 8]); + columnFoldEllipsis(deletions, 1, 'deletions').click(); + await waitForColumnLines(additions, initialLines); + await waitForColumnLines(deletions, initialLines); + } + } finally { + await wait(10); + fileDiff.cleanUp(); + dom.cleanup(); + } + }); + } + + test('unified folds a read-only deleted file', async () => { const dom = installDom(); const container = document.createElement('div'); document.body.appendChild(container); const fileDiff = new FileDiff({ disableFileHeader: true, - diffStyle: 'split', + diffStyle: 'unified', + folding: true, + parseDiffOptions: { context: 20 }, theme: DEFAULT_THEMES, }); - const editor = new Editor({ folding: true }); - const oldFile: FileContents = { - name: 'foldable.ts', - contents: FOLDABLE_CONTENTS, - }; - const newFile: FileContents = { - name: 'foldable.ts', - contents: FOLDABLE_CONTENTS.replace( - ' return before;', - ' return before + 1;' - ), - }; try { fileDiff.render({ - oldFile, - newFile, + oldFile: { name: 'deleted.ts', contents: FOLDABLE_CONTENTS }, + newFile: null, fileContainer: container, forceRender: true, }); - editor.edit(fileDiff); - await waitForEditableContent(container); - const shadow = shadowRoot(container); - expect(shadow.querySelector('[data-code][data-editor-folding]')).toBe( - null + await waitFor( + () => + container.shadowRoot?.querySelector( + '[data-code][data-unified] ' + + '[data-column-number="1"] ' + + '[data-fold-toggle][data-fold-side="deletions"]' + ) != null, + { timeout: 3000 } ); - expect(shadow.querySelector('[data-fold]')).toBe(null); - expect(shadow.querySelector('[data-fold-toggle]')).toBe(null); + const unified = codeColumn(container, 'unified'); + await waitForColumnLines(unified, [1, 2, 3, 4, 5, 6, 7, 8]); - editor.setOptions({ folding: false }); - editor.setOptions({ folding: true }); - expect(shadow.querySelector('[data-fold-toggle]')).toBe(null); + columnFoldToggle(unified, 1, 'deletions').click(); + await waitForColumnLines(unified, [1, 7, 8]); + columnFoldEllipsis(unified, 1, 'deletions').click(); + await waitForColumnLines(unified, [1, 2, 3, 4, 5, 6, 7, 8]); } finally { await wait(10); - editor.cleanUp(); fileDiff.cleanUp(); dom.cleanup(); } }); + + test('unified folds a deleted block in a modified file', async () => { + const dom = installDom(); + const container = document.createElement('div'); + document.body.appendChild(container); + const fileDiff = new FileDiff({ + disableFileHeader: true, + diffStyle: 'unified', + folding: true, + parseDiffOptions: { context: 20 }, + theme: DEFAULT_THEMES, + }); + const deletedBodySelector = + '[data-content] > [data-line="3"][data-line-type="change-deletion"]'; + + try { + fileDiff.render({ + oldFile: { + name: 'modified.ts', + contents: [ + 'function outer() {', + ' if (before) {', + ' console.log(before);', + ' }', + ' return before;', + '}', + ].join('\n'), + }, + newFile: { + name: 'modified.ts', + contents: ['function outer() {', ' return before;', '}'].join('\n'), + }, + fileContainer: container, + forceRender: true, + }); + + await waitFor( + () => + container.shadowRoot?.querySelector( + '[data-column-number="2"] ' + + '[data-fold-toggle][data-fold-side="deletions"]' + ) != null, + { timeout: 3000 } + ); + const unified = codeColumn(container, 'unified'); + expect(unified.querySelector(deletedBodySelector)).not.toBe(null); + + columnFoldToggle(unified, 2, 'deletions').click(); + await waitFor(() => unified.querySelector(deletedBodySelector) == null); + columnFoldEllipsis(unified, 2, 'deletions').click(); + await waitFor(() => unified.querySelector(deletedBodySelector) != null); + } finally { + await wait(10); + fileDiff.cleanUp(); + dom.cleanup(); + } + }); + + test('unified places a shifted deletion fold on its header', async () => { + const dom = installDom(); + const container = document.createElement('div'); + document.body.appendChild(container); + const fileDiff = new FileDiff({ + disableFileHeader: true, + diffStyle: 'unified', + folding: true, + parseDiffOptions: { context: 20 }, + theme: DEFAULT_THEMES, + }); + + try { + fileDiff.render({ + oldFile: { + name: 'shifted.ts', + contents: [ + 'const removedA = true;', + 'const removedB = true;', + 'function outer() {', + ' oldOnly();', + '}', + 'const tail = true;', + ].join('\n'), + }, + newFile: { + name: 'shifted.ts', + contents: [ + 'const inserted = true;', + 'function outer() {', + '}', + 'const tail = true;', + ].join('\n'), + }, + fileContainer: container, + forceRender: true, + }); + + const unified = codeColumn(container, 'unified'); + await waitFor( + () => + unified.querySelector( + '[data-fold-toggle][data-fold-side="deletions"]' + ) != null, + { timeout: 3000 } + ); + + const toggle = unified.querySelector( + '[data-fold-toggle][data-fold-side="deletions"]' + ); + const gutter = unified.querySelector('[data-gutter]'); + const content = unified.querySelector('[data-content]'); + const header = [...(content?.children ?? [])].find((row) => + row.textContent?.includes('function outer()') + ); + const toggleRow = toggle?.closest('[data-column-number]'); + if ( + toggle == null || + gutter == null || + content == null || + header == null || + toggleRow == null + ) { + throw new Error('shifted deletion fold did not render'); + } + + expect(toggle.ariaLabel).toBe('Fold line 3'); + expect([...gutter.children].indexOf(toggleRow)).toBe( + [...content.children].indexOf(header) + ); + + toggle.click(); + await waitFor( + () => + unified.querySelector( + '[data-content] > [data-line="4"]' + + '[data-line-type="change-deletion"]' + ) == null + ); + expect( + header.querySelector( + '[data-fold-indicator][data-fold-side="deletions"]' + ) + ).not.toBe(null); + } finally { + await wait(10); + fileDiff.cleanUp(); + dom.cleanup(); + } + }); + + test('unified folds both sides from a shared shifted header', async () => { + const dom = installDom(); + const container = document.createElement('div'); + document.body.appendChild(container); + const fileDiff = new FileDiff({ + disableFileHeader: true, + diffStyle: 'unified', + folding: true, + parseDiffOptions: { context: 20 }, + theme: DEFAULT_THEMES, + }); + const oldOnlySelector = + '[data-content] > [data-line="6"]' + '[data-line-type="change-deletion"]'; + const newOnlySelector = + '[data-content] > [data-line="4"]' + '[data-line-type="change-addition"]'; + + try { + fileDiff.render({ + oldFile: { + name: 'shared.ts', + contents: [ + 'const removedA = true;', + 'const removedB = true;', + 'function outer() {', + ' const before = true;', + ' oldA();', + ' oldB();', + ' return before;', + '}', + 'const tail = true;', + ].join('\n'), + }, + newFile: { + name: 'shared.ts', + contents: [ + 'const inserted = true;', + 'function outer() {', + ' const before = true;', + ' newOnly();', + ' return before;', + '}', + 'const tail = true;', + ].join('\n'), + }, + fileContainer: container, + forceRender: true, + }); + + const unified = codeColumn(container, 'unified'); + await waitFor( + () => + unified.querySelector( + '[data-column-number="2"] ' + + '[data-fold-toggle][data-fold-side="additions"]' + ) != null, + { timeout: 3000 } + ); + expect(unified.querySelector(oldOnlySelector)).not.toBe(null); + expect(unified.querySelector(newOnlySelector)).not.toBe(null); + + columnFoldToggle(unified, 2, 'additions').click(); + await waitFor( + () => + unified.querySelector(oldOnlySelector) == null && + unified.querySelector(newOnlySelector) == null + ); + expect( + unified.querySelectorAll('[data-content] [data-fold-indicator]') + ).toHaveLength(1); + + columnFoldEllipsis(unified, 2, 'additions').click(); + await waitFor( + () => + unified.querySelector(oldOnlySelector) != null && + unified.querySelector(newOnlySelector) != null + ); + } finally { + await wait(10); + fileDiff.cleanUp(); + dom.cleanup(); + } + }); + + for (const diffStyle of ['split', 'unified'] as const) { + test(`${diffStyle} ignores folds inside expanded unchanged context`, async () => { + const dom = installDom(); + const container = document.createElement('div'); + document.body.appendChild(container); + const fileDiff = new FileDiff({ + disableFileHeader: true, + diffStyle, + expandUnchanged: true, + folding: true, + theme: DEFAULT_THEMES, + }); + const unchangedTail = Array.from( + { length: 12 }, + (_, index) => `const padding${index} = true;` + ).join('\n'); + const oldContents = `${FOLDABLE_CONTENTS}\n${unchangedTail}\nconst tail = true;`; + const newContents = oldContents.replace( + 'const tail = true;', + 'const tail = false;' + ); + + try { + fileDiff.render({ + oldFile: { name: 'expanded.ts', contents: oldContents }, + newFile: { name: 'expanded.ts', contents: newContents }, + fileContainer: container, + forceRender: true, + }); + + const additions = codeColumn( + container, + diffStyle === 'split' ? 'additions' : 'unified' + ); + await waitFor(() => renderedColumnLineNumbers(additions).length >= 21, { + timeout: 3000, + }); + const initialLines = renderedColumnLineNumbers(additions); + + fileDiff.__setFoldRanges([{ startLine: 1, endLine: 5 }], 'additions'); + await waitForColumnLines(additions, initialLines); + expect( + additions.querySelector( + '[data-column-number="1"] ' + + '[data-fold-toggle][data-fold-side="additions"]' + ) + ).toBe(null); + } finally { + await wait(10); + fileDiff.cleanUp(); + dom.cleanup(); + } + }); + } }); diff --git a/packages/diffs/test/virtualizedFileDiffEstimatedHeights.test.ts b/packages/diffs/test/virtualizedFileDiffEstimatedHeights.test.ts index a501dea13..301450bc3 100644 --- a/packages/diffs/test/virtualizedFileDiffEstimatedHeights.test.ts +++ b/packages/diffs/test/virtualizedFileDiffEstimatedHeights.test.ts @@ -42,6 +42,7 @@ const virtualizer = { getOffsetInScrollContainer() { return 0; }, + markDOMDirty() {}, instanceChanged() {}, isInstanceVisible() { return true; @@ -50,6 +51,10 @@ const virtualizer = { } as never; interface InspectableVirtualizedFileDiff { + additionFoldingManager: { + getFoldRanges(): { startLine: number; endLine: number }[]; + toggleFold(startLine: number): boolean; + }; cache: { heightDeltas: Map; measuredHeightDeltaTotal: number; @@ -283,6 +288,53 @@ function countIteratedRows( } describe('VirtualizedFileDiff estimated height cache', () => { + test('preserves read-only folds across recycle but clears them for a new diff', () => { + const oldFile = { + name: 'folded.ts', + contents: [ + 'function outer() {', + ' const before = 1;', + ' return before;', + '}', + ].join('\n'), + }; + const newFile = { + ...oldFile, + contents: oldFile.contents.replace('before = 1', 'before = 2'), + }; + const fileDiff = parseDiffFromFile(oldFile, newFile, { context: 20 }); + const instance = new VirtualizedFileDiff( + { diffStyle: 'unified', folding: true }, + virtualizer, + metrics + ); + instance.prepareCodeViewItem(fileDiff, 0); + const unfoldedHeight = instance.getVirtualizedHeight(); + + expect(inspect(instance).additionFoldingManager.toggleFold(0)).toBe(true); + const foldedHeight = instance.getVirtualizedHeight(); + expect(foldedHeight).toBeLessThan(unfoldedHeight); + + instance.cleanUp(true); + instance.virtualizedSetup(); + instance.prepareCodeViewItem(fileDiff, 0); + expect(inspect(instance).additionFoldingManager.getFoldRanges()).toEqual([ + { startLine: 0, endLine: 2 }, + ]); + expect(instance.getVirtualizedHeight()).toBe(foldedHeight); + + const nextDiff = parseDiffFromFile( + { ...oldFile, name: 'next.ts' }, + { ...newFile, name: 'next.ts' }, + { context: 20 } + ); + instance.prepareCodeViewItem(nextDiff, 0); + expect(inspect(instance).additionFoldingManager.getFoldRanges()).toEqual( + [] + ); + expect(instance.getVirtualizedHeight()).toBe(unfoldedHeight); + }); + test('computes split and unified estimates together on first prepare', () => { const instance = new VirtualizedFileDiff({}, virtualizer, metrics); @@ -891,6 +943,44 @@ describe('VirtualizedFileDiff estimated height cache', () => { expect(inspect(instance).cache.checkpoints.length).toBeGreaterThan(1); }); + test('maps folded visible windows back to raw render rows', () => { + const instance = new VirtualizedFileDiff( + { diffStyle: 'unified' }, + virtualizer, + metrics + ); + const fileDiff = createHugeSingleBlockDiff(1_000); + instance.prepareCodeViewItem(fileDiff, 0); + instance.__setFoldRanges([{ startLine: 1, endLine: 998 }], 'additions'); + + const range = inspect(instance).computeRenderRangeFromWindow(fileDiff, 0, { + top: metrics.diffHeaderHeight, + bottom: metrics.diffHeaderHeight + 20, + }); + + expect(range.startingLine).toBe(0); + expect(range.startingLine + range.totalLines).toBeGreaterThanOrEqual(1_000); + }); + + test('removes folded split metadata from the estimated height', () => { + const instance = new VirtualizedFileDiff( + { diffStyle: 'split', parseDiffOptions: { context: 20 } }, + virtualizer, + metrics + ); + const fileDiff = parseDiffFromFile( + { name: 'no-eof.ts', contents: 'if (x)\n old' }, + { name: 'no-eof.ts', contents: 'if (x)\n a\n b\n c\n' }, + { context: 20 } + ); + instance.prepareCodeViewItem(fileDiff, 0); + instance.__setFoldRanges([{ startLine: 1, endLine: 3 }], 'additions'); + + expect(instance.getVirtualizedHeight()).toBe( + metrics.diffHeaderHeight + metrics.lineHeight + metrics.spacing + ); + }); + test('ignores trailing fromEnd expansion in render range line totals', () => { const { cleanup } = installDom(); const fileDiff = createTwoHunkDiff();