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

@pierre/diffs — E2E fixtures

window.__editableReady.

+
  • + folding.html +

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

    +
  • markers.html

    diff --git a/packages/diffs/test/e2e/folding.pw.ts b/packages/diffs/test/e2e/folding.pw.ts new file mode 100644 index 000000000..f1e4072e7 --- /dev/null +++ b/packages/diffs/test/e2e/folding.pw.ts @@ -0,0 +1,100 @@ +import { expect, type Page, test } from '@playwright/test'; + +const GUTTER = '[data-code][data-editor-folding] [data-gutter]'; +const CONTENT_ROWS = '[data-content] > [data-line]'; +const OUTER_TOGGLE = '[data-column-number="1"] [data-fold-toggle]'; +const OUTER_INDICATOR = + '[data-content] > [data-line="1"] > [data-fold-indicator]'; + +async function openFixture(page: Page): Promise { + await page.goto('/test/e2e/fixtures/folding.html'); + await page.waitForFunction(() => window.__foldingReady === true); +} + +const renderedLineNumbers = (page: Page): Promise => + page + .locator(CONTENT_ROWS) + .evaluateAll((rows) => + rows.map((row) => Number((row as HTMLElement).dataset.line)) + ); + +const firstTokenColor = (page: Page): Promise => + page + .locator('[data-content] > [data-line="1"] [data-char]') + .first() + .evaluate((element) => getComputedStyle(element).color); + +test.describe('editor folding controls', () => { + test('reveal on hover and fold or unfold the outer block', async ({ + page, + }) => { + await openFixture(page); + + const gutter = page.locator(GUTTER); + let toggle = page.locator(OUTER_TOGGLE); + await expect(toggle.locator('use')).toHaveAttribute( + 'href', + '#diffs-editor-icon-chevron-down' + ); + await expect(toggle).toHaveCSS('opacity', '0'); + + await gutter.hover(); + await expect(toggle).toHaveCSS('opacity', '0.5'); + + await toggle.hover(); + await expect(toggle).toHaveCSS('opacity', '0.75'); + + await toggle.click(); + await expect.poll(() => renderedLineNumbers(page)).toEqual([1, 7, 8]); + + await page.mouse.move(1150, 780); + toggle = page.locator(OUTER_TOGGLE); + await expect(toggle).toHaveAttribute('data-folded', ''); + await expect(toggle.locator('use')).toHaveAttribute( + 'href', + '#diffs-editor-icon-chevron-right' + ); + await expect(toggle).toHaveCSS('opacity', '0.5'); + + const indicator = page.locator(OUTER_INDICATOR); + const ellipsis = indicator.locator('[data-fold-ellipsis]'); + await expect(indicator).toBeVisible(); + await expect(ellipsis.locator('use')).toHaveAttribute( + 'href', + '#diffs-editor-icon-ellipsis' + ); + expect(await indicator.getAttribute('data-fold-end-text')).toBeNull(); + await expect(indicator).toHaveText(''); + await expect(page.locator('[data-content] > [data-line="7"]')).toHaveText( + '}' + ); + + const ellipsisBox = await ellipsis.boundingBox(); + const indicatorBox = await indicator.boundingBox(); + expect(ellipsisBox).not.toBeNull(); + expect(indicatorBox).not.toBeNull(); + expect(indicatorBox!.width).toBeCloseTo(ellipsisBox!.width, 5); + + const darkTokenColor = await firstTokenColor(page); + await ellipsis.focus(); + await expect(ellipsis).toBeFocused(); + await page.evaluate(() => window.__setFoldingTheme?.()); + await expect.poll(() => firstTokenColor(page)).not.toBe(darkTokenColor); + await expect(ellipsis).toBeFocused(); + + await ellipsis.click(); + await expect + .poll(() => renderedLineNumbers(page)) + .toEqual([1, 2, 3, 4, 5, 6, 7, 8]); + await expect(indicator).toHaveCount(0); + + await page.mouse.move(1150, 780); + toggle = page.locator(OUTER_TOGGLE); + await expect(toggle).not.toHaveAttribute('data-folded', ''); + await expect(toggle.locator('use')).toHaveAttribute( + 'href', + '#diffs-editor-icon-chevron-down' + ); + await expect(toggle).toHaveCSS('opacity', '0'); + }); +}); diff --git a/packages/diffs/test/editorFolding.test.ts b/packages/diffs/test/editorFolding.test.ts new file mode 100644 index 000000000..5cf65e2df --- /dev/null +++ b/packages/diffs/test/editorFolding.test.ts @@ -0,0 +1,650 @@ +import { afterAll, describe, expect, test } from 'bun:test'; + +import { File } from '../src/components/File'; +import { FileDiff } from '../src/components/FileDiff'; +import { DEFAULT_THEMES } from '../src/constants'; +import { Editor, type EditorOptions } from '../src/editor/editor'; +import { disposeHighlighter } from '../src/highlighter/shared_highlighter'; +import type { FileContents, LineRange } from '../src/types'; +import { installDom, wait, waitFor } from './domHarness'; + +afterAll(async () => { + await disposeHighlighter(); +}); + +const FOLDABLE_CONTENTS = [ + 'function outer() {', + ' const before = 1;', + ' if (before) {', + ' console.log(before);', + ' }', + ' return before;', + '}', + 'const after = true;', +].join('\n'); + +interface FileEditorFixture { + cleanup(): void; + container: HTMLElement; + editor: Editor; + file: File; +} + +async function waitForEditableContent(container: HTMLElement): Promise { + const hasEditableContent = (): boolean => + [ + ...(container.shadowRoot?.querySelectorAll( + '[data-content]' + ) ?? []), + ].some( + (content) => + content.contentEditable === 'true' || + content.getAttribute('contenteditable') === 'true' + ); + + await waitFor(hasEditableContent, { timeout: 3000 }); + + expect(hasEditableContent()).toBe(true); +} + +async function createFileEditorFixture( + editorOptions?: EditorOptions, + contents = FOLDABLE_CONTENTS +): Promise { + const dom = installDom(); + const container = document.createElement('div'); + document.body.appendChild(container); + + const file = new File({ + disableFileHeader: true, + theme: DEFAULT_THEMES, + }); + const editor = new Editor(editorOptions); + const fileContents: FileContents = { + name: 'foldable.ts', + contents, + }; + + file.render({ + file: fileContents, + fileContainer: container, + forceRender: true, + }); + editor.edit(file); + await waitForEditableContent(container); + + return { + cleanup() { + editor.cleanUp(); + file.cleanUp(); + dom.cleanup(); + }, + container, + editor, + file, + }; +} + +function shadowRoot(container: HTMLElement): ShadowRoot { + const shadow = container.shadowRoot; + if (shadow == null) { + throw new Error('file container has no shadow root'); + } + return shadow; +} + +function renderedLineNumbers(container: HTMLElement): number[] { + return [ + ...shadowRoot(container).querySelectorAll( + '[data-content] > [data-line]' + ), + ].map((line) => Number(line.dataset.line)); +} + +function gutterRow( + container: HTMLElement, + oneIndexedLine: number +): HTMLElement { + const row = shadowRoot(container).querySelector( + `[data-column-number="${oneIndexedLine}"]` + ); + if (row == null) { + throw new Error(`no gutter row found for line ${oneIndexedLine}`); + } + return row; +} + +function foldToggle( + container: HTMLElement, + oneIndexedLine: number +): HTMLButtonElement { + const toggle = gutterRow(container, oneIndexedLine).querySelector( + '[data-fold-toggle]' + ); + if (!(toggle instanceof HTMLButtonElement)) { + throw new Error(`no fold toggle found for line ${oneIndexedLine}`); + } + return toggle; +} + +function foldIconHref(toggle: HTMLButtonElement): string | null { + return toggle.querySelector('use')?.getAttribute('href') ?? null; +} + +function foldIndicator( + container: HTMLElement, + oneIndexedLine: number +): HTMLElement { + const indicator = shadowRoot(container).querySelector( + `[data-content] > [data-line="${oneIndexedLine}"] > [data-fold-indicator]` + ); + if (indicator == null) { + throw new Error(`no fold indicator found for line ${oneIndexedLine}`); + } + return indicator; +} + +function foldEllipsis( + container: HTMLElement, + oneIndexedLine: number +): HTMLButtonElement { + const ellipsis = foldIndicator(container, oneIndexedLine).querySelector( + '[data-fold-ellipsis]' + ); + if (!(ellipsis instanceof HTMLButtonElement)) { + throw new Error(`no fold ellipsis found for line ${oneIndexedLine}`); + } + return ellipsis; +} + +function recordFoldRangeUpdates(file: File): LineRange[][] { + const updates: LineRange[][] = []; + const setFoldRanges = file.__setFoldRanges.bind(file); + file.__setFoldRanges = (ranges) => { + updates.push(ranges.map((range) => ({ ...range }))); + setFoldRanges(ranges); + }; + return updates; +} + +async function waitForLines( + container: HTMLElement, + expected: number[] +): Promise { + await waitFor( + () => + JSON.stringify(renderedLineNumbers(container)) === + JSON.stringify(expected), + { timeout: 3000 } + ); + expect(renderedLineNumbers(container)).toEqual(expected); +} + +describe('editor folding on File', () => { + test('is enabled by default and folds or unfolds a block from the gutter', async () => { + const { cleanup, container } = await createFileEditorFixture(); + try { + const shadow = shadowRoot(container); + const firstGutterRow = gutterRow(container, 1); + const foldZone = firstGutterRow.querySelector(':scope > [data-fold]'); + const initialToggle = foldToggle(container, 1); + + expect(shadow.querySelector('[data-code][data-editor-folding]')).not.toBe( + null + ); + expect(foldZone?.parentElement).toBe(firstGutterRow); + expect(firstGutterRow.closest('[data-gutter]')).not.toBe(null); + expect(foldZone?.contains(initialToggle)).toBe(true); + expect(initialToggle.getAttribute('aria-expanded')).toBe('true'); + expect(foldIconHref(initialToggle)).toBe( + '#diffs-editor-icon-chevron-down' + ); + + initialToggle.focus(); + initialToggle.click(); + await waitForLines(container, [1, 7, 8]); + + const foldedToggle = foldToggle(container, 1); + expect(shadow.activeElement).toBe(foldedToggle); + expect(foldedToggle.hasAttribute('data-folded')).toBe(true); + expect(foldedToggle.getAttribute('aria-expanded')).toBe('false'); + expect(foldIconHref(foldedToggle)).toBe( + '#diffs-editor-icon-chevron-right' + ); + + const indicator = foldIndicator(container, 1); + const ellipsis = foldEllipsis(container, 1); + expect(indicator.parentElement?.dataset.line).toBe('1'); + expect(indicator.getAttribute('contenteditable')).toBe('false'); + expect(ellipsis.ariaLabel).toBe('Unfold line 1'); + expect(foldIconHref(ellipsis)).toBe('#diffs-editor-icon-ellipsis'); + expect(indicator.dataset.foldEndText).toBeUndefined(); + expect(indicator.children.length).toBe(1); + expect(indicator.firstElementChild).toBe(ellipsis); + expect(indicator.textContent).toBe(''); + + ellipsis.click(); + await waitForLines(container, [1, 2, 3, 4, 5, 6, 7, 8]); + expect(shadow.querySelector('[data-fold-indicator]')).toBe(null); + + const unfoldedToggle = foldToggle(container, 1); + expect(shadow.activeElement).toBe(unfoldedToggle); + expect(unfoldedToggle.hasAttribute('data-folded')).toBe(false); + expect(foldIconHref(unfoldedToggle)).toBe( + '#diffs-editor-icon-chevron-down' + ); + } finally { + cleanup(); + } + }); + + test('can be disabled at construction', async () => { + const { cleanup, container } = await createFileEditorFixture({ + folding: false, + }); + try { + const shadow = shadowRoot(container); + expect(shadow.querySelector('[data-code][data-editor-folding]')).toBe( + null + ); + expect(shadow.querySelector('[data-fold]')).toBe(null); + expect(shadow.querySelector('[data-fold-toggle]')).toBe(null); + expect(renderedLineNumbers(container)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]); + } finally { + cleanup(); + } + }); + + test('responds to folding option changes at runtime', async () => { + const { cleanup, container, editor } = await createFileEditorFixture({ + folding: false, + }); + try { + editor.setOptions({ folding: true }); + await waitFor(() => foldToggle(container, 1) != null); + + foldToggle(container, 1).click(); + await waitForLines(container, [1, 7, 8]); + + editor.setOptions({ folding: false }); + await waitForLines(container, [1, 2, 3, 4, 5, 6, 7, 8]); + expect(shadowRoot(container).querySelector('[data-fold-toggle]')).toBe( + null + ); + + editor.setOptions({ folding: true }); + expect(foldToggle(container, 1)).toBeInstanceOf(HTMLButtonElement); + } finally { + cleanup(); + } + }); + + test('preserves a nested fold while its outer fold is toggled', async () => { + const { cleanup, container } = await createFileEditorFixture(); + try { + foldToggle(container, 3).click(); + await waitForLines(container, [1, 2, 3, 5, 6, 7, 8]); + + foldToggle(container, 1).click(); + await waitForLines(container, [1, 7, 8]); + + foldToggle(container, 1).click(); + await waitForLines(container, [1, 2, 3, 5, 6, 7, 8]); + + const nestedToggle = foldToggle(container, 3); + expect(nestedToggle.hasAttribute('data-folded')).toBe(true); + expect(foldIconHref(nestedToggle)).toBe( + '#diffs-editor-icon-chevron-right' + ); + + nestedToggle.click(); + await waitForLines(container, [1, 2, 3, 4, 5, 6, 7, 8]); + } finally { + cleanup(); + } + }); + + test('round-trips nested fold state and ignores stale ranges', async () => { + const { cleanup, container, editor } = await createFileEditorFixture(); + try { + foldToggle(container, 3).click(); + await waitForLines(container, [1, 2, 3, 5, 6, 7, 8]); + foldToggle(container, 1).click(); + await waitForLines(container, [1, 7, 8]); + + expect(editor.getState().foldRanges).toEqual([ + { startLine: 0, endLine: 5 }, + { startLine: 2, endLine: 3 }, + ]); + + editor.setState({ foldRanges: [] }); + await waitForLines(container, [1, 2, 3, 4, 5, 6, 7, 8]); + + editor.setState({ + foldRanges: [ + { startLine: 0, endLine: 6 }, + { startLine: 2, endLine: 3 }, + { startLine: 0, endLine: 5 }, + { startLine: 2, endLine: 4 }, + { startLine: 2, endLine: 3 }, + { startLine: -1, endLine: 5 }, + { startLine: 7, endLine: 9 }, + ], + }); + await waitForLines(container, [1, 7, 8]); + expect(editor.getState().foldRanges).toEqual([ + { startLine: 0, endLine: 5 }, + { startLine: 2, endLine: 3 }, + ]); + + foldToggle(container, 1).click(); + await waitForLines(container, [1, 2, 3, 5, 6, 7, 8]); + expect(foldToggle(container, 3).hasAttribute('data-folded')).toBe(true); + } finally { + cleanup(); + } + }); + + test('reveals a folded caret restored through setState', async () => { + const { cleanup, container, editor } = await createFileEditorFixture(); + try { + foldToggle(container, 1).click(); + await waitForLines(container, [1, 7, 8]); + + editor.setState({ + selections: [ + { + start: { line: 3, character: 4 }, + end: { line: 3, character: 4 }, + direction: 0, + }, + ], + }); + + await waitForLines(container, [1, 2, 3, 4, 5, 6, 7, 8]); + expect(editor.getState().selections?.at(-1)?.start).toEqual({ + line: 3, + character: 4, + }); + } finally { + cleanup(); + } + }); + + test('keeps a fold when restoring a caret on its visible delimiter', async () => { + const { cleanup, container, editor } = await createFileEditorFixture(); + try { + foldToggle(container, 1).click(); + await waitForLines(container, [1, 7, 8]); + + editor.setState({ + foldRanges: [{ startLine: 0, endLine: 5 }], + selections: [ + { + start: { line: 6, character: 0 }, + end: { line: 6, character: 0 }, + direction: 0, + }, + ], + }); + + await waitForLines(container, [1, 7, 8]); + expect(editor.getState().foldRanges).toEqual([ + { startLine: 0, endLine: 5 }, + ]); + expect(editor.getState().selections?.at(-1)?.start).toEqual({ + line: 6, + character: 0, + }); + } finally { + cleanup(); + } + }); + + test('keeps a fold between line-changing edits in a net-zero batch', async () => { + const contents = [ + 'const first = {', + ' value: 1,', + '};', + 'const middle = true;', + 'const second = {', + ' value: 2,', + '};', + 'removeMe();', + 'keepMe();', + ].join('\n'); + const { cleanup, container, editor } = await createFileEditorFixture( + undefined, + contents + ); + try { + foldToggle(container, 5).click(); + await waitForLines(container, [1, 2, 3, 4, 5, 7, 8, 9]); + + editor.applyEdits([ + { + range: { + start: { line: 3, character: 0 }, + end: { line: 3, character: 0 }, + }, + newText: 'inserted();\n', + }, + { + range: { + start: { line: 7, character: 0 }, + end: { line: 8, character: 0 }, + }, + newText: '', + }, + ]); + + await waitForLines(container, [1, 2, 3, 4, 5, 6, 8, 9]); + const shiftedToggle = foldToggle(container, 6); + expect(shiftedToggle.hasAttribute('data-folded')).toBe(true); + expect(foldIconHref(shiftedToggle)).toBe( + '#diffs-editor-icon-chevron-right' + ); + } finally { + cleanup(); + } + }); + + test('shifts a fold when a newline is inserted before its header', async () => { + const { cleanup, container, editor } = await createFileEditorFixture(); + try { + foldToggle(container, 1).click(); + await waitForLines(container, [1, 7, 8]); + let foldRangesDuringChange: LineRange[] | undefined; + editor.setOptions({ + onChange: () => { + foldRangesDuringChange = editor.getState().foldRanges; + }, + }); + + editor.applyEdits([ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 0 }, + }, + newText: '\n', + }, + ]); + + await waitForLines(container, [1, 2, 8, 9]); + const shiftedToggle = foldToggle(container, 2); + expect(shiftedToggle.hasAttribute('data-folded')).toBe(true); + expect(foldIconHref(shiftedToggle)).toBe( + '#diffs-editor-icon-chevron-right' + ); + expect(foldRangesDuringChange).toEqual([{ startLine: 1, endLine: 6 }]); + expect(editor.getState().foldRanges).toEqual([ + { startLine: 1, endLine: 6 }, + ]); + } finally { + cleanup(); + } + }); + + test('shifts a fold when whole lines are deleted before its header', async () => { + const { cleanup, container, editor } = await createFileEditorFixture( + undefined, + `removeMe();\n${FOLDABLE_CONTENTS}` + ); + try { + foldToggle(container, 2).click(); + await waitForLines(container, [1, 2, 8, 9]); + + editor.applyEdits([ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 1, character: 0 }, + }, + newText: '', + }, + ]); + + await waitForLines(container, [1, 7, 8]); + expect(foldToggle(container, 1).hasAttribute('data-folded')).toBe(true); + expect(editor.getState().foldRanges).toEqual([ + { startLine: 0, endLine: 5 }, + ]); + } finally { + cleanup(); + } + }); + + test('retains active fold controls across ordinary character edits', async () => { + const { cleanup, container, editor, file } = + await createFileEditorFixture(); + try { + foldToggle(container, 1).click(); + await waitForLines(container, [1, 7, 8]); + await wait(0); + + const outerToggle = foldToggle(container, 1); + const outerIndicator = foldIndicator(container, 1); + const foldRangeUpdates = recordFoldRangeUpdates(file); + + editor.applyEdits([ + { + range: { + start: { line: 0, character: 9 }, + end: { line: 0, character: 14 }, + }, + newText: 'inner', + }, + ]); + + await waitFor(() => editor.getText().includes('function inner() {')); + expect(foldToggle(container, 1)).toBe(outerToggle); + expect(foldIndicator(container, 1)).toBe(outerIndicator); + expect(outerToggle.isConnected).toBe(true); + expect(outerToggle.hasAttribute('data-folded')).toBe(true); + expect(foldRangeUpdates).toEqual([]); + } finally { + cleanup(); + } + }); + + test('recomputes a fold when its closing delimiter gains a suffix', async () => { + const { cleanup, container, editor } = await createFileEditorFixture( + undefined, + ['section {', ' child', '', '}', 'after'].join('\n') + ); + try { + foldToggle(container, 1).click(); + await waitForLines(container, [1, 4, 5]); + + editor.applyEdits([ + { + range: { + start: { line: 3, character: 1 }, + end: { line: 3, character: 1 }, + }, + newText: ' // comment', + }, + ]); + + await waitForLines(container, [1, 3, 4, 5]); + expect(editor.getState().foldRanges).toEqual([ + { startLine: 0, endLine: 1 }, + ]); + } finally { + cleanup(); + } + }); + + test('syncs each fold toggle to the host once', async () => { + const { cleanup, container, file } = await createFileEditorFixture(); + try { + const foldRangeUpdates = recordFoldRangeUpdates(file); + + foldToggle(container, 1).click(); + await waitForLines(container, [1, 7, 8]); + await wait(0); + expect(foldRangeUpdates).toEqual([[{ startLine: 1, endLine: 5 }]]); + + foldRangeUpdates.length = 0; + foldToggle(container, 1).click(); + await waitForLines(container, [1, 2, 3, 4, 5, 6, 7, 8]); + await wait(0); + expect(foldRangeUpdates).toEqual([[]]); + } finally { + cleanup(); + } + }); +}); + +describe('editor folding on FileDiff', () => { + test('does not render fold controls even when folding is enabled', async () => { + const dom = installDom(); + const container = document.createElement('div'); + document.body.appendChild(container); + const fileDiff = new FileDiff({ + disableFileHeader: true, + diffStyle: 'split', + theme: DEFAULT_THEMES, + }); + const editor = new Editor({ folding: true }); + const oldFile: FileContents = { + name: 'foldable.ts', + contents: FOLDABLE_CONTENTS, + }; + const newFile: FileContents = { + name: 'foldable.ts', + contents: FOLDABLE_CONTENTS.replace( + ' return before;', + ' return before + 1;' + ), + }; + + try { + fileDiff.render({ + oldFile, + newFile, + fileContainer: container, + forceRender: true, + }); + editor.edit(fileDiff); + await waitForEditableContent(container); + + const shadow = shadowRoot(container); + expect(shadow.querySelector('[data-code][data-editor-folding]')).toBe( + null + ); + expect(shadow.querySelector('[data-fold]')).toBe(null); + expect(shadow.querySelector('[data-fold-toggle]')).toBe(null); + + editor.setOptions({ folding: false }); + editor.setOptions({ folding: true }); + expect(shadow.querySelector('[data-fold-toggle]')).toBe(null); + } finally { + await wait(10); + editor.cleanUp(); + fileDiff.cleanUp(); + dom.cleanup(); + } + }); +}); diff --git a/packages/diffs/test/editorFoldingRanges.test.ts b/packages/diffs/test/editorFoldingRanges.test.ts new file mode 100644 index 000000000..d0881aa1a --- /dev/null +++ b/packages/diffs/test/editorFoldingRanges.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, test } from 'bun:test'; + +import { + computeIndentFoldingRanges, + LineRangeIndex, + mergeHiddenLineRanges, +} from '../src/editor/folding'; +import { TextDocument } from '../src/editor/textDocument'; + +function document(text: string) { + return new TextDocument('inmemory://folding', text); +} + +describe('computeIndentFoldingRanges', () => { + test('returns nested ranges in start-line order and excludes standalone closing delimiters', () => { + const ranges = computeIndentFoldingRanges( + document( + ['const value = {', ' nested: {', ' ok: true,', ' },', '};'].join( + '\n' + ) + ) + ); + + expect(ranges).toEqual([ + { startLine: 0, endLine: 3 }, + { startLine: 1, endLine: 2 }, + ]); + }); + + test('uses the next nonblank line and excludes trailing blank lines', () => { + const ranges = computeIndentFoldingRanges( + document(['section', '', ' child', '', 'sibling', '', ''].join('\n')) + ); + + expect(ranges).toEqual([{ startLine: 0, endLine: 2 }]); + }); + + test('includes blank lines before a visible closing delimiter', () => { + const ranges = computeIndentFoldingRanges( + document(['section {', ' child', '', '}'].join('\n')) + ); + + expect(ranges).toEqual([{ startLine: 0, endLine: 2 }]); + }); + + test('uses tab stops when comparing indentation', () => { + const ranges = computeIndentFoldingRanges( + document(['root', '\tchild', ' sibling', 'next'].join('\n')), + 4 + ); + + expect(ranges).toEqual([{ startLine: 0, endLine: 2 }]); + }); + + test('returns no range when nonblank indentation never increases', () => { + expect( + computeIndentFoldingRanges(document(['first', '', 'second'].join('\n'))) + ).toEqual([]); + }); + + test('only emits ranges that contain at least one child line', () => { + const ranges = computeIndentFoldingRanges( + document(['root', ' child', 'next', ' final child'].join('\n')) + ); + + expect(ranges).toEqual([ + { startLine: 0, endLine: 1 }, + { startLine: 2, endLine: 3 }, + ]); + expect(ranges.every((range) => range.endLine > range.startLine)).toBeTrue(); + }); +}); + +describe('mergeHiddenLineRanges', () => { + test('merges the bodies of selected overlapping and adjacent folds', () => { + const hiddenRanges = mergeHiddenLineRanges( + [ + { startLine: 0, endLine: 4 }, + { startLine: 2, endLine: 6 }, + { startLine: 6, endLine: 8 }, + { startLine: 10, endLine: 10 }, + { startLine: 12, endLine: 14 }, + ], + new Set([0, 2, 6, 10]) + ); + + expect(hiddenRanges).toEqual([{ startLine: 1, endLine: 8 }]); + }); +}); + +describe('LineRangeIndex', () => { + const index = new LineRangeIndex([ + { startLine: 7, endLine: 8 }, + { startLine: 3, endLine: 4 }, + { startLine: 2, endLine: 3 }, + ]); + + test('finds hidden lines and their merged containing range', () => { + expect(index.isHidden(1)).toBe(false); + expect(index.isHidden(2)).toBe(true); + expect(index.isHidden(4)).toBe(true); + expect(index.isHidden(5)).toBe(false); + expect(index.containingRange(3)).toEqual({ startLine: 2, endLine: 4 }); + expect(index.containingRange(6)).toBeUndefined(); + }); + + test('jumps past a hidden range without creating a range object', () => { + expect(index.lineAfterHiddenRange(1)).toBeUndefined(); + expect(index.lineAfterHiddenRange(2)).toBe(5); + expect(index.lineAfterHiddenRange(4)).toBe(5); + expect(index.lineAfterHiddenRange(7)).toBe(9); + expect(index.lineAfterHiddenRange(9)).toBeUndefined(); + }); + + test('normalizes ranges that are already ordered', () => { + const orderedIndex = new LineRangeIndex([ + { startLine: 2, endLine: 3 }, + { startLine: 3, endLine: 5 }, + { startLine: 8, endLine: 9 }, + ]); + + expect(orderedIndex.containingRange(4)).toEqual({ + startLine: 2, + endLine: 5, + }); + expect(orderedIndex.lineAtVisibleIndex(2, 12)).toBe(6); + }); + + test('counts hidden and visible lines at boundaries', () => { + expect(index.hiddenCountBefore(0)).toBe(0); + expect(index.hiddenCountBefore(2)).toBe(0); + expect(index.hiddenCountBefore(3)).toBe(1); + expect(index.hiddenCountBefore(5)).toBe(3); + expect(index.hiddenCountBefore(7)).toBe(3); + expect(index.hiddenCountBefore(8)).toBe(4); + expect(index.hiddenCountBefore(9)).toBe(5); + expect(index.visibleLineCount(12)).toBe(7); + }); + + test('maps visible indexes back to document lines', () => { + expect( + Array.from({ length: index.visibleLineCount(12) }, (_, visibleIndex) => + index.lineAtVisibleIndex(visibleIndex, 12) + ) + ).toEqual([0, 1, 5, 6, 9, 10, 11]); + expect(index.lineAtVisibleIndex(-1, 12)).toBeUndefined(); + expect(index.lineAtVisibleIndex(7, 12)).toBeUndefined(); + }); + + test('finds the nearest visible line in the requested direction', () => { + expect(index.nearestVisibleLine(3, 'up', 12)).toBe(1); + expect(index.nearestVisibleLine(3, 'down', 12)).toBe(5); + expect(index.nearestVisibleLine(5, 'up', 12)).toBe(5); + expect(index.nearestVisibleLine(7, 'up', 12)).toBe(6); + expect(index.nearestVisibleLine(7, 'down', 12)).toBe(9); + expect(index.nearestVisibleLine(-1, 'down', 12)).toBe(0); + expect(index.nearestVisibleLine(-1, 'up', 12)).toBeUndefined(); + expect(index.nearestVisibleLine(12, 'up', 12)).toBe(11); + expect(index.nearestVisibleLine(12, 'down', 12)).toBeUndefined(); + }); + + test('returns undefined when a hidden edge has no visible line beyond it', () => { + const hiddenStart = new LineRangeIndex([{ startLine: 0, endLine: 2 }]); + const hiddenEnd = new LineRangeIndex([{ startLine: 9, endLine: 20 }]); + + expect(hiddenStart.nearestVisibleLine(1, 'up', 12)).toBeUndefined(); + expect(hiddenStart.nearestVisibleLine(1, 'down', 12)).toBe(3); + expect(hiddenEnd.nearestVisibleLine(10, 'up', 12)).toBe(8); + expect(hiddenEnd.nearestVisibleLine(10, 'down', 12)).toBeUndefined(); + }); +}); diff --git a/packages/diffs/test/editorPersistStateLifecycle.test.ts b/packages/diffs/test/editorPersistStateLifecycle.test.ts index ae2a13bae..63adb764c 100644 --- a/packages/diffs/test/editorPersistStateLifecycle.test.ts +++ b/packages/diffs/test/editorPersistStateLifecycle.test.ts @@ -4,7 +4,7 @@ import { File } from '../src/components/File'; import { DEFAULT_THEMES } from '../src/constants'; import { Editor, type IStateStorage } from '../src/editor'; import { disposeHighlighter } from '../src/highlighter/shared_highlighter'; -import type { EditorState, FileContents } from '../src/types'; +import type { EditorState, FileContents, LineRange } from '../src/types'; import { installDom, wait, waitFor } from './domHarness'; afterAll(async () => { @@ -17,6 +17,21 @@ const ORIGINAL_FILE: FileContents = { cacheKey: 'persisted.ts', }; +const FOLDABLE_FILE: FileContents = { + name: 'foldable.ts', + contents: [ + 'function outer() {', + ' const before = 1;', + ' if (before) {', + ' console.log(before);', + ' }', + ' return before;', + '}', + 'const after = true;', + ].join('\n'), + cacheKey: 'foldable.ts', +}; + interface AttachedFile { container: HTMLElement; file: File; @@ -96,7 +111,110 @@ function savedCaret(character: number): EditorState { }; } +function foldToggle( + attached: AttachedFile, + oneIndexedLine: number +): HTMLButtonElement { + const toggle = attached.container.shadowRoot?.querySelector( + `[data-column-number="${oneIndexedLine}"] [data-fold-toggle]` + ); + if (!(toggle instanceof HTMLButtonElement)) { + throw new Error(`no fold toggle found for line ${oneIndexedLine}`); + } + return toggle; +} + +function renderedLineNumbers(attached: AttachedFile): number[] { + return [ + ...(attached.container.shadowRoot?.querySelectorAll( + '[data-content] > [data-line]' + ) ?? []), + ].map((line) => Number(line.dataset.line)); +} + +async function waitForFoldRanges( + editor: Editor, + expected: LineRange[] +): Promise { + await waitFor( + () => + JSON.stringify(editor.getState().foldRanges) === JSON.stringify(expected) + ); + expect(editor.getState().foldRanges).toEqual(expected); +} + describe('Editor persisted state lifecycle', () => { + test('restores nested folds after switching files', async () => { + const dom = installDom(); + const editor = new Editor({ persistState: true }); + let attached: AttachedFile | undefined; + const nestedFold = { startLine: 2, endLine: 3 }; + const outerFold = { startLine: 0, endLine: 5 }; + + try { + attached = await attachFile(editor, { ...FOLDABLE_FILE }); + + foldToggle(attached, 3).click(); + await waitForFoldRanges(editor, [nestedFold]); + foldToggle(attached, 1).click(); + await waitForFoldRanges(editor, [outerFold, nestedFold]); + + await renderFile(editor, attached, { + name: 'next.ts', + contents: 'next\n', + cacheKey: 'next.ts', + }); + await renderFile(editor, attached, { ...FOLDABLE_FILE }); + + await waitForFoldRanges(editor, [outerFold, nestedFold]); + expect(renderedLineNumbers(attached)).toEqual([1, 7, 8]); + + foldToggle(attached, 1).click(); + await waitForFoldRanges(editor, [nestedFold]); + expect(renderedLineNumbers(attached)).toEqual([1, 2, 3, 5, 6, 7, 8]); + } finally { + editor.cleanUp(); + attached?.file.cleanUp(); + dom.cleanup(); + } + }); + + test('a pending async restore cannot overwrite a newer fold toggle', async () => { + const dom = installDom(); + const pendingState = createDeferred(); + const storage: IStateStorage = { + get() { + return pendingState.promise; + }, + set() {}, + }; + const editor = new Editor({ + persistState: true, + persistStateStorage: storage, + }); + let attached: AttachedFile | undefined; + const nestedFold = { startLine: 2, endLine: 3 }; + + try { + attached = await attachFile(editor, { ...FOLDABLE_FILE }); + + foldToggle(attached, 3).click(); + await waitForFoldRanges(editor, [nestedFold]); + + pendingState.resolve({ + foldRanges: [{ startLine: 0, endLine: 5 }], + }); + await wait(0); + + expect(editor.getState().foldRanges).toEqual([nestedFold]); + expect(renderedLineNumbers(attached)).toEqual([1, 2, 3, 5, 6, 7, 8]); + } finally { + editor.cleanUp(); + attached?.file.cleanUp(); + dom.cleanup(); + } + }); + test('an async restore survives an unchanged file rerender', async () => { const dom = installDom(); const pendingState = createDeferred(); diff --git a/packages/diffs/test/editorSelection.test.ts b/packages/diffs/test/editorSelection.test.ts index 9b51f65f4..089fa3e26 100644 --- a/packages/diffs/test/editorSelection.test.ts +++ b/packages/diffs/test/editorSelection.test.ts @@ -375,6 +375,30 @@ describe('convertSelection', () => { ); }); + test('maps an inline fold indicator to the folded header end', () => { + const icon = element('SVG', [element('USE')]); + const ellipsis = element('BUTTON', [icon]); + const indicator = element('SPAN', [ellipsis]); + indicator.dataset.foldIndicator = ''; + indicator.dataset.foldCharacter = '18'; + const renderedLine = pre(6, [span('function outer() {', 0), indicator]); + + expect( + convertSelection(composedRange(renderedLine as unknown as Node, 2)) + ).toEqual({ + start: { line: 6, character: 18 }, + end: { line: 6, character: 18 }, + direction: DirectionNone, + }); + expect( + convertSelection(composedRange(ellipsis as unknown as Node, 0)) + ).toEqual({ + start: { line: 6, character: 18 }, + end: { line: 6, character: 18 }, + direction: DirectionNone, + }); + }); + test('maps a text node inside a nested diff-span token', () => { const diffToken = span('_diff', 15); const diff = diffSpan(diffToken, span(':', 20), span(' FileMetadata', 22));