diff --git a/apps/demo/src/main.ts b/apps/demo/src/main.ts index b2eb2f3fa..ff16ba162 100644 --- a/apps/demo/src/main.ts +++ b/apps/demo/src/main.ts @@ -1064,6 +1064,8 @@ if (renderFileButton != null) { overflow: wrap ? 'wrap' : 'scroll', theme: DEMO_THEME, themeType: getThemeType(), + // Folding starts disabled in the demo; the header toggle enables it. + folding: false, renderAnnotation, ...(RENDER_FILENAME_SUFFIX ? { @@ -1098,6 +1100,22 @@ if (renderFileButton != null) { } } ); + // Folding lives on the shared code options, so this flip reaches + // whichever side currently owns folding: the read-only file or an + // attached editor. + const foldingToggle = createToggle( + 'Folding', + instance?.options.folding === true, + (checked) => { + instance?.setOptions({ + ...instance.options, + folding: checked, + }); + if (!VIRTUALIZE) { + void instance.rerender(); + } + } + ); editShortcutCallback = (): boolean | void => { if (!isEditing) { editableToggle.querySelector('input')?.click(); @@ -1107,7 +1125,7 @@ if (renderFileButton != null) { const div = document.createElement('div'); div.style.display = 'flex'; div.style.gap = '8px'; - div.append(collapsedToggle, editableToggle); + div.append(collapsedToggle, editableToggle, foldingToggle); return div; }, diff --git a/apps/docs/app/(diffs)/docs/Edit/constants.ts b/apps/docs/app/(diffs)/docs/Edit/constants.ts index e71a8fd78..d2744033d 100644 --- a/apps/docs/app/(diffs)/docs/Edit/constants.ts +++ b/apps/docs/app/(diffs)/docs/Edit/constants.ts @@ -1223,14 +1223,16 @@ const file: FileContents | undefined = editor.getFile(); // Full document text, or '' when nothing is attached. const text: string = editor.getText(); -// Snapshot selections and scroll positions for explicit restoration: +// Snapshot selections, active folds, and scroll positions for +// persistence or remount restore. const state: EditorState = editor.getState(); // EditorState = { // selections?: EditorSelection[]; +// foldRanges?: LineRange[]; // zero-based; standalone closers stay visible // view?: { scrollLeft: number; scrollTop?: number }; // } -// Restore selections and scroll positions after re-rendering. +// Restore selections, folds, and scroll positions after re-rendering. editor.setState(state); // Replace all cursors and ranges programmatically. Positions are zero-based; diff --git a/apps/docs/app/(diffs)/playground/PlaygroundClient.tsx b/apps/docs/app/(diffs)/playground/PlaygroundClient.tsx index b1aaca774..1608d25f4 100644 --- a/apps/docs/app/(diffs)/playground/PlaygroundClient.tsx +++ b/apps/docs/app/(diffs)/playground/PlaygroundClient.tsx @@ -23,6 +23,7 @@ import { IconCodeStyleBars, IconCodeStyleBg, IconCodeStyleInline, + IconCollapsedRow, IconColorAuto, IconColorDark, IconColorLight, @@ -131,6 +132,7 @@ export type SharedRenderOptions = Pick< | 'disableBackground' | 'disableLineNumbers' | 'overflow' + | 'folding' | 'themeType' | 'theme' > & { @@ -169,6 +171,8 @@ interface PlaygroundControlsContentProps { setDisableLineNumbers: (v: boolean) => void; overflow: 'wrap' | 'scroll'; setOverflow: (v: 'wrap' | 'scroll') => void; + folding: boolean; + setFolding: (v: boolean) => void; enableLineSelection: boolean; setEnableLineSelection: (v: boolean) => void; enableGutterUtility: boolean; @@ -213,6 +217,8 @@ function PlaygroundControlsContent({ setDisableLineNumbers, overflow, setOverflow, + folding, + setFolding, enableLineSelection, setEnableLineSelection, enableGutterUtility, @@ -493,6 +499,18 @@ function PlaygroundControlsContent({ } /> + {/* Folding only applies to file surfaces (the Virtualizer README and + CodeView file items); diffs don't fold, so hide it in Normal. */} + {viewMode !== 'normal' && ( + } + label="Folding" + checked={folding} + onCheckedChange={setFolding} + title="Code folding on file surfaces (diffs don't fold)" + /> + )} + } label="Annotations" @@ -686,6 +704,7 @@ export function PlaygroundClient({ prerenderedDiff }: PlaygroundClientProps) { urlState.disableLineNumbers ); const [overflow, setOverflow] = useState(urlState.overflow); + const [folding, setFolding] = useState(urlState.folding); const [enableLineSelection, setEnableLineSelection] = useState( urlState.enableLineSelection ); @@ -795,6 +814,7 @@ export function PlaygroundClient({ prerenderedDiff }: PlaygroundClientProps) { params.set('ln', disableLineNumbers ? '0' : '1'); if ((overflow === 'wrap') !== DEFAULTS.wrap) params.set('wrap', overflow === 'wrap' ? '1' : '0'); + if (folding !== DEFAULTS.folding) params.set('fold', folding ? '1' : '0'); if (interactionMode !== DEFAULTS.interactionMode) params.set('lineMode', interactionMode); if (enableLineSelection !== DEFAULTS.lineSelection) @@ -833,6 +853,7 @@ export function PlaygroundClient({ prerenderedDiff }: PlaygroundClientProps) { disableBackground, disableLineNumbers, overflow, + folding, interactionMode, enableLineSelection, enableGutterUtility, @@ -988,6 +1009,8 @@ export function PlaygroundClient({ prerenderedDiff }: PlaygroundClientProps) { setDisableLineNumbers, overflow, setOverflow, + folding, + setFolding, enableLineSelection, setEnableLineSelection, enableGutterUtility, @@ -1026,6 +1049,7 @@ export function PlaygroundClient({ prerenderedDiff }: PlaygroundClientProps) { disableBackground, disableLineNumbers, overflow, + folding, themeType: effectiveColorMode, theme: { dark: selectedDarkTheme, light: selectedLightTheme }, }), @@ -1038,6 +1062,7 @@ export function PlaygroundClient({ prerenderedDiff }: PlaygroundClientProps) { disableBackground, disableLineNumbers, overflow, + folding, effectiveColorMode, selectedDarkTheme, selectedLightTheme, diff --git a/apps/docs/app/(diffs)/playground/PlaygroundVirtualizerElementView.tsx b/apps/docs/app/(diffs)/playground/PlaygroundVirtualizerElementView.tsx index e9ee1e28e..97690691e 100644 --- a/apps/docs/app/(diffs)/playground/PlaygroundVirtualizerElementView.tsx +++ b/apps/docs/app/(diffs)/playground/PlaygroundVirtualizerElementView.tsx @@ -21,7 +21,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { flushSync } from 'react-dom'; import type { PlaygroundAnnotationMetadata } from './constants'; -import { ITEM_UNSAFE_CSS, LONG_README_FILE } from './constants'; +import { ITEM_UNSAFE_CSS, LONG_CODE_FILE } from './constants'; import type { SharedRenderOptions } from './PlaygroundClient'; import { CommentForm, CommentThread } from './PlaygroundComments'; @@ -40,7 +40,7 @@ interface PlaygroundVirtualizerElementViewProps { // fixed-height scroll region, in contrast to the window-scroll variant that // drives the vanilla Virtualizer against `document`. Any React // nested under auto-virtualizes through context; no imperative -// wiring is needed. The long README plain file leads the list (as in +// wiring is needed. The long foldable plain file leads the list (as in // CodeView), rendered through , which virtualizes the same way. export function PlaygroundVirtualizerElementView({ diffs, @@ -75,7 +75,7 @@ const FILE_EDITOR_OPTIONS: EditorOptions = { }, }; -// The long README plain-file surface leading the list. Carries the same +// The long foldable plain-file surface leading the list. Carries the same // header Edit toggle as the diffs (the app-level EditProvider creates its // editor); no comment wiring, since the demo file has no annotations. function ElementVirtualizerFile({ options }: { options: SharedRenderOptions }) { @@ -113,7 +113,7 @@ function ElementVirtualizerFile({ options }: { options: SharedRenderOptions }) { return ( [] = [ + { + id: `file:${LONG_FIXTURE_NAME}`, + type: 'file', + file: LONG_CODE_FILE, + }, { id: 'file:README.md', type: 'file', diff --git a/apps/docs/app/(diffs)/playground/searchParams.ts b/apps/docs/app/(diffs)/playground/searchParams.ts index cb010d594..a3bd5f3f7 100644 --- a/apps/docs/app/(diffs)/playground/searchParams.ts +++ b/apps/docs/app/(diffs)/playground/searchParams.ts @@ -78,6 +78,7 @@ export const DEFAULTS = { background: true, lineNumbers: true, wrap: true, + folding: true, lineSelection: true, gutterButton: true, interactionMode: 'comment' as const, @@ -99,6 +100,8 @@ export interface PlaygroundUrlState { disableBackground: boolean; disableLineNumbers: boolean; overflow: 'wrap' | 'scroll'; + // Code folding on file surfaces (diffs don't fold). + folding: boolean; enableLineSelection: boolean; enableGutterUtility: boolean; showAnnotations: boolean; @@ -176,6 +179,7 @@ export function parsePlaygroundSearchParams( disableBackground: !pickBool(get('bg'), DEFAULTS.background), disableLineNumbers: !pickBool(get('ln'), DEFAULTS.lineNumbers), overflow: pickBool(get('wrap'), DEFAULTS.wrap) ? 'wrap' : 'scroll', + folding: pickBool(get('fold'), DEFAULTS.folding), enableLineSelection, enableGutterUtility, showAnnotations: pickBool(get('annot'), DEFAULTS.annotations), diff --git a/packages/diffs/src/components/CodeView.ts b/packages/diffs/src/components/CodeView.ts index db35c8083..749a92b6c 100644 --- a/packages/diffs/src/components/CodeView.ts +++ b/packages/diffs/src/components/CodeView.ts @@ -272,6 +272,7 @@ export const CODE_VIEW_DIFF_OPTION_KEYS = [ 'themeType', 'disableFileHeader', 'disableVirtualizationBuffers', + 'folding', 'preferredHighlighter', 'useCSSClasses', 'useTokenTransformer', @@ -305,6 +306,7 @@ export const CODE_VIEW_FILE_OPTION_KEYS = [ 'themeType', 'disableFileHeader', 'disableVirtualizationBuffers', + 'folding', 'preferredHighlighter', 'useCSSClasses', 'useTokenTransformer', @@ -4118,6 +4120,8 @@ function hasItemLayoutOptionChanged( (previousOptions.disableFileHeader ?? false) !== (nextOptions.disableFileHeader ?? false) || previousOptions.unsafeCSS !== nextOptions.unsafeCSS || + // Disabling folding unfolds items, changing their heights. + (previousOptions.folding ?? true) !== (nextOptions.folding ?? true) || (previousOptions.diffStyle ?? 'split') !== (nextOptions.diffStyle ?? 'split') || (previousOptions.diffIndicators ?? 'bars') !== diff --git a/packages/diffs/src/components/File.ts b/packages/diffs/src/components/File.ts index 99fccac34..723088236 100644 --- a/packages/diffs/src/components/File.ts +++ b/packages/diffs/src/components/File.ts @@ -13,6 +13,7 @@ import { THEME_CSS_ATTRIBUTE, UNSAFE_CSS_ATTRIBUTE, } from '../constants'; +import { FoldManager } from '../managers/FoldManager'; import { type GetHoveredLineResult, InteractionManager, @@ -33,6 +34,7 @@ import type { FileContents, HighlightedToken, LineAnnotation, + LineRange, PostRenderPhase, PrePropertiesConfig, RenderFileMetadata, @@ -178,6 +180,11 @@ export class File< public file: FileContents | undefined; protected renderRange: RenderRange | undefined; protected enabled = true; + protected foldRanges: LineRange[] = []; + // Interactive read-only fold state; shared with the renderer, which + // decorates fold headers from it. An attached editor bypasses it and pushes + // its own hidden ranges through __setFoldRanges. + protected foldManager: FoldManager; protected editor: DiffsEditor | undefined; @@ -191,6 +198,12 @@ export class File< this.handleHighlightRender, this.workerManager ); + this.foldManager = new FoldManager({ + isEnabled: () => this.isReadOnlyFoldingEnabled(), + onToggleFold: (startLine, restoreFocus) => + this.toggleFold(startLine, restoreFocus), + }); + this.fileRenderer.setFoldManager(this.foldManager); this.resizeManager = new ResizeManager(); this.interactionManager = new InteractionManager( 'file', @@ -216,6 +229,83 @@ export class File< return this.file; } + public __setFoldRanges(ranges: LineRange[]): void { + this.applyFoldRanges(ranges); + } + + // Hide the given line ranges and re-render when they changed. The + // virtualized subclass overrides this to also invalidate row layout. + protected applyFoldRanges(ranges: LineRange[]): boolean { + if (!this.updateFoldRanges(ranges)) { + return false; + } + if (this.enabled && this.file != null) { + this.rerender(); + } + return true; + } + + // Whether this component drives its own folding: the folding option is on + // and no editor session owns the fold state. + protected isReadOnlyFoldingEnabled(): boolean { + return this.options.folding !== false && this.editor == null; + } + + // Fold or unfold the block starting at the zero-based header line, hiding + // or revealing its body. Invoked by the FoldManager for fold-control clicks. + protected toggleFold(startLine: number, restoreFocus = false): void { + const { file } = this; + if (!this.isReadOnlyFoldingEnabled() || file == null) { + return; + } + const lines = this.fileRenderer.getOrCreateLineCache(file); + if (!this.foldManager.toggleFold(startLine, lines)) { + return; + } + if (!this.applyFoldRanges(this.foldManager.getHiddenLineRanges(lines))) { + // The hidden ranges can survive a toggle (e.g. a fold nested inside a + // still-folded block); re-render for the control state alone. + this.rerender(); + } + if (restoreFocus) { + this.pre + ?.querySelector( + `[data-column-number][data-line-index="${startLine}"] [data-fold-toggle]` + ) + ?.focus(); + } + } + + /** + * Unfold everything and drop the interactive fold state, re-rendering when + * anything was folded. Used when an editor attaches (the session owns + * folding) and when the folding option turns off. + */ + protected resetReadOnlyFolding(): void { + const hadFolds = this.foldManager.reset(); + if (!this.applyFoldRanges([]) && hadFolds) { + this.rerender(); + } + } + + protected updateFoldRanges(ranges: LineRange[]): boolean { + if ( + ranges.length === this.foldRanges.length && + ranges.every((range, index) => { + const previous = this.foldRanges[index]; + return ( + previous?.startLine === range.startLine && + previous.endLine === range.endLine + ); + }) + ) { + return false; + } + this.foldRanges = ranges.map((range) => ({ ...range })); + this.fileRenderer.setFoldRanges(this.foldRanges); + return true; + } + public onThemeChange(): void { this.fileRenderer.clearRenderCache(); this.rerender(); @@ -223,9 +313,16 @@ export class File< public setOptions(options: FileOptions | undefined): void { if (options == null) return; + const foldingDisabled = + options.folding === false && this.options.folding !== false; this.options = options; this.cachedHeaderHTML = undefined; this.syncInteractionOptions(); + if (foldingDisabled && this.editor == null) { + this.resetReadOnlyFolding(); + } + // The editor reads shared code options (e.g. folding) from this host. + this.editor?.__hostOptionsChanged?.(); } protected syncInteractionOptions(): void { @@ -324,6 +421,7 @@ export class File< const { overflow = 'scroll' } = this.options; this.interactionManager.setup(this.pre); + this.foldManager.setup(this.pre); this.resizeManager.setup(this.pre, { disableAnnotations: overflow === 'wrap', columnVariables: this.shouldApplyColumnVariables(overflow) @@ -344,6 +442,11 @@ export class File< this.editor = undefined; this.resizeManager.cleanUp(); this.interactionManager.cleanUp(); + this.foldManager.cleanUp(); + // A recycle keeps the fold state so a virtualized remount restores it. + if (!recycle) { + this.foldManager.reset(); + } this.managersDirty = false; this.workerManager?.unsubscribeToThemeChanges(this); this.renderRange = undefined; @@ -393,6 +496,8 @@ export class File< this.workerManager = undefined; this.file = undefined; } + this.foldRanges = []; + this.enabled = false; } @@ -553,6 +658,10 @@ export class File< public attachEditor(editor: DiffsEditor): () => void { this.editor?.cleanUp(); + // The attaching editor owns folding for the session; unfold the + // read-only state so the session starts from (and detaches back to) a + // clean view. + this.resetReadOnlyFolding(); this.editor = editor; this.fileRenderer.beginEditSession(); const preparedFile = @@ -664,6 +773,11 @@ export class File< this.renderRange = nextRenderRange; if (didFileChange) { this.cachedHeaderHTML = undefined; + // Folded blocks don't carry over to different contents. State-only + // reset: the render below already reflects it. + if (this.foldManager.reset()) { + this.updateFoldRanges([]); + } } this.file = file; this.fileRenderer.setOptions(getFileRendererOptions(this.options)); @@ -672,6 +786,16 @@ export class File< this.setLineAnnotations(lineAnnotations); } this.fileRenderer.setLineAnnotations(this.lineAnnotations); + // Re-derive hidden ranges from the interactive fold state so every render + // hides exactly the folded bodies, including after a recycle dropped the + // ranges while the fold state survived. + if (this.isReadOnlyFoldingEnabled() && this.foldManager.hasFolds()) { + this.updateFoldRanges( + this.foldManager.getHiddenLineRanges( + this.fileRenderer.getOrCreateLineCache(file) + ) + ); + } const { disableErrorHandling = false, disableFileHeader = false } = this.options; @@ -1113,6 +1237,9 @@ export class File< this.cleanupErrorWrapper(); this.applyPreNodeAttributes(pre, result); const code = (this.code = getOrCreateCodeNode({ code: this.code })); + // Reserves gutter space for fold toggles (see style.css). An attached + // editor re-applies the attribute when it renders its own controls. + code.toggleAttribute('data-folding', this.fileRenderer.showsFoldControls()); const codeAst = this.fileRenderer.renderCodeAST(result); this.editor?.__captureFocusForDOMReplacement(); const applyColumns = () => { diff --git a/packages/diffs/src/components/VirtualizedFile.ts b/packages/diffs/src/components/VirtualizedFile.ts index 864c05bcb..300f3cb11 100644 --- a/packages/diffs/src/components/VirtualizedFile.ts +++ b/packages/diffs/src/components/VirtualizedFile.ts @@ -1,8 +1,10 @@ import { DEFAULT_VIRTUAL_FILE_METRICS } from '../constants'; +import { LineRangeIndex } from '../managers/FoldManager'; import type { DiffsTextDocument, FileContents, LineAnnotation, + LineRange, NumericScrollLineAnchor, PendingCodeViewLayoutReset, RenderRange, @@ -84,6 +86,7 @@ export class VirtualizedFile< private layoutDirty = true; private forceRenderOverride: true | undefined; private currentCollapsed: boolean | undefined; + private editorFoldedLineIndex = new LineRangeIndex(); constructor( options: FileOptions | undefined, @@ -140,6 +143,18 @@ export class VirtualizedFile< // If not cached and hasMetadataLine is true, adds lineHeight for the // metadata. public getLineHeight(lineIndex: number, hasMetadataLine = false): number { + if (this.editorFoldedLineIndex.isHidden(lineIndex)) { + return 0; + } + return this.getVisibleLineHeight(lineIndex, hasMetadataLine); + } + + // Read a row height after the caller has already established that the line + // is visible, avoiding another folded-range lookup inside layout scans. + private getVisibleLineHeight( + lineIndex: number, + hasMetadataLine = false + ): number { const cached = this.cache.heights.get(lineIndex); if (cached != null) { return cached; @@ -185,6 +200,37 @@ export class VirtualizedFile< super.setThemeType(themeType); } + protected override updateFoldRanges(ranges: LineRange[]): boolean { + if (!super.updateFoldRanges(ranges)) { + return false; + } + this.editorFoldedLineIndex = new LineRangeIndex(this.foldRanges); + return true; + } + + protected override applyFoldRanges(ranges: LineRange[]): boolean { + if (!this.updateFoldRanges(ranges)) { + return false; + } + this.forceRenderOverride = true; + this.invalidateEditorFoldingLayout(); + if (this.enabled && this.file != null) { + this.virtualizer.instanceChanged(this, true); + } + return true; + } + + // Folding only changes which rows participate in layout. Preserve measured + // wrap/annotation heights and rebuild the derived total and checkpoints. + private invalidateEditorFoldingLayout(): void { + this.layoutDirty = true; + this.cache.checkpoints.length = 0; + this.renderRange = undefined; + if (this.isSimpleMode()) { + this.computeApproximateSize(); + } + } + private resetLayoutCache(recompute = false, resetRenderRange = true): void { this.layoutDirty = true; this.cache.fileAnnotationHeight = 0; @@ -340,6 +386,16 @@ export class VirtualizedFile< shouldResetLayoutCache = true; } + // CodeView flips options globally without calling setOptions on items, so + // catch a disabled folding option here and unfold before layout runs. + if (this.options.folding === false && this.foldManager.hasFolds()) { + this.foldManager.reset(); + if (this.updateFoldRanges([])) { + this.forceRenderOverride = true; + shouldResetLayoutCache = true; + } + } + if (shouldResetLayoutCache) { this.resetLayoutCache(); } @@ -377,6 +433,16 @@ export class VirtualizedFile< top += this.cache.fileAnnotationHeight; if (overflow === 'scroll' && !this.hasLineAnnotations()) { + if (this.foldRanges.length > 0) { + const hiddenBefore = + this.editorFoldedLineIndex.hiddenCountBefore(clampedLineIndex); + return { + top: top + (clampedLineIndex - hiddenBefore) * lineHeight, + height: this.editorFoldedLineIndex.isHidden(clampedLineIndex) + ? 0 + : lineHeight, + }; + } return { top: top + clampedLineIndex * lineHeight, height: lineHeight, @@ -386,12 +452,22 @@ export class VirtualizedFile< const checkpoint = this.getLayoutCheckpointBeforeLineIndex(clampedLineIndex); top = checkpoint?.top ?? top; - for ( - let lineIndex = checkpoint?.lineIndex ?? 0; - lineIndex < clampedLineIndex; - lineIndex++ - ) { - top += this.getLineHeight(lineIndex, false); + let lineIndex = checkpoint?.lineIndex ?? 0; + const lineAfterStartingFold = + this.editorFoldedLineIndex.lineAfterHiddenRange(lineIndex); + if (lineAfterStartingFold != null) { + lineIndex = Math.min(lineAfterStartingFold, clampedLineIndex); + } + let foldedRangeIndex = this.getFoldRangeIndexAtOrAfter(lineIndex); + while (lineIndex < clampedLineIndex) { + const foldedRange = this.foldRanges[foldedRangeIndex]; + if (foldedRange != null && lineIndex >= foldedRange.startLine) { + lineIndex = Math.min(foldedRange.endLine + 1, clampedLineIndex); + foldedRangeIndex++; + continue; + } + top += this.getVisibleLineHeight(lineIndex); + lineIndex++; } return { @@ -448,6 +524,50 @@ export class VirtualizedFile< // multiply our way to the the correct value if (overflow === 'scroll' && !this.hasLineAnnotations()) { const { lineHeight } = this.metrics; + if (this.foldRanges.length > 0) { + const firstVisibleLineIndex = + this.editorFoldedLineIndex.nearestVisibleLine( + firstRenderedLineIndex, + 'down', + lastLineIndex + 1 + ); + const lastVisibleLineIndex = + this.editorFoldedLineIndex.nearestVisibleLine( + lastRenderedLineIndex, + 'up', + lastLineIndex + 1 + ); + if ( + firstVisibleLineIndex == null || + lastVisibleLineIndex == null || + firstVisibleLineIndex > lastVisibleLineIndex + ) { + return undefined; + } + + const firstVisibleIndex = + firstVisibleLineIndex - + this.editorFoldedLineIndex.hiddenCountBefore(firstVisibleLineIndex); + const firstRenderedLineTop = + headerRegion + fileAnnotationHeight + firstVisibleIndex * lineHeight; + const deltaLineCount = Math.max( + Math.ceil((localViewportTop - firstRenderedLineTop) / lineHeight), + 0 + ); + const visibleIndex = firstVisibleIndex + deltaLineCount; + const lineIndex = this.editorFoldedLineIndex.lineAtVisibleIndex( + visibleIndex, + lastLineIndex + 1 + ); + if (lineIndex == null || lineIndex > lastVisibleLineIndex) { + return undefined; + } + + return { + lineNumber: lineIndex + 1, + top: headerRegion + fileAnnotationHeight + visibleIndex * lineHeight, + }; + } const firstRenderedLineTop = headerRegion + (firstRenderedLineIndex === 0 @@ -474,18 +594,28 @@ export class VirtualizedFile< (firstRenderedLineIndex === 0 ? fileAnnotationHeight : this.renderRange.bufferBefore); - for ( - let lineIndex = firstRenderedLineIndex; - lineIndex <= lastRenderedLineIndex; - lineIndex++ - ) { + let lineIndex = firstRenderedLineIndex; + const lineAfterStartingFold = + this.editorFoldedLineIndex.lineAfterHiddenRange(lineIndex); + if (lineAfterStartingFold != null) { + lineIndex = lineAfterStartingFold; + } + let foldedRangeIndex = this.getFoldRangeIndexAtOrAfter(lineIndex); + while (lineIndex <= lastRenderedLineIndex) { + const foldedRange = this.foldRanges[foldedRangeIndex]; + if (foldedRange != null && lineIndex >= foldedRange.startLine) { + lineIndex = foldedRange.endLine + 1; + foldedRangeIndex++; + continue; + } if (top >= localViewportTop) { return { lineNumber: lineIndex + 1, top, }; } - top += this.getLineHeight(lineIndex); + top += this.getVisibleLineHeight(lineIndex); + lineIndex++; } return undefined; @@ -538,6 +668,7 @@ export class VirtualizedFile< } override cleanUp(recycle = false): void { + const recycledFoldedRanges = recycle ? this.foldRanges : []; if (this.fileContainer != null && this.isSimpleMode()) { this.getSimpleVirtualizer()?.disconnect(this.fileContainer); } @@ -546,6 +677,12 @@ export class VirtualizedFile< } this.isSetup = false; super.cleanUp(recycle); + if (recycle && recycledFoldedRanges.length > 0) { + this.foldRanges = recycledFoldedRanges; + this.fileRenderer.setFoldRanges(recycledFoldedRanges); + } else { + this.editorFoldedLineIndex = new LineRangeIndex(); + } } // Compute the approximate size of the file using cached line heights. @@ -592,11 +729,52 @@ export class VirtualizedFile< this.height += this.cache.fileAnnotationHeight; if (overflow === 'scroll' && !this.hasLineAnnotations()) { - this.height += lineCount * lineHeight; + this.height += + this.editorFoldedLineIndex.visibleLineCount(lineCount) * lineHeight; } else { - for (let lineIndex = 0; lineIndex < lineCount; lineIndex++) { - this.addLayoutCheckpoint(lineIndex, this.height); - this.height += this.getLineHeight(lineIndex, false); + const codeRegionTop = this.height; + const measuredHeights = [...this.cache.heights] + .filter( + ([lineIndex]) => + lineIndex >= 0 && + lineIndex < lineCount && + !this.editorFoldedLineIndex.isHidden(lineIndex) + ) + .sort(([leftLineIndex], [rightLineIndex]) => { + return leftLineIndex - rightLineIndex; + }); + let measuredHeightDelta = 0; + for (const [, measuredHeight] of measuredHeights) { + measuredHeightDelta += measuredHeight - lineHeight; + } + + this.height += + this.editorFoldedLineIndex.visibleLineCount(lineCount) * lineHeight + + measuredHeightDelta; + + let measuredHeightIndex = 0; + let measuredHeightDeltaBefore = 0; + for ( + let lineIndex = 0; + lineIndex < lineCount; + lineIndex += LAYOUT_CHECKPOINT_INTERVAL + ) { + let measuredHeight = measuredHeights[measuredHeightIndex]; + while (measuredHeight != null && measuredHeight[0] < lineIndex) { + measuredHeightDeltaBefore += measuredHeight[1] - lineHeight; + measuredHeightIndex++; + measuredHeight = measuredHeights[measuredHeightIndex]; + } + + const visibleLinesBefore = + lineIndex - this.editorFoldedLineIndex.hiddenCountBefore(lineIndex); + this.cache.checkpoints.push({ + lineIndex, + top: + codeRegionTop + + visibleLinesBefore * lineHeight + + measuredHeightDeltaBefore, + }); } } @@ -806,11 +984,24 @@ export class VirtualizedFile< return this.virtualizer.type === 'advanced'; } - private addLayoutCheckpoint(lineIndex: number, top: number): void { - if (lineIndex % LAYOUT_CHECKPOINT_INTERVAL !== 0) { - return; + // Locate the first ordered folded range that can contain or follow a raw + // line. Scans then advance through ranges once and jump collapsed bodies. + private getFoldRangeIndexAtOrAfter(lineIndex: number): number { + let low = 0; + let high = this.foldRanges.length; + while (low < high) { + const middle = low + ((high - low) >> 1); + const range = this.foldRanges[middle]; + if (range == null) { + throw new Error('VirtualizedFile: invalid folded range index'); + } + if (range.endLine < lineIndex) { + low = middle + 1; + } else { + high = middle; + } } - this.cache.checkpoints.push({ lineIndex, top }); + return low; } // Find the nearest sparse layout checkpoint at or before a raw file line. @@ -849,7 +1040,8 @@ export class VirtualizedFile< // Render-range scans start from this checkpoint so variable-height files // only replay the nearby measured rows. When `hunkLineCount` is provided, // step backward to a hunk boundary so hooks that depend on grouped lines - // still see a complete hunk. + // still see a complete hunk. Folded files use visible-row hunk indexes, so + // they resume at the nearest raw checkpoint and derive the missing offset. private getLayoutCheckpointBeforeTop( top: number, hunkLineCount?: number @@ -872,7 +1064,7 @@ export class VirtualizedFile< } } - if (hunkLineCount == null) { + if (hunkLineCount == null || this.foldRanges.length > 0) { return resultIndex >= 0 ? this.cache.checkpoints[resultIndex] : undefined; } @@ -918,6 +1110,10 @@ export class VirtualizedFile< const { disableFileHeader = false, overflow = 'scroll' } = this.options; const { hunkLineCount, lineHeight } = this.metrics; const lineCount = this.fileRenderer.getLineCount(file); + const hasEditorFolds = this.foldRanges.length > 0; + const visibleLineCount = hasEditorFolds + ? this.editorFoldedLineIndex.visibleLineCount(lineCount) + : lineCount; const fileHeight = this.height; const headerRegion = getVirtualFileHeaderRegion( this.metrics, @@ -994,21 +1190,61 @@ export class VirtualizedFile< // Calculate ideal start centered around viewport const idealStartHunk = centerHunk - Math.floor(totalHunks / 2); - const totalHunksInFile = Math.ceil(lineCount / hunkLineCount); - const startingLine = + const totalHunksInFile = Math.ceil(visibleLineCount / hunkLineCount); + const startingVisibleLine = Math.max(0, Math.min(idealStartHunk, totalHunksInFile)) * hunkLineCount; - const clampedTotalLines = + const clampedVisibleLines = idealStartHunk < 0 ? totalLines + idealStartHunk * hunkLineCount : totalLines; + if (hasEditorFolds) { + const renderedVisibleLines = Math.max( + 0, + Math.min(clampedVisibleLines, visibleLineCount - startingVisibleLine) + ); + const startingLine = this.editorFoldedLineIndex.lineAtVisibleIndex( + startingVisibleLine, + lineCount + ); + const finalLine = + renderedVisibleLines > 0 + ? this.editorFoldedLineIndex.lineAtVisibleIndex( + startingVisibleLine + renderedVisibleLines - 1, + lineCount + ) + : undefined; + if (startingLine == null || finalLine == null) { + return { + startingLine: 0, + totalLines: 0, + bufferBefore: 0, + bufferAfter: codeRowsHeight, + }; + } + return { + startingLine, + totalLines: finalLine - startingLine + 1, + bufferBefore: + startingVisibleLine === 0 + ? 0 + : fileAnnotationHeight + startingVisibleLine * lineHeight, + bufferAfter: Math.max( + 0, + (visibleLineCount - startingVisibleLine - renderedVisibleLines) * + lineHeight + ), + }; + } + + const startingLine = startingVisibleLine; const bufferBefore = startingLine === 0 ? 0 : fileAnnotationHeight + startingLine * lineHeight; const renderedLines = Math.min( - clampedTotalLines, + clampedVisibleLines, lineCount - startingLine ); const bufferAfter = Math.max( @@ -1018,7 +1254,7 @@ export class VirtualizedFile< return { startingLine, - totalLines: clampedTotalLines, + totalLines: clampedVisibleLines, bufferBefore, bufferAfter, }; @@ -1036,17 +1272,28 @@ export class VirtualizedFile< ); let absoluteLineTop = fileTop + (checkpoint?.top ?? codeRegionTop); - let currentLine = checkpoint?.lineIndex ?? 0; + let currentLine = hasEditorFolds + ? (checkpoint?.lineIndex ?? 0) - + this.editorFoldedLineIndex.hiddenCountBefore(checkpoint?.lineIndex ?? 0) + : (checkpoint?.lineIndex ?? 0); let firstVisibleHunk: number | undefined; let centerHunk: number | undefined; let overflowCounter: number | undefined; - const startingLineIndex = checkpoint?.lineIndex ?? 0; - for ( - let lineIndex = startingLineIndex; - lineIndex < lineCount; - lineIndex++ - ) { + let lineIndex = checkpoint?.lineIndex ?? 0; + const lineAfterStartingFold = + this.editorFoldedLineIndex.lineAfterHiddenRange(lineIndex); + if (lineAfterStartingFold != null) { + lineIndex = lineAfterStartingFold; + } + let foldedRangeIndex = this.getFoldRangeIndexAtOrAfter(lineIndex); + while (lineIndex < lineCount) { + const foldedRange = this.foldRanges[foldedRangeIndex]; + if (foldedRange != null && lineIndex >= foldedRange.startLine) { + lineIndex = foldedRange.endLine + 1; + foldedRangeIndex++; + continue; + } const isAtHunkBoundary = currentLine % hunkLineCount === 0; const currentHunk = Math.floor(currentLine / hunkLineCount); @@ -1061,15 +1308,18 @@ export class VirtualizedFile< } } - const lineHeight = this.getLineHeight(lineIndex, false); + const visibleLineHeight = this.getVisibleLineHeight(lineIndex); // Track visible region - if (absoluteLineTop > top - lineHeight && absoluteLineTop < bottom) { + if ( + absoluteLineTop > top - visibleLineHeight && + absoluteLineTop < bottom + ) { firstVisibleHunk ??= currentHunk; } // Track which hunk contains the viewport center - if (absoluteLineTop + lineHeight > viewportCenter) { + if (absoluteLineTop + visibleLineHeight > viewportCenter) { centerHunk ??= currentHunk; } @@ -1083,7 +1333,8 @@ export class VirtualizedFile< } currentLine++; - absoluteLineTop += lineHeight; + absoluteLineTop += visibleLineHeight; + lineIndex++; } // No visible lines found @@ -1109,19 +1360,48 @@ export class VirtualizedFile< // startHunk back const maxStartHunk = Math.max( 0, - Math.ceil(lineCount / hunkLineCount) - totalHunks + Math.ceil(visibleLineCount / hunkLineCount) - totalHunks ); const startHunk = Math.max(0, Math.min(idealStartHunk, maxStartHunk)); - const startingLine = startHunk * hunkLineCount; + const startingVisibleLine = startHunk * hunkLineCount; + const startingLine = hasEditorFolds + ? (this.editorFoldedLineIndex.lineAtVisibleIndex( + startingVisibleLine, + lineCount + ) ?? lineCount) + : startingVisibleLine; // If we wanted to start before 0, reduce totalLines by the clamped amount const clampedTotalLines = idealStartHunk < 0 ? totalLines + idealStartHunk * hunkLineCount : totalLines; + let rawTotalLines = clampedTotalLines; + if (hasEditorFolds) { + const renderedVisibleLines = Math.max( + 0, + Math.min(clampedTotalLines, visibleLineCount - startingVisibleLine) + ); + const finalLine = + renderedVisibleLines > 0 + ? this.editorFoldedLineIndex.lineAtVisibleIndex( + startingVisibleLine + renderedVisibleLines - 1, + lineCount + ) + : undefined; + rawTotalLines = finalLine == null ? 0 : finalLine - startingLine + 1; + } // Use hunkOffsets array for efficient buffer calculations - const codeBufferBefore = hunkOffsets[startHunk] ?? 0; + const codeBufferBefore = + hunkOffsets[startHunk] ?? + (hasEditorFolds && startingLine < lineCount + ? Math.max( + 0, + (this.getLinePosition(startingLine + 1)?.top ?? codeRegionTop) - + codeRegionTop + ) + : 0); const bufferBefore = startingLine === 0 ? 0 : fileAnnotationHeight + codeBufferBefore; @@ -1134,7 +1414,7 @@ export class VirtualizedFile< return { startingLine, - totalLines: clampedTotalLines, + totalLines: rawTotalLines, bufferBefore, bufferAfter: Math.max(0, bufferAfter), }; diff --git a/packages/diffs/src/editor/editor.ts b/packages/diffs/src/editor/editor.ts index c42dfe899..2645001e7 100644 --- a/packages/diffs/src/editor/editor.ts +++ b/packages/diffs/src/editor/editor.ts @@ -1,3 +1,9 @@ +import { + computeIndentFoldingRanges, + isFoldingClosingDelimiter, + LineRangeIndex, + mergeHiddenLineRanges, +} from '../managers/FoldManager'; import { dequeueRender, queueRender, @@ -15,6 +21,7 @@ import type { FileDiffMetadata, HighlightedToken, LineAnnotation, + LineRange, Position, Range, RenderRange, @@ -22,6 +29,11 @@ import type { SelectionSide, TextEdit, } from '../types'; +import { + FOLD_ELLIPSIS_ICON_SIZE, + FOLD_TOGGLE_ICON_SIZE, + getFoldIconSvg, +} from '../utils/foldControls'; import { getFiletypeFromFileName } from '../utils/getFiletypeFromFileName'; import { isGutterUtilityPath } from '../utils/isGutterUtilityPath'; import { @@ -291,6 +303,7 @@ export class Editor implements DiffsEditor { cacheKey: string; textDocument: TextDocument; documentVersion: number; + foldStateVersion: number; selections: EditorSelection[] | undefined; view: EditorState['view']; completion: Promise; @@ -331,6 +344,7 @@ export class Editor implements DiffsEditor { #themeStyleElement?: HTMLStyleElement; #spriteElement?: SVGSVGElement; #fileContainer?: HTMLElement; + #codeElement?: HTMLElement; #gutterElement?: HTMLElement; #contentElement?: HTMLElement; #overlayElement?: HTMLElement; @@ -396,6 +410,27 @@ export class Editor implements DiffsEditor { #retainSearchPanelFocus = false; #fontRemeasureScheduled = false; #themeSelectionRefreshFrame?: number; + #foldRanges: LineRange[] = []; + #foldRangesByStart = new Map(); + #foldEndLines = new Set(); + #foldClosingDelimiterLines = new Set(); + #foldedStartLines = new Set(); + #hiddenLineRanges: LineRange[] = []; + #hiddenLineIndex = new LineRangeIndex(); + #hiddenLineRangeVersion = 0; + #foldStateVersion = 0; + #syncedFoldRangeHost?: DiffsEditableComponent; + #syncedFoldRangeVersion = -1; + #syncedFoldingEnabled?: boolean; + // Cached `folding` option from the attached host component; refreshed on + // attach, on every render-view sync, and via __hostOptionsChanged. + #hostFoldingEnabled = false; + #foldRangeDocument?: TextDocument; + #foldRangeVersion = -1; + #renderViewGeneration = 0; + #pendingFoldFocus?: { line: number; generation: number }; + #hasFoldIndicators = false; + #initializedFoldButtons = new WeakSet(); #onDeferTokenize = ( lines: Map>, @@ -417,7 +452,7 @@ export class Editor implements DiffsEditor { if (line >= startingLine && line < endLine) { const lineElement = this.#getLineElement(line); if (lineElement !== undefined) { - lineElement.replaceChildren(...renderLineTokens(tokens)); + this.#replaceLineTokens(lineElement, tokens); } } } @@ -461,6 +496,15 @@ export class Editor implements DiffsEditor { } } + /** + * @internal Called by the host component after its options change. The + * `folding` option lives on the host (`BaseCodeOptions`), so this is how an + * option flip reaches an already-attached editor without a host re-render. + */ + __hostOptionsChanged(): void { + this.#refreshHostFoldingOption(); + } + // Small typescript hack to prevent UnresolvedFile from being editable. edit>( fileInstance: EditableInstance @@ -473,6 +517,15 @@ export class Editor implements DiffsEditor { } } this.#invalidateOnAttach(); + this.#hostFoldingEnabled = + fileInstance.type === 'file' && + fileInstance.__getEffectiveCodeOptions().folding !== false; + if ( + !this.#hostFoldingEnabled || + fileInstance.__setFoldRanges === undefined + ) { + this.#resetFoldingState(); + } this.#fileInstance = fileInstance; this.#initialize(); this.#detach = fileInstance.attachEditor(this); @@ -494,6 +547,7 @@ export class Editor implements DiffsEditor { if (textDocument == null) { throw new Error('Editor is not attached'); } + this.#unfoldFoldsIntersectingRanges(edits.map((edit) => edit.range)); // Only reposition focus and scroll when the editor already holds focus. A // programmatic edit must not pull focus from another input the user is // typing in; the selection state below is re-anchored either way. @@ -595,8 +649,17 @@ export class Editor implements DiffsEditor { getState(): EditorState { const fileInstance = this.#fileInstance; + const foldRanges: LineRange[] = []; + if (this.#isFoldingEnabled && this.#foldedStartLines.size > 0) { + for (const range of this.#foldRanges) { + if (this.#foldedStartLines.has(range.startLine)) { + foldRanges.push({ ...range }); + } + } + } return { selections: this.#selections, + foldRanges, view: fileInstance != null ? { @@ -607,11 +670,16 @@ export class Editor implements DiffsEditor { }; } - setState({ selections, view }: EditorState): void { + setState({ selections, foldRanges, view }: EditorState): void { if (this.#fileInstance === undefined || this.#textDocument === undefined) { throw new Error('Editor is not attached'); } this.#canMountSelectionAction = false; + this.#restoreFoldRanges(foldRanges ?? []); + const primarySelection = selections?.at(-1); + if (primarySelection !== undefined) { + this.#revealLineIfCollapsed(getCaretPosition(primarySelection).line); + } this.#updateSelections(selections ?? []); // When a saved view is present, honor its scroll offsets exactly. Scrolling // the caret into view afterward would overwrite them whenever the caret @@ -741,7 +809,8 @@ export class Editor implements DiffsEditor { if (!recycle) { this.#attachState.delivered = false; } - const hadFileInstance = this.#fileInstance != null; + const fileInstance = this.#fileInstance; + const hadFileInstance = fileInstance != null; const shouldRestoreState = this.#options.persistState === true; this.#stateRestoreGeneration++; this.#persistCurrentState(); @@ -774,8 +843,19 @@ export class Editor implements DiffsEditor { this.#editorEventDisposes = undefined; this.#selectEventDisposes?.forEach((dispose) => dispose()); this.#selectEventDisposes = undefined; + this.#removeFoldingControls(); this.#detach?.(recycle); this.#detach = undefined; + if (!recycle) { + fileInstance?.__setFoldRanges?.([]); + this.#resetFoldingState(); + } + this.#fileInstance = undefined; + this.#pendingFoldFocus = undefined; + this.#syncedFoldRangeHost = undefined; + this.#syncedFoldRangeVersion = -1; + this.#syncedFoldingEnabled = undefined; + this.#hostFoldingEnabled = false; // cache this.#gutterWidthCache = undefined; @@ -795,6 +875,7 @@ export class Editor implements DiffsEditor { this.#spriteElement?.remove(); this.#spriteElement = undefined; this.#fileContainer = undefined; + this.#codeElement = undefined; this.#popoverManager?.cleanUp(); this.#popoverManager = undefined; this.#gutterElement = undefined; @@ -884,6 +965,7 @@ export class Editor implements DiffsEditor { if (fileInstance == null) { return; } + this.#renderViewGeneration++; const shadowRoot = fileContainer.shadowRoot; if (shadowRoot == null) { console.error('[editor] Could not find the shadow root.'); @@ -912,6 +994,7 @@ export class Editor implements DiffsEditor { this.#replacementFocusRequest = undefined; return; } + this.#codeElement = codeElement; this.#getPopoverManager().setViewportElements(fileContainer, codeElement); @@ -983,6 +1066,7 @@ export class Editor implements DiffsEditor { new TextDocument(fileOrDiff.name, contents, languageId, 0, editStack); this.#fileInfo = { name, lang, cacheKey }; this.#textDocument = textDocument; + this.#resetFoldingState(); if (persistedCacheKey !== undefined) { this.#textDocumentCache.set(persistedCacheKey, textDocument); persistedStateTarget = { @@ -1101,6 +1185,10 @@ export class Editor implements DiffsEditor { } } + this.#refreshHostFoldingOption(); + this.#refreshFoldingRanges(); + this.#syncFoldedRangesToHost(); + this.#renderFoldingControls(); this.#resetCache(); // The tokenizer is created once per attached document and reused across @@ -1235,6 +1323,7 @@ export class Editor implements DiffsEditor { this.#pendingStateRestore = undefined; if ( textDocument.version === pendingRestore.documentVersion && + this.#foldStateVersion === pendingRestore.foldStateVersion && this.#selections === pendingRestore.selections && state.view?.scrollLeft === pendingRestore.view?.scrollLeft && state.view?.scrollTop === pendingRestore.view?.scrollTop @@ -1297,6 +1386,7 @@ export class Editor implements DiffsEditor { ): void { const generation = ++this.#stateRestoreGeneration; const documentVersion = textDocument.version; + const foldStateVersion = this.#foldStateVersion; const selections = this.#selections; const view = this.getState().view; let inputWatch: ViewportInputWatch | undefined; @@ -1310,6 +1400,7 @@ export class Editor implements DiffsEditor { generation !== this.#stateRestoreGeneration || this.#textDocument !== textDocument || textDocument.version !== documentVersion || + this.#foldStateVersion !== foldStateVersion || this.#selections !== selections || currentView?.scrollLeft !== view?.scrollLeft || this.#fileInfo === undefined || @@ -1362,6 +1453,7 @@ export class Editor implements DiffsEditor { cacheKey, textDocument, documentVersion, + foldStateVersion, selections, view, completion: result.catch(() => {}), @@ -1376,6 +1468,671 @@ export class Editor implements DiffsEditor { } } + get #isFoldingEnabled(): boolean { + return ( + this.#hostFoldingEnabled && + this.#fileInstance?.type === 'file' && + this.#fileInstance.__setFoldRanges !== undefined + ); + } + + // Re-read the host's `folding` option and rebuild or clear the fold state + // when it flipped. Host option changes always re-render the host, which + // lands in #syncRenderView, so polling there plus the explicit + // __hostOptionsChanged hook covers both render-driven and direct flips. + #refreshHostFoldingOption(): void { + const fileInstance = this.#fileInstance; + if (fileInstance?.type !== 'file') { + return; + } + const enabled = fileInstance.__getEffectiveCodeOptions().folding !== false; + if (this.#hostFoldingEnabled === enabled) { + return; + } + this.#hostFoldingEnabled = enabled; + this.#handleFoldingOptionChange(); + } + + #resetFoldingState(): void { + this.#foldRanges = []; + this.#foldRangesByStart.clear(); + this.#foldEndLines.clear(); + this.#foldClosingDelimiterLines.clear(); + this.#foldedStartLines.clear(); + this.#hiddenLineRanges = []; + this.#hiddenLineIndex = new LineRangeIndex(); + this.#hiddenLineRangeVersion++; + this.#foldStateVersion++; + this.#foldRangeDocument = undefined; + this.#foldRangeVersion = -1; + } + + #setHiddenLineRanges(ranges: LineRange[]): boolean { + if ( + ranges.length === this.#hiddenLineRanges.length && + ranges.every((range, index) => { + const previous = this.#hiddenLineRanges[index]; + return ( + previous?.startLine === range.startLine && + previous.endLine === range.endLine + ); + }) + ) { + return false; + } + this.#hiddenLineRanges = ranges; + this.#hiddenLineIndex = new LineRangeIndex(ranges); + this.#hiddenLineRangeVersion++; + return true; + } + + // Fold candidates are cached by document version and computed in one pass. + // Active folded headers stay separate so nested state survives an outer + // fold being toggled off. + #refreshFoldingRanges(force = false): boolean { + const textDocument = this.#textDocument; + if (!this.#isFoldingEnabled || textDocument === undefined) { + return false; + } + if ( + !force && + this.#foldRangeDocument === textDocument && + this.#foldRangeVersion === textDocument.version + ) { + return false; + } + + const hadFoldedRanges = this.#foldedStartLines.size > 0; + this.#foldRanges = computeIndentFoldingRanges( + textDocument, + this.#metrics.tabSize + ); + const rangesByStart = new Map(); + const endLines = new Set(); + const closingDelimiterLines = new Set(); + for (const range of this.#foldRanges) { + rangesByStart.set(range.startLine, range); + endLines.add(range.endLine); + const closingLine = range.endLine + 1; + if ( + closingLine < textDocument.lineCount && + isFoldingClosingDelimiter(textDocument.getLineText(closingLine)) + ) { + closingDelimiterLines.add(closingLine); + } + } + this.#foldRangesByStart = rangesByStart; + this.#foldEndLines = endLines; + this.#foldClosingDelimiterLines = closingDelimiterLines; + for (const startLine of this.#foldedStartLines) { + if (!this.#foldRangesByStart.has(startLine)) { + this.#foldedStartLines.delete(startLine); + } + } + if (hadFoldedRanges) { + this.#foldStateVersion++; + } + this.#foldRangeDocument = textDocument; + this.#foldRangeVersion = textDocument.version; + return this.#setHiddenLineRanges( + mergeHiddenLineRanges(this.#foldRanges, this.#foldedStartLines) + ); + } + + #restoreFoldRanges(foldRanges: readonly LineRange[]): void { + if (!this.#isFoldingEnabled) { + return; + } + this.#refreshFoldingRanges(); + + const nextFoldedStartLines = new Set(); + for (const range of foldRanges) { + if ( + range == null || + !Number.isInteger(range.startLine) || + !Number.isInteger(range.endLine) + ) { + continue; + } + const candidate = this.#foldRangesByStart.get(range.startLine); + if (candidate?.endLine === range.endLine) { + nextFoldedStartLines.add(range.startLine); + } + } + + let changed = nextFoldedStartLines.size !== this.#foldedStartLines.size; + if (!changed) { + for (const startLine of nextFoldedStartLines) { + if (!this.#foldedStartLines.has(startLine)) { + changed = true; + break; + } + } + } + if (!changed) { + return; + } + + this.#foldedStartLines = nextFoldedStartLines; + this.#foldStateVersion++; + this.#refreshFoldedView(); + } + + #syncFoldedRangesToHost(): void { + const host = this.#fileInstance; + if (host?.type !== 'file' || host.__setFoldRanges === undefined) { + return; + } + const foldingEnabled = this.#isFoldingEnabled; + if ( + this.#syncedFoldRangeHost === host && + this.#syncedFoldRangeVersion === this.#hiddenLineRangeVersion && + this.#syncedFoldingEnabled === foldingEnabled + ) { + return; + } + host.__setFoldRanges(foldingEnabled ? this.#hiddenLineRanges : []); + this.#syncedFoldRangeHost = host; + this.#syncedFoldRangeVersion = this.#hiddenLineRangeVersion; + this.#syncedFoldingEnabled = foldingEnabled; + } + + #removeFoldingControls(): void { + this.#codeElement?.removeAttribute('data-folding'); + this.#gutterElement + ?.querySelectorAll('[data-fold]') + .forEach((element) => element.remove()); + if (this.#hasFoldIndicators) { + this.#contentElement + ?.querySelectorAll('[data-fold-indicator]') + .forEach((element) => element.remove()); + this.#hasFoldIndicators = false; + } + } + + // Attach toggle listeners once per button. Buttons can be adopted from + // read-only fold markup the file renderer emitted before the editor + // attached, so creation and initialization are tracked separately. + #initializeFoldButton(button: HTMLButtonElement): void { + if (this.#initializedFoldButtons.has(button)) { + return; + } + this.#initializedFoldButtons.add(button); + button.addEventListener('pointerdown', (event) => { + event.preventDefault(); + event.stopPropagation(); + }); + button.addEventListener('keydown', (event) => { + if (event.key !== 'Escape') { + event.stopPropagation(); + } + }); + button.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + const lineIndex = Number( + button.closest('[data-line-index]')?.dataset.lineIndex + ); + if (Number.isInteger(lineIndex)) { + this.#toggleFold(lineIndex, event.detail === 0); + } + }); + } + + #renderFoldingControls(): void { + const codeElement = this.#codeElement; + const gutterElement = this.#gutterElement; + const contentElement = this.#contentElement; + const textDocument = this.#textDocument; + if ( + !this.#isFoldingEnabled || + codeElement === undefined || + gutterElement === undefined || + contentElement === undefined || + textDocument === undefined + ) { + this.#removeFoldingControls(); + return; + } + + codeElement.setAttribute('data-folding', ''); + const foldedLineElements = new Map(); + if (this.#foldedStartLines.size === 0) { + if (this.#hasFoldIndicators) { + contentElement + .querySelectorAll('[data-fold-indicator]') + .forEach((element) => element.remove()); + this.#hasFoldIndicators = false; + } + } else { + let hasFoldIndicators = false; + for (const child of contentElement.children) { + if (!(child instanceof HTMLElement)) { + continue; + } + const lineIndex = Number(child.dataset.lineIndex); + const isFolded = + Number.isInteger(lineIndex) && + this.#foldedStartLines.has(lineIndex) && + this.#foldRangesByStart.has(lineIndex); + const indicator = child.querySelector( + ':scope > [data-fold-indicator]' + ); + if (!isFolded) { + indicator?.remove(); + continue; + } + foldedLineElements.set(lineIndex, child); + hasFoldIndicators ||= indicator !== null; + } + this.#hasFoldIndicators = hasFoldIndicators; + } + + for (const child of gutterElement.children) { + if (!(child instanceof HTMLElement)) { + continue; + } + const lineIndex = Number(child.dataset.lineIndex); + const existingZone = child.querySelector( + ':scope > [data-fold]' + ); + if ( + child.dataset.columnNumber === undefined || + !Number.isInteger(lineIndex) + ) { + existingZone?.remove(); + continue; + } + + const range = this.#foldRangesByStart.get(lineIndex); + if (range === undefined) { + existingZone?.remove(); + continue; + } + + const folded = this.#foldedStartLines.has(lineIndex); + const zone = existingZone ?? h('span', { dataset: 'fold' }, child); + let button = zone.querySelector( + ':scope > [data-fold-toggle]' + ); + let buttonCreated = false; + if (button === null) { + button = h( + 'button', + { + dataset: 'foldToggle', + type: 'button', + }, + zone + ); + buttonCreated = true; + } + this.#initializeFoldButton(button); + + const wasFolded = button.dataset.folded !== undefined; + button.ariaExpanded = folded ? 'false' : 'true'; + button.ariaLabel = `${folded ? 'Unfold' : 'Fold'} line ${lineIndex + 1}`; + button.title = folded ? 'Unfold' : 'Fold'; + button.toggleAttribute('data-folded', folded); + if (buttonCreated || wasFolded !== folded) { + button.innerHTML = getFoldIconSvg( + folded ? 'chevron-right' : 'chevron-down', + FOLD_TOGGLE_ICON_SIZE + ); + } + + if (!folded) { + continue; + } + + const lineElement = foldedLineElements.get(lineIndex); + if (lineElement === undefined) { + continue; + } + + const indicator = + lineElement.querySelector( + ':scope > [data-fold-indicator]' + ) ?? + h( + 'span', + { + dataset: 'foldIndicator', + contentEditable: 'false', + spellcheck: false, + }, + lineElement + ); + this.#hasFoldIndicators = true; + indicator.setAttribute('contenteditable', 'false'); + indicator.dataset.foldCharacter = textDocument + .getLineLength(lineIndex) + .toString(); + let ellipsis = indicator.querySelector( + ':scope > [data-fold-ellipsis]' + ); + if (ellipsis === null) { + ellipsis = h('button', { + dataset: 'foldEllipsis', + type: 'button', + innerHTML: getFoldIconSvg('ellipsis', FOLD_ELLIPSIS_ICON_SIZE), + }); + indicator.prepend(ellipsis); + } + this.#initializeFoldButton(ellipsis); + ellipsis.ariaLabel = `Unfold line ${lineIndex + 1}`; + ellipsis.title = 'Unfold'; + } + this.#restorePendingFoldFocus(); + } + + #refreshFoldedView(updateHiddenLineRanges = true): void { + if (updateHiddenLineRanges) { + this.#setHiddenLineRanges( + mergeHiddenLineRanges(this.#foldRanges, this.#foldedStartLines) + ); + } + this.#syncFoldedRangesToHost(); + this.#resetCache(); + this.#renderFoldingControls(); + if (this.#selections !== undefined) { + this.#updateSelections(this.#selections); + } + this.#markerRenderer?.removePopover(); + } + + #toggleFold(startLine: number, restoreFocus = false): void { + this.#refreshFoldingRanges(); + const range = this.#foldRangesByStart.get(startLine); + const textDocument = this.#textDocument; + if (range === undefined || textDocument === undefined) { + return; + } + if (restoreFocus) { + this.#pendingFoldFocus = { + line: startLine, + generation: this.#renderViewGeneration + 1, + }; + } + + if (this.#foldedStartLines.has(startLine)) { + this.#foldedStartLines.delete(startLine); + } else { + this.#foldedStartLines.add(startLine); + } + this.#foldStateVersion++; + this.#setHiddenLineRanges( + mergeHiddenLineRanges(this.#foldRanges, this.#foldedStartLines) + ); + + // A selection whose caret is swallowed by the new fold maps to the end of + // its header, matching the display-map behavior of native editors. + if (this.#selections !== undefined) { + let didMoveCaret = false; + const nextSelections = this.#selections.map( + (selection) => { + if ( + !this.#hiddenLineIndex.isHidden(getCaretPosition(selection).line) + ) { + return selection; + } + didMoveCaret = true; + const caret = { + line: startLine, + character: textDocument.getLineLength(startLine), + }; + return { start: caret, end: caret, direction: DirectionNone }; + } + ); + if (didMoveCaret) { + this.#selections = nextSelections; + } + } + + this.#refreshFoldedView(false); + } + + #restorePendingFoldFocus(): void { + const pending = this.#pendingFoldFocus; + if ( + pending === undefined || + this.#renderViewGeneration < pending.generation + ) { + return; + } + const toggle = this.#gutterElement?.querySelector( + `[data-line-index="${pending.line}"] [data-fold-toggle]` + ); + if (toggle?.isConnected !== true) { + return; + } + toggle.focus({ preventScroll: true }); + this.#pendingFoldFocus = undefined; + } + + #unfoldFoldsIntersectingRanges(ranges: readonly Range[]): void { + if ( + !this.#isFoldingEnabled || + this.#foldedStartLines.size === 0 || + this.#textDocument === undefined + ) { + return; + } + const normalizedRanges = ranges.map((range) => { + const start = this.#textDocument!.normalizePosition(range.start); + const end = this.#textDocument!.normalizePosition(range.end); + return { + startLine: Math.min(start.line, end.line), + endLine: Math.max(start.line, end.line), + }; + }); + let changed = false; + for (const startLine of this.#foldedStartLines) { + const foldRange = this.#foldRangesByStart.get(startLine); + if ( + foldRange !== undefined && + normalizedRanges.some( + (range) => + range.endLine >= foldRange.startLine + 1 && + range.startLine <= foldRange.endLine + ) + ) { + this.#foldedStartLines.delete(startLine); + changed = true; + } + } + if (changed) { + this.#foldStateVersion++; + this.#refreshFoldedView(); + } + } + + #unfoldLine(line: number): boolean { + if (!this.#isFoldingEnabled) { + return false; + } + let changed = false; + for (const startLine of this.#foldedStartLines) { + const range = this.#foldRangesByStart.get(startLine); + if ( + range !== undefined && + line > range.startLine && + line <= range.endLine + ) { + this.#foldedStartLines.delete(startLine); + changed = true; + } + } + if (changed) { + this.#foldStateVersion++; + this.#refreshFoldedView(); + } + return changed; + } + + #remapFoldingAfterChange(change: TextDocumentChange): boolean { + if (!this.#isFoldingEnabled) { + return false; + } + + const shouldRefreshRanges = this.#changeMayAffectFolding(change); + this.#remapFoldedRangesAfterChange(change); + + if (shouldRefreshRanges) { + this.#foldRangeVersion = -1; + this.#refreshFoldingRanges(true); + } else if (this.#textDocument !== undefined) { + this.#foldRangeDocument = this.#textDocument; + this.#foldRangeVersion = this.#textDocument.version; + } + return shouldRefreshRanges; + } + + // Remap active ranges through each line-changing edit in document order. + // Aggregate change bounds are insufficient for multi-edit batches because + // folds between edits can stay valid even when the batch has a net delta. + #remapFoldedRangesAfterChange(change: TextDocumentChange): void { + const lineChanges = + change.changedLineChanges ?? + ([[change.startLine, change.endLine, change.lineDelta]] as const); + if ( + this.#foldedStartLines.size === 0 || + !lineChanges.some(([, , lineDelta]) => lineDelta !== 0) + ) { + return; + } + + let activeRanges = [...this.#foldedStartLines] + .map((startLine) => this.#foldRangesByStart.get(startLine)) + .filter((range): range is LineRange => range !== undefined) + .map((range) => ({ ...range })); + + for (const [ + startLine, + endLine, + lineDelta, + startCharacter, + endCharacter, + ] of lineChanges) { + if (lineDelta === 0) { + continue; + } + const oldEndLine = Math.max(startLine, endLine - lineDelta); + const insertsBeforeLine = + lineDelta > 0 && startCharacter === 0 && endCharacter === 0; + const deletesBeforeLine = lineDelta < 0 && endCharacter === 0; + const nextRanges: LineRange[] = []; + for (const range of activeRanges) { + if (range.endLine < startLine) { + nextRanges.push(range); + } else if ( + range.startLine > oldEndLine || + (insertsBeforeLine && range.startLine >= startLine) || + (deletesBeforeLine && range.startLine === oldEndLine) + ) { + nextRanges.push({ + startLine: range.startLine + lineDelta, + endLine: range.endLine + lineDelta, + }); + } + } + activeRanges = nextRanges; + } + + const nextFoldedStartLines = new Set( + activeRanges.map((range) => range.startLine) + ); + let changed = nextFoldedStartLines.size !== this.#foldedStartLines.size; + if (!changed) { + for (const startLine of nextFoldedStartLines) { + if (!this.#foldedStartLines.has(startLine)) { + changed = true; + break; + } + } + } + if (changed) { + this.#foldedStartLines = nextFoldedStartLines; + this.#foldStateVersion++; + } + } + + // Ordinary character edits cannot change indentation folds. Re-scan only + // when an edit touches indentation, blank headers/ends, closing delimiters, + // or the document's line structure. + #changeMayAffectFolding(change: TextDocumentChange): boolean { + const textDocument = this.#textDocument; + if (textDocument === undefined || change.lineDelta !== 0) { + return true; + } + + const lineChanges = + change.changedLineChanges ?? + ([ + [ + change.startLine, + change.endLine, + change.lineDelta, + change.startCharacter, + change.endCharacter, + ], + ] as const); + for (const [ + startLine, + endLine, + lineDelta, + startCharacter = 0, + endCharacter = startCharacter, + ] of lineChanges) { + if (lineDelta !== 0) { + return true; + } + for (let line = startLine; line <= endLine; line++) { + const text = textDocument.getLineText(line); + const indentationLength = text.length - text.trimStart().length; + const trimmedText = text.trim(); + if ( + startCharacter <= indentationLength || + endCharacter <= indentationLength || + this.#foldClosingDelimiterLines.has(line) || + isFoldingClosingDelimiter(trimmedText) || + (trimmedText.length === 0 && + (this.#foldRangesByStart.has(line) || this.#foldEndLines.has(line))) + ) { + return true; + } + } + } + return false; + } + + #handleFoldingOptionChange(): void { + if (this.#isFoldingEnabled) { + this.#foldRangeVersion = -1; + this.#refreshFoldingRanges(true); + } else { + if (this.#foldedStartLines.size > 0) { + this.#foldStateVersion++; + } + this.#foldRanges = []; + this.#foldRangesByStart.clear(); + this.#foldEndLines.clear(); + this.#foldClosingDelimiterLines.clear(); + this.#foldedStartLines.clear(); + this.#setHiddenLineRanges([]); + this.#foldRangeDocument = undefined; + this.#foldRangeVersion = -1; + } + this.#syncFoldedRangesToHost(); + this.#resetCache(); + this.#gutterWidthCache = undefined; + this.#contentWidthCache = undefined; + this.#renderFoldingControls(); + if (this.#selections !== undefined) { + this.#updateSelections(this.#selections); + } + } + #watchViewportUserInput(): ViewportInputWatch | undefined { const viewport = this.#getScrollViewport(); if (!(viewport instanceof HTMLElement)) { @@ -1401,28 +2158,59 @@ export class Editor implements DiffsEditor { return { userScrolled: () => scrolled, dispose }; } - // Whether a zero-based document line has (or will have on scroll) a - // rendered row. False only for lines hidden inside a collapsed unchanged - // region of a diff host; hosts without collapsible regions treat every - // line as renderable. + // Whether a zero-based document line has (or will have on scroll) a rendered + // row. File folds and collapsed diff regions share this visibility gate. #isLineRenderable(line: number): boolean { - return this.#fileInstance?.isLineRenderable?.(line + 1) ?? true; + return ( + (!this.#isFoldingEnabled || !this.#hiddenLineIndex.isHidden(line)) && + (this.#fileInstance?.isLineRenderable?.(line + 1) ?? true) + ); } // Fold-skip resolver for vertical caret motion (zero-based lines), or - // undefined for hosts without collapsible regions so motion stays plain - // line arithmetic. + // undefined when neither the editor nor its host hides document lines. get #resolveRenderableLine(): ResolveRenderableLine | undefined { const fileInstance = this.#fileInstance; - if (fileInstance?.getNearestRenderableLine == null) { + const textDocument = this.#textDocument; + const hasEditorFolds = + this.#isFoldingEnabled && this.#hiddenLineRanges.length > 0; + if (!hasEditorFolds && fileInstance?.getNearestRenderableLine == null) { return undefined; } return (line, direction) => { - const nearest = fileInstance.getNearestRenderableLine!( - line + 1, - direction - ); - return nearest == null ? undefined : nearest - 1; + let candidate = line; + for (let attempt = 0; attempt < 4; attempt++) { + if (hasEditorFolds) { + const nearest = this.#hiddenLineIndex.nearestVisibleLine( + candidate, + direction, + textDocument?.lineCount ?? 0 + ); + if (nearest === undefined) { + return undefined; + } + candidate = nearest; + } + if (fileInstance?.getNearestRenderableLine == null) { + return candidate; + } + const nearest = fileInstance.getNearestRenderableLine( + candidate + 1, + direction + ); + if (nearest == null) { + return undefined; + } + const hostCandidate = nearest - 1; + if ( + hostCandidate === candidate && + !this.#hiddenLineIndex.isHidden(hostCandidate) + ) { + return hostCandidate; + } + candidate = hostCandidate; + } + return this.#isLineRenderable(candidate) ? candidate : undefined; }; } @@ -1430,6 +2218,7 @@ export class Editor implements DiffsEditor { // moves) may land inside a collapsed region; expand it minimally so the // caret's row can render before the scroll below retries toward it. #revealLineIfCollapsed(line: number): void { + this.#unfoldLine(line); if (!this.#isLineRenderable(line)) { this.#fileInstance?.revealLine?.(line + 1); } @@ -3190,6 +3979,36 @@ export class Editor implements DiffsEditor { }); } + // Retokenizing a folded header keeps its focused inline indicator attached. + #replaceLineTokens( + lineElement: HTMLElement, + tokens: Array + ): void { + const lastElement = lineElement.lastElementChild; + const foldIndicator = + lastElement instanceof HTMLElement && + lastElement.dataset.foldIndicator !== undefined + ? lastElement + : undefined; + if (foldIndicator === undefined) { + lineElement.replaceChildren(...renderLineTokens(tokens)); + return; + } + + let child = lineElement.firstChild; + while (child !== null && child !== foldIndicator) { + const next = child.nextSibling; + child.remove(); + child = next; + } + foldIndicator.before(...renderLineTokens(tokens)); + const lineIndex = Number(lineElement.dataset.lineIndex); + if (Number.isInteger(lineIndex)) { + foldIndicator.dataset.foldCharacter = + this.#textDocument?.getLineLength(lineIndex).toString() ?? '0'; + } + } + #rerender( change: TextDocumentChange, newLineAnnotations?: DiffLineAnnotation[], @@ -3246,7 +4065,7 @@ export class Editor implements DiffsEditor { const lineIndex = lineNumber - 1; if (dirtyLineIndexes.has(lineIndex)) { const tokens = dirtyLines.get(lineIndex)!; - child.replaceChildren(...renderLineTokens(tokens)); + this.#replaceLineTokens(child, tokens); dirtyLineIndexes.delete(lineIndex); if (dirtyLineIndexes.size === 0) { break; @@ -5228,6 +6047,8 @@ export class Editor implements DiffsEditor { newLineAnnotations?: DiffLineAnnotation[], options?: { skipSearchRefresh?: boolean; skipFocus?: boolean } ) { + const foldingControlsChanged = this.#remapFoldingAfterChange(change); + const fileRef = this.getFile(); const onChange = this.#options.onChange; if (fileRef !== undefined && onChange !== undefined) { @@ -5360,6 +6181,10 @@ export class Editor implements DiffsEditor { } } this.#rerender(change, newLineAnnotations, renderRange, shouldUpdateBuffer); + this.#syncFoldedRangesToHost(); + if (foldingControlsChanged) { + this.#renderFoldingControls(); + } if ( options?.skipSearchRefresh !== true && diff --git a/packages/diffs/src/editor/selection.ts b/packages/diffs/src/editor/selection.ts index cb83b2e15..9999360f8 100644 --- a/packages/diffs/src/editor/selection.ts +++ b/packages/diffs/src/editor/selection.ts @@ -2406,6 +2406,20 @@ function boundaryToPosition(node: Node, offset: number): Position | null { return null; } + let foldIndicator = host; + while ( + foldIndicator !== null && + foldIndicator.dataset.foldIndicator === undefined + ) { + foldIndicator = foldIndicator.parentElement; + } + if (foldIndicator != null) { + const character = parseInt(foldIndicator.dataset.foldCharacter ?? '', 10); + if (!Number.isNaN(character)) { + return { line, character }; + } + } + if (node.nodeType === 3) { if (node.parentElement === null) { return null; @@ -2635,6 +2649,10 @@ function getLineChildEnd( if (el.tagName !== 'SPAN' && el.tagName !== 'BR') { return 0; } + if (el.dataset.foldIndicator !== undefined) { + const character = parseInt(el.dataset.foldCharacter ?? '', 10); + return Number.isNaN(character) ? 0 : character; + } const base = getCharacterIndex(el); if (base !== undefined) { return base + (el.textContent?.length ?? 0); diff --git a/packages/diffs/src/editor/sprite.ts b/packages/diffs/src/editor/sprite.ts index 71fc0471f..a4102e8fc 100644 --- a/packages/diffs/src/editor/sprite.ts +++ b/packages/diffs/src/editor/sprite.ts @@ -8,13 +8,11 @@ export type SVGSpriteNames = | 'replace' | 'replace-all'; -// Icon artwork is sourced from `@pierre/icons` (IconSearch, IconX, -// IconArrowRight, IconType, IconTypeWord, IconRegex, IconReplace, -// IconReplaceAll) so the editor matches the rest of the product. The arrow glyph -// is the full-size right arrow rotated to point up/down for the search -// "previous"/"next" controls. `getEditorIconSvg` omits an outer viewBox so each -// symbol scales to fill the requested size regardless of its intrinsic -// coordinate system. +// Icon artwork is sourced from `@pierre/icons` so the editor matches the rest +// of the product. The arrow glyph is the full-size right arrow rotated to point +// up/down for the search "previous"/"next" controls. `getEditorIconSvg` omits +// an outer viewBox so each symbol scales to fill the requested size regardless +// of its intrinsic coordinate system. export const SVGSpriteSheet = ` + + + diffs read-only-folding fixture + + + + ← All fixtures +
+ + + + diff --git a/packages/diffs/test/e2e/fixtures/folding.html b/packages/diffs/test/e2e/fixtures/folding.html new file mode 100644 index 000000000..ae1fdfed8 --- /dev/null +++ b/packages/diffs/test/e2e/fixtures/folding.html @@ -0,0 +1,94 @@ + + + + + + diffs editor-folding fixture + + + + ← All fixtures +
+ + + + diff --git a/packages/diffs/test/e2e/fixtures/index.html b/packages/diffs/test/e2e/fixtures/index.html index 1ce22bd6a..4b456bbd3 100644 --- a/packages/diffs/test/e2e/fixtures/index.html +++ b/packages/diffs/test/e2e/fixtures/index.html @@ -116,6 +116,26 @@

@pierre/diffs — E2E fixtures

window.__editableReady.

+
  • + folding.html +

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

    +
  • +
  • + folding-readonly.html +

    + A read-only File (no editor) with fold controls and line + selection enabled. Drives the read-only suite in + folding.pw.ts (fold/unfold, selection not triggered by + toggles). Ready flag: window.__foldingReady; line-number + clicks are logged to window.__lineNumberClicks. +

    +
  • markers.html

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