From a39820fba929450adb41d8adc87e3eb9687103e5 Mon Sep 17 00:00:00 2001
From: Amadeus Demarzi
Date: Fri, 10 Jul 2026 12:12:49 -0700
Subject: [PATCH 01/11] feat(diffs): Add edit-session region skeleton for diff
hunks
While an editor is attached to a FileDiff, hunk updates now preserve
the current hunk structure instead of recomputing it per keystroke:
each hunk is a frozen region, edits re-diff only the region they land
in, a fully reverted region persists as a context-only hunk, edits
spanning regions merge them, and edits inside an unchanged gap
synthesize a new region. The real recompute runs once on genuine
session exit, so post-session state is unchanged.
This is groundwork for edit mode preserving collapsed unchanged
regions; with expandUnchanged still forced during editing, rendering
behavior is unchanged.
Details:
- New pure module `utils/editSessionHunks.ts` holds the region math
(window mapping, region re-diff with zero context, expansion-key
remapping, exit recompute) built on the exported updateDiffHunks
helpers.
- Line-count edit passes run updateRenderCache with dense, stale
indexed dirty lines before applyDocumentChange. A new explicit
`lineCountChangeInFlight` flag threads from the editor through the
host so the session path defers hunk work to the document rebuild,
which scans a per-session line snapshot instead of the contaminated
live array. Deferred background-tokenize passes keep running region
updates from their (current) changed indexes.
- A same-line-count gap edit changes the rendered row set, which the
debounced refresh cannot express; the host escalates to a deferred
full re-render, and VirtualizedFileDiff also invalidates its layout
caches.
- The editor detach closure now reports whether it is a virtualized
recycle or a genuine session end. Session hunks and a dirty marker
on the shared FileDiffMetadata survive recycling; CodeView finishes
sessions whose instance was already released, and FileDiff.render
self-heals session-shaped metadata reused after a teardown.
---
packages/diffs/src/components/CodeView.ts | 15 +
packages/diffs/src/components/FileDiff.ts | 91 +++-
.../src/components/VirtualizedFileDiff.ts | 22 +
packages/diffs/src/editor/editor.ts | 9 +-
.../diffs/src/renderers/DiffHunksRenderer.ts | 155 +++++-
packages/diffs/src/types.ts | 28 +-
packages/diffs/src/utils/editSessionHunks.ts | 505 ++++++++++++++++++
packages/diffs/src/utils/updateDiffHunks.ts | 33 +-
packages/diffs/test/CodeView.edit.test.ts | 159 +++++-
.../test/DiffHunksRendererRecompute.test.ts | 165 +++++-
packages/diffs/test/editSessionHunks.test.ts | 369 +++++++++++++
11 files changed, 1514 insertions(+), 37 deletions(-)
create mode 100644 packages/diffs/src/utils/editSessionHunks.ts
create mode 100644 packages/diffs/test/editSessionHunks.test.ts
diff --git a/packages/diffs/src/components/CodeView.ts b/packages/diffs/src/components/CodeView.ts
index cd22f8dca..4df46c526 100644
--- a/packages/diffs/src/components/CodeView.ts
+++ b/packages/diffs/src/components/CodeView.ts
@@ -45,6 +45,7 @@ import { areSelectionsEqual } from '../utils/areSelectionsEqual';
import { areThemesEqual } from '../utils/areThemesEqual';
import { createCodeViewHeaderFooterHostElement } from '../utils/createCodeViewHeaderFooterHostElement';
import { createWindowFromScrollPosition } from '../utils/createWindowFromScrollPosition';
+import { finishEditSessionForDiff } from '../utils/editSessionHunks';
import { isStyleNode } from '../utils/isStyleNode';
import { prefersReducedMotion } from '../utils/prefersReducedMotion';
import { roundToDevicePixel } from '../utils/roundToDevicePixel';
@@ -2098,6 +2099,20 @@ export class CodeView {
record.editor.cleanUp();
this.itemEditors.delete(id);
this.attachedEditors.delete(id);
+ // When the session's instance was released by virtualization, the
+ // cleanUp above had no detach closure left to run the exit recompute,
+ // so finish the session directly against the diff metadata (idempotent:
+ // the dirty marker clears on the first run). The metadata comes from
+ // the live item, or from the last change's snapshot for removed items.
+ const itemSnapshot = item?.item ?? record.state.lastChange?.item;
+ if (
+ itemSnapshot?.type === 'diff' &&
+ finishEditSessionForDiff(itemSnapshot.fileDiff) &&
+ item != null
+ ) {
+ this.markItemLayoutDirty(item);
+ this.render();
+ }
const { lastChange } = record.state;
if (lastChange != null) {
// Prefer the current item record (it carries the update that ended
diff --git a/packages/diffs/src/components/FileDiff.ts b/packages/diffs/src/components/FileDiff.ts
index ae729ff84..775de5e10 100644
--- a/packages/diffs/src/components/FileDiff.ts
+++ b/packages/diffs/src/components/FileDiff.ts
@@ -23,6 +23,10 @@ import {
} from '../managers/InteractionManager';
import { ResizeManager } from '../managers/ResizeManager';
import { ScrollSyncManager } from '../managers/ScrollSyncManager';
+import {
+ dequeueRender,
+ queueRender,
+} from '../managers/UniversalRenderingManager';
import {
DiffHunksRenderer,
type DiffHunksRendererOptions,
@@ -69,6 +73,7 @@ import {
wrapThemeCSS,
wrapUnsafeCSS,
} from '../utils/cssWrappers';
+import { finishEditSessionForDiff } from '../utils/editSessionHunks';
import { getDiffFileInput } from '../utils/getDiffFileInput';
import { getDiffHunksRendererOptions } from '../utils/getDiffHunksRendererOptions';
import { getLineAnnotationName } from '../utils/getLineAnnotationName';
@@ -541,6 +546,7 @@ export class FileDiff<
}
public cleanUp(recycle: boolean = false): void {
+ dequeueRender(this.handleEditSessionRender);
this.emitPostRender(true);
this.resizeManager.cleanUp();
this.interactionManager.cleanUp();
@@ -982,6 +988,15 @@ export class FileDiff<
if (this.fileDiff == null) {
return false;
}
+ // Backstop for sessions that ended without their exit hook running (e.g.
+ // session-shaped metadata reused after a host teardown): restore
+ // recompute-shaped hunks before rendering.
+ if (
+ this.fileDiff.editSessionDirty === true &&
+ this.shouldSelfHealEditSession()
+ ) {
+ finishEditSessionForDiff(this.fileDiff, this.options.parseDiffOptions);
+ }
if (expandUnchanged) {
this.loadFilesIfNecessary();
}
@@ -1188,17 +1203,45 @@ export class FileDiff<
}
}
- public attachEditor(editor: DiffsEditor): () => void {
+ public attachEditor(
+ editor: DiffsEditor
+ ): (recycle?: boolean) => void {
this.editor?.cleanUp();
this.editor = editor;
this.interactionManager.setEditorAttached(true);
+ // Edit sessions are a plain-diff concern; subclasses with their own hunk
+ // semantics (merge conflicts) keep the per-edit recompute pipeline.
+ if (this.type === 'file-diff') {
+ this.hunksRenderer.beginEditSession(this.fileDiffCache);
+ }
this.syncRenderViewToEditor();
- return () => {
+ return (recycle?: boolean) => {
this.editor = undefined;
this.interactionManager.setEditorAttached(false);
+ // A recycle detach is a virtualized unmount mid-session: the session
+ // continues on remount, so hunks stay session-shaped. Only a genuine
+ // end runs the exit recompute.
+ if (recycle !== true) {
+ this.finishEditSession();
+ }
};
}
+ // Genuine session exit: restore recompute-shaped hunks (a context-only
+ // region collapses away, boundaries re-derive) and repaint. Safe on a
+ // cleaned-up instance — the recompute is pure metadata work and rerender is
+ // enabled-guarded.
+ private finishEditSession(): void {
+ this.hunksRenderer.endEditSession();
+ const fileDiff = this.fileDiffCache;
+ if (
+ fileDiff != null &&
+ finishEditSessionForDiff(fileDiff, this.options.parseDiffOptions)
+ ) {
+ this.rerender();
+ }
+ }
+
// normally triggered by the host when the document line count changes
public applyDocumentChange(
textDocument: DiffsTextDocument,
@@ -1228,9 +1271,28 @@ export class FileDiff<
public updateRenderCache(
dirtyLines: Map>,
themeType: 'dark' | 'light',
- shouldRefreshView: boolean
+ shouldRefreshView: boolean,
+ lineCountChangeInFlight = false
): void {
- this.hunksRenderer.updateRenderCache(dirtyLines, themeType);
+ const regionsChanged = this.hunksRenderer.updateRenderCache(
+ dirtyLines,
+ themeType,
+ lineCountChangeInFlight
+ );
+ // A same-line-count edit that reshaped the session regions (an edit into
+ // a collapsed gap) changes the rendered row set, which the debounced
+ // line-type refresh below cannot express. Escalate to a deferred full
+ // re-render — never a synchronous one, since this runs mid-editor-pass
+ // and rebuilding rows the editor is about to touch detaches its geometry
+ // caches.
+ if (regionsChanged) {
+ if (this.refreshViewTimeout != null) {
+ clearTimeout(this.refreshViewTimeout);
+ this.refreshViewTimeout = undefined;
+ }
+ this.escalateEditSessionRender();
+ return;
+ }
if (shouldRefreshView) {
if (this.refreshViewTimeout != null) {
clearTimeout(this.refreshViewTimeout);
@@ -1250,6 +1312,27 @@ export class FileDiff<
}
}
+ // Whether render() may run the session-exit recompute on dirty metadata.
+ // False while an editor is attached (the session is live). CodeView-managed
+ // instances override this: their sessions survive recycling with no editor
+ // attached, and CodeView runs the exit recompute itself when it reaps a
+ // session.
+ protected shouldSelfHealEditSession(): boolean {
+ return this.editor == null;
+ }
+
+ // Deferred full re-render for session region changes. The subsequent
+ // render() ends in syncRenderViewToEditor, which resets the editor's
+ // geometry caches against the rebuilt rows. VirtualizedFileDiff overrides
+ // this to also invalidate its layout caches.
+ protected escalateEditSessionRender(): void {
+ queueRender(this.handleEditSessionRender);
+ }
+
+ private handleEditSessionRender = (): void => {
+ this.rerender();
+ };
+
private removeRenderedCode(): void {
this.resizeManager.cleanUp();
this.scrollSyncManager.cleanUp();
diff --git a/packages/diffs/src/components/VirtualizedFileDiff.ts b/packages/diffs/src/components/VirtualizedFileDiff.ts
index 94692e0f0..781e38728 100644
--- a/packages/diffs/src/components/VirtualizedFileDiff.ts
+++ b/packages/diffs/src/components/VirtualizedFileDiff.ts
@@ -896,6 +896,28 @@ export class VirtualizedFileDiff<
super.loadFilesIfNecessary();
}
+ // Session region changes alter the rendered row set without a line-count
+ // change, so the layout caches need the same invalidation a document change
+ // gets; rerender() defers the actual render through the virtualizer queue.
+ protected override escalateEditSessionRender(): void {
+ this.getSimpleVirtualizer()?.markDOMDirty();
+ this.resetLayoutCache({
+ forceSimpleRecompute: this.isSimpleMode(),
+ includeEstimatedHeights: true,
+ resetRenderRange: false,
+ });
+ if (!this.isSimpleMode()) {
+ this.computeApproximateSize(true);
+ }
+ this.rerender();
+ }
+
+ protected override shouldSelfHealEditSession(): boolean {
+ // CodeView sessions survive recycling with no editor attached; CodeView
+ // itself runs the exit recompute when it reaps a session.
+ return !this.isAdvancedMode() && super.shouldSelfHealEditSession();
+ }
+
public setVisibility(visible: boolean): void {
if (this.isAdvancedMode() || this.fileContainer == null) {
return;
diff --git a/packages/diffs/src/editor/editor.ts b/packages/diffs/src/editor/editor.ts
index 154b108fc..eb80155aa 100644
--- a/packages/diffs/src/editor/editor.ts
+++ b/packages/diffs/src/editor/editor.ts
@@ -200,7 +200,7 @@ export class Editor implements DiffsEditor {
#editorEventDisposes?: (() => void)[];
#globalEventDisposes?: (() => void)[];
#selectEventDisposes?: (() => void)[];
- #detach?: () => void;
+ #detach?: (recycle?: boolean) => void;
// cache
#contentOffset?: { left: number; top: number };
@@ -285,7 +285,7 @@ export class Editor implements DiffsEditor {
lines: Map>,
themeType: 'light' | 'dark'
) => {
- this.#fileInstance?.updateRenderCache(lines, themeType, false);
+ this.#fileInstance?.updateRenderCache(lines, themeType, false, false);
// update the view if the render range is updated by scrolling
// and the deferred tokenized lines inside the render range
if (
@@ -600,7 +600,7 @@ export class Editor implements DiffsEditor {
this.#editorEventDisposes = undefined;
this.#selectEventDisposes?.forEach((dispose) => dispose());
this.#selectEventDisposes = undefined;
- this.#detach?.();
+ this.#detach?.(recycle);
this.#detach = undefined;
// cache
@@ -2248,7 +2248,8 @@ export class Editor implements DiffsEditor {
fileInstance.updateRenderCache(
dirtyLines,
tokenizer.themeType,
- !didLineCountChange
+ !didLineCountChange,
+ didLineCountChange
);
if (didLineCountChange) {
// Line-count change: recompute hunks from the full document and re-render.
diff --git a/packages/diffs/src/renderers/DiffHunksRenderer.ts b/packages/diffs/src/renderers/DiffHunksRenderer.ts
index 4357bc26e..b3b8c5a1d 100644
--- a/packages/diffs/src/renderers/DiffHunksRenderer.ts
+++ b/packages/diffs/src/renderers/DiffHunksRenderer.ts
@@ -51,6 +51,14 @@ import { createFileHeaderElement } from '../utils/createFileHeaderElement';
import { createNoNewlineElement } from '../utils/createNoNewlineElement';
import { createPreElement } from '../utils/createPreElement';
import { createSeparator } from '../utils/createSeparator';
+import {
+ applySessionChangedLines,
+ applySessionEditWindow,
+ findChangedLineWindow,
+ normalizeEditorLines,
+ remapExpandedHunksForRegionChange,
+ type SessionRegionChange,
+} from '../utils/editSessionHunks';
import { getFiletypeFromFileName } from '../utils/getFiletypeFromFileName';
import { getHighlighterOptions } from '../utils/getHighlighterOptions';
import { getHunkSeparatorSlotName } from '../utils/getHunkSeparatorSlotName';
@@ -75,8 +83,11 @@ import { iterateOverDiff } from '../utils/iterateOverDiff';
import { renderDiffWithHighlighter } from '../utils/renderDiffWithHighlighter';
import { shouldUseTokenTransformer } from '../utils/shouldUseTokenTransformer';
import {
+ preserveTrailingEditorBlankLine,
recomputeDiffHunksForEdit,
recomputeEmptyDocumentDiff,
+ recomputeTopAlignedAdditionDiff,
+ shouldTopAlignAdditionRecompute,
updateDiffHunks,
} from '../utils/updateDiffHunks';
import { getTrailingContextRangeSize } from '../utils/virtualDiffLayout';
@@ -229,6 +240,15 @@ export class DiffHunksRenderer {
private computedLang: SupportedLanguages = 'text';
private renderCache: RenderedDiffASTCache | undefined;
+ // Edit-session state: while active, hunk updates go through the frozen
+ // region skeleton (editSessionHunks) instead of the full recompute.
+ private editSessionActive = false;
+ // Addition lines as of the last completed hunk-update pass, used by the
+ // prefix/suffix scan in applyDocumentChange. Line-count passes write dirty
+ // line text at stale indexes into `diff.additionLines` before the document
+ // rebuild lands, so the live array can never serve as the "before" side.
+ private editSessionLines: string[] | undefined;
+
constructor(
public options: DiffHunksRendererOptions = { theme: DEFAULT_THEMES },
private onRenderUpdate?: () => unknown,
@@ -255,6 +275,31 @@ export class DiffHunksRenderer {
this.additionAnnotations = {};
this.deletionAnnotations = {};
this.workerManager?.cleanUpTasks(this);
+ // Session hunks and the metadata dirty marker survive recycle in the
+ // shared FileDiffMetadata; the renderer-local state re-seeds on the next
+ // attach (beginEditSession).
+ this.endEditSession();
+ }
+
+ /**
+ * Enter edit-session mode: hunk updates preserve the current region
+ * skeleton instead of recomputing hunks. Called on every editor attach,
+ * including a re-attach after recycle, which losslessly re-seeds the pass
+ * snapshot because both hunk-update paths keep `diff.additionLines`
+ * current.
+ */
+ public beginEditSession(diff: FileDiffMetadata | undefined): void {
+ this.editSessionActive = true;
+ this.editSessionLines =
+ diff != null
+ ? normalizeEditorLines(diff.additionLines).slice()
+ : undefined;
+ }
+
+ /** Leave edit-session mode. The exit recompute is the host's concern. */
+ public endEditSession(): void {
+ this.editSessionActive = false;
+ this.editSessionLines = undefined;
}
public get diffCache(): FileDiffMetadata | undefined {
@@ -337,16 +382,23 @@ export class DiffHunksRenderer {
}
}
+ /**
+ * Returns true when a session-mode pass changed the region skeleton itself
+ * (a gap edit synthesized or merged regions), which changes the rendered
+ * row set without a line-count change — the host must escalate to a full
+ * re-render instead of its cheap refresh path.
+ */
public updateRenderCache(
dirtyLines: Map>,
- themeType: 'dark' | 'light'
- ): void {
+ themeType: 'dark' | 'light',
+ lineCountChangeInFlight = false
+ ): boolean {
if (this.renderCache == null) {
- return;
+ return false;
}
const { result, diff } = this.renderCache;
if (result == null) {
- return;
+ return false;
}
if (diff.isPartial) {
throw new Error('Could not update render cache for partial diff');
@@ -403,19 +455,51 @@ export class DiffHunksRenderer {
};
}
+ let regionsChanged = false;
if (changedAdditionLines.length > 0) {
- Object.assign(
- diff,
- updateDiffHunks(
+ if (this.editSessionActive && !diff.isPartial) {
+ // On a line-count pass the tokenizer emits shifted-but-unedited lines
+ // as dirty and the writes above land at stale indexes, so hunk work
+ // must wait for the authoritative applyDocumentChange in this same
+ // pass. Otherwise (including deferred background passes, which carry
+ // genuine changes and are never followed by applyDocumentChange) the
+ // explicit changed indexes are current.
+ if (!lineCountChangeInFlight) {
+ const changes = applySessionChangedLines(
+ diff,
+ changedAdditionLines,
+ this.options.parseDiffOptions
+ );
+ this.applyExpansionRemaps(changes);
+ regionsChanged = changes.length > 0;
+ this.editSessionLines = normalizeEditorLines(
+ diff.additionLines
+ ).slice();
+ }
+ } else {
+ Object.assign(
diff,
- changedAdditionLines,
- this.options.parseDiffOptions
- )
- );
+ updateDiffHunks(
+ diff,
+ changedAdditionLines,
+ this.options.parseDiffOptions
+ )
+ );
+ }
}
result.baseThemeType = themeType;
this.renderCache.isDirty = true;
+ return regionsChanged;
+ }
+
+ private applyExpansionRemaps(changes: SessionRegionChange[]): void {
+ for (const change of changes) {
+ this.expandedHunks = remapExpandedHunksForRegionChange(
+ this.expandedHunks,
+ change
+ );
+ }
}
// Normally triggered by the host when the document line count changes.
@@ -461,6 +545,9 @@ export class DiffHunksRenderer {
recomputeEmptyDocumentDiff(diff, this.options.parseDiffOptions)
);
additionHastLines[0] = createPlainAdditionLineElement(0, textDocument);
+ this.markEditSessionPass(diff);
+ } else if (this.editSessionActive) {
+ this.applySessionDocumentChange(diff);
} else {
Object.assign(
diff,
@@ -472,6 +559,52 @@ export class DiffHunksRenderer {
this.renderCache.isDirty = true;
}
+ // Session-mode counterpart of the line-count recompute: locate the changed
+ // window with a prefix/suffix scan of the pass snapshot against the rebuilt
+ // document lines, then update only the region skeleton it covers.
+ private applySessionDocumentChange(diff: FileDiffMetadata): void {
+ const { parseDiffOptions } = this.options;
+ const rawLines = diff.additionLines;
+ if (shouldTopAlignAdditionRecompute(diff, rawLines)) {
+ Object.assign(
+ diff,
+ recomputeTopAlignedAdditionDiff(diff, rawLines, parseDiffOptions)
+ );
+ this.markEditSessionPass(diff);
+ return;
+ }
+ const previousLines = this.editSessionLines;
+ const nextLines = normalizeEditorLines(rawLines);
+ if (previousLines == null) {
+ // No pass snapshot (the editor attached without diff data): fall back
+ // to the full recompute for this pass and start tracking from it.
+ Object.assign(diff, recomputeDiffHunksForEdit(diff, parseDiffOptions));
+ this.markEditSessionPass(diff);
+ return;
+ }
+ diff.additionLines = nextLines;
+ const window = findChangedLineWindow(previousLines, nextLines);
+ if (window != null) {
+ const change = applySessionEditWindow(diff, window, parseDiffOptions);
+ if (change != null) {
+ this.applyExpansionRemaps([change]);
+ }
+ }
+ preserveTrailingEditorBlankLine(diff, rawLines);
+ this.editSessionLines = nextLines.slice();
+ }
+
+ // Records a session pass that replaced hunks wholesale (empty-document /
+ // top-aligned shims or a snapshotless fallback): the skeleton collapsed to
+ // the recompute result, and the pass snapshot restarts from it.
+ private markEditSessionPass(diff: FileDiffMetadata): void {
+ if (!this.editSessionActive) {
+ return;
+ }
+ diff.editSessionDirty = true;
+ this.editSessionLines = normalizeEditorLines(diff.additionLines).slice();
+ }
+
protected getUnifiedLineDecoration({
lineType,
}: UnifiedLineDecorationProps): LineDecoration {
diff --git a/packages/diffs/src/types.ts b/packages/diffs/src/types.ts
index f6415109c..7a77b1884 100644
--- a/packages/diffs/src/types.ts
+++ b/packages/diffs/src/types.ts
@@ -348,6 +348,16 @@ export interface FileDiffMetadata {
* the patch.
*/
additionLines: string[];
+ /**
+ * Set while an attached editor's session-scoped hunk updates have reshaped
+ * `hunks` away from a plain recompute (regions stay frozen during an edit
+ * session). Consumed by the session-exit recompute, which restores
+ * recompute-shaped hunks and clears the flag. It lives on the metadata so
+ * it survives instance recycling and pooling.
+ *
+ * @internal
+ */
+ editSessionDirty?: boolean;
/**
* This unique key is only used for Worker Pools to avoid subsequent requests
* to highlight if we've already highlighted the diff. Please note that if
@@ -1008,16 +1018,30 @@ export interface DiffsEditableComponent<
* Return the scroll container element.
*/
getScrollContainer?: () => HTMLElement | undefined;
- attachEditor: (editor: DiffsEditor) => () => void;
+ /**
+ * Attach an editor to this component. The returned detach closure receives
+ * `recycle: true` when the editor is only being released by a virtualized
+ * unmount (the session continues on remount) and no argument/false on a
+ * genuine session end.
+ */
+ attachEditor: (
+ editor: DiffsEditor
+ ) => (recycle?: boolean) => void;
applyDocumentChange: (
textDocument: DiffsTextDocument,
newLineAnnotations?: DiffLineAnnotation[],
shouldUpdateBuffer?: boolean
) => void;
+ /**
+ * `lineCountChangeInFlight` is true only during an edit pass whose line
+ * count changed, where an authoritative `applyDocumentChange` follows in
+ * the same pass; deferred background-tokenize passes always pass false.
+ */
updateRenderCache: (
lines: Map>,
themeType: 'dark' | 'light',
- shouldRefreshView: boolean
+ shouldRefreshView: boolean,
+ lineCountChangeInFlight?: boolean
) => void;
}
diff --git a/packages/diffs/src/utils/editSessionHunks.ts b/packages/diffs/src/utils/editSessionHunks.ts
new file mode 100644
index 000000000..19d087aae
--- /dev/null
+++ b/packages/diffs/src/utils/editSessionHunks.ts
@@ -0,0 +1,505 @@
+import type { CreatePatchOptionsNonabortable } from 'diff';
+
+import type {
+ ChangeContent,
+ ContextContent,
+ FileDiffMetadata,
+ Hunk,
+ HunkExpansionRegion,
+} from '../types';
+import { parseDiffFromFile } from './parseDiffFromFile';
+import {
+ offsetHunkContent,
+ recomputeDiffHunksForEdit,
+ recomputeDiffRenderLineCounts,
+ recomputeHunkRenderLineCounts,
+ syncHunkNoEOFCRFromFullFile,
+} from './updateDiffHunks';
+
+// While an editor is attached to a FileDiff, hunks are treated as a frozen
+// "region skeleton": each hunk is one region spanning its full range, regions
+// never merge/split/drop on their own, and each edit re-diffs only the region
+// it lands in. A region whose re-diff comes back empty persists as a
+// context-only hunk so its rows keep rendering. The functions here implement
+// that per-edit region math; the real hunk recompute runs once on genuine
+// session exit (finishEditSessionForDiff).
+
+/**
+ * The addition-line span an edit changed, from a common prefix/suffix scan.
+ * `start` is the first changed line (identical in pre/post-edit coordinates),
+ * `prevEnd`/`nextEnd` are the exclusive ends in pre/post-edit lines.
+ */
+export interface ChangedLineWindow {
+ start: number;
+ prevEnd: number;
+ nextEnd: number;
+}
+
+/** A structural change to the region skeleton (rendered row set changed). */
+export type SessionRegionChange =
+ | {
+ type: 'merge';
+ firstIndex: number;
+ lastIndex: number;
+ previousHunkCount: number;
+ }
+ | { type: 'insert'; index: number; previousHunkCount: number };
+
+interface RegionBounds {
+ additionStart: number;
+ additionEnd: number;
+ deletionStart: number;
+ deletionEnd: number;
+}
+
+/**
+ * Drops the editor document's phantom trailing empty line (a document ending
+ * in a newline exposes one extra empty line the parsed diff never contains)
+ * so session line arrays compare like parse-derived ones.
+ */
+export function normalizeEditorLines(lines: string[]): string[] {
+ if (lines.length > 1 && lines[lines.length - 1] === '') {
+ return lines.slice(0, -1);
+ }
+ return lines;
+}
+
+/**
+ * Common prefix/suffix scan between the last completed pass's lines and the
+ * rebuilt document lines. Returns undefined when nothing changed.
+ */
+export function findChangedLineWindow(
+ previousLines: string[],
+ nextLines: string[]
+): ChangedLineWindow | undefined {
+ const maxStart = Math.min(previousLines.length, nextLines.length);
+ let start = 0;
+ while (start < maxStart && previousLines[start] === nextLines[start]) {
+ start++;
+ }
+ let prevEnd = previousLines.length;
+ let nextEnd = nextLines.length;
+ while (
+ prevEnd > start &&
+ nextEnd > start &&
+ previousLines[prevEnd - 1] === nextLines[nextEnd - 1]
+ ) {
+ prevEnd--;
+ nextEnd--;
+ }
+ if (start === prevEnd && start === nextEnd) {
+ return undefined;
+ }
+ return { start, prevEnd, nextEnd };
+}
+
+/**
+ * Applies one contiguous changed window to the session skeleton: regions the
+ * window touches merge/grow to cover it (absorbing unchanged gap lines in
+ * paired old/new correspondence), a window wholly inside a gap synthesizes a
+ * new region, and regions after the window shift by the line delta. The
+ * covered region is re-diffed in place. Returns the structural change when
+ * the region set itself changed shape (merge/growth/synthesis).
+ */
+export function applySessionEditWindow(
+ diff: FileDiffMetadata,
+ window: ChangedLineWindow,
+ parseDiffOptions?: CreatePatchOptionsNonabortable
+): SessionRegionChange | undefined {
+ const { hunks } = diff;
+ const delta = window.nextEnd - window.prevEnd;
+ diff.editSessionDirty = true;
+
+ if (hunks.length === 0) {
+ const bounds: RegionBounds = {
+ additionStart: 0,
+ additionEnd: diff.additionLines.length,
+ deletionStart: 0,
+ deletionEnd: diff.deletionLines.length,
+ };
+ hunks.push(rediffRegion(diff, bounds, parseDiffOptions));
+ finalizeSessionHunks(diff, 0);
+ return { type: 'insert', index: 0, previousHunkCount: 0 };
+ }
+
+ // Locate the span of regions the window touches, treating windows adjacent
+ // to a region edge as touching so boundary edits grow the region instead of
+ // synthesizing a sibling next to it.
+ let firstIndex = -1;
+ let lastIndex = -1;
+ for (let index = 0; index < hunks.length; index++) {
+ const hunk = hunks[index];
+ const regionStart = hunk.additionLineIndex;
+ const regionEnd = regionStart + hunk.additionCount;
+ if (regionStart > window.prevEnd) {
+ break;
+ }
+ if (window.start <= regionEnd) {
+ if (firstIndex === -1) {
+ firstIndex = index;
+ }
+ lastIndex = index;
+ }
+ }
+
+ if (firstIndex === -1) {
+ return synthesizeRegion(diff, window, delta, parseDiffOptions);
+ }
+
+ const firstHunk = hunks[firstIndex];
+ const lastHunk = hunks[lastIndex];
+ const lastHunkEnd = lastHunk.additionLineIndex + lastHunk.additionCount;
+ const additionStart = Math.min(firstHunk.additionLineIndex, window.start);
+ const additionEndBefore = Math.max(lastHunkEnd, window.prevEnd);
+ // Gap lines are 1:1 paired with old-side lines, so growing a region into a
+ // gap absorbs the same number of deletion lines from that gap.
+ const bounds: RegionBounds = {
+ additionStart,
+ additionEnd: additionEndBefore + delta,
+ deletionStart:
+ firstHunk.deletionLineIndex -
+ (firstHunk.additionLineIndex - additionStart),
+ deletionEnd:
+ lastHunk.deletionLineIndex +
+ lastHunk.deletionCount +
+ (additionEndBefore - lastHunkEnd),
+ };
+ const regionHunk = rediffRegion(diff, bounds, parseDiffOptions);
+ if (delta !== 0) {
+ for (let index = lastIndex + 1; index < hunks.length; index++) {
+ shiftHunkAdditionCoords(hunks[index], delta);
+ }
+ }
+ const previousHunkCount = hunks.length;
+ hunks.splice(firstIndex, lastIndex - firstIndex + 1, regionHunk);
+ finalizeSessionHunks(diff, firstIndex);
+
+ const regionGrew =
+ additionStart < firstHunk.additionLineIndex ||
+ additionEndBefore > lastHunkEnd;
+ if (firstIndex === lastIndex && !regionGrew) {
+ return undefined;
+ }
+ return { type: 'merge', firstIndex, lastIndex, previousHunkCount };
+}
+
+/**
+ * Applies a set of changed addition-line indexes from a pass with no line
+ * count change: lines inside a region re-diff that region in place; lines in
+ * a gap synthesize a region per gap (one window spanning that gap's changed
+ * lines). Returns the structural changes, if any, for escalation/remapping.
+ */
+export function applySessionChangedLines(
+ diff: FileDiffMetadata,
+ changedAdditionLineIndexes: Iterable,
+ parseDiffOptions?: CreatePatchOptionsNonabortable
+): SessionRegionChange[] {
+ const lines = Array.from(new Set(changedAdditionLineIndexes))
+ .filter((line) => line >= 0 && line < diff.additionLines.length)
+ .sort((a, b) => a - b);
+ if (lines.length === 0) {
+ return [];
+ }
+
+ const { hunks } = diff;
+ const regionIndexes = new Set();
+ // Changed lines outside every region, grouped per gap (keyed by the index
+ // of the region that follows the gap) into one [start, end) window each.
+ const gapWindows: Array<[start: number, end: number]> = [];
+ let hunkIndex = 0;
+ let currentGapKey = -1;
+ for (const line of lines) {
+ while (
+ hunkIndex < hunks.length &&
+ line >=
+ hunks[hunkIndex].additionLineIndex + hunks[hunkIndex].additionCount
+ ) {
+ hunkIndex++;
+ }
+ const hunk = hunks[hunkIndex];
+ if (hunk != null && line >= hunk.additionLineIndex) {
+ regionIndexes.add(hunkIndex);
+ continue;
+ }
+ const lastWindow = gapWindows[gapWindows.length - 1];
+ if (currentGapKey === hunkIndex && lastWindow != null) {
+ lastWindow[1] = line + 1;
+ } else {
+ currentGapKey = hunkIndex;
+ gapWindows.push([line, line + 1]);
+ }
+ }
+
+ // In-place region re-diffs first, while region indexes are still valid.
+ for (const index of regionIndexes) {
+ const hunk = hunks[index];
+ const bounds: RegionBounds = {
+ additionStart: hunk.additionLineIndex,
+ additionEnd: hunk.additionLineIndex + hunk.additionCount,
+ deletionStart: hunk.deletionLineIndex,
+ deletionEnd: hunk.deletionLineIndex + hunk.deletionCount,
+ };
+ hunks[index] = rediffRegion(diff, bounds, parseDiffOptions);
+ syncHunkNoEOFCRFromFullFile(diff, index);
+ diff.editSessionDirty = true;
+ }
+ if (regionIndexes.size > 0) {
+ recomputeDiffRenderLineCounts(diff);
+ }
+
+ // Gap windows in descending order so earlier windows keep valid coordinates
+ // while later synthesis inserts hunks behind them.
+ const changes: SessionRegionChange[] = [];
+ for (let index = gapWindows.length - 1; index >= 0; index--) {
+ const [start, end] = gapWindows[index];
+ const change = applySessionEditWindow(
+ diff,
+ { start, prevEnd: end, nextEnd: end },
+ parseDiffOptions
+ );
+ if (change != null) {
+ changes.push(change);
+ }
+ }
+ return changes;
+}
+
+/**
+ * Rebuilds hunk-gap expansion keys after a structural region change so
+ * expansion state stays anchored to the same gaps. Merged-away gap keys drop;
+ * a synthesized region splits its gap's expansion across the two new gaps.
+ * The trailing pseudo-key (old hunk count) follows the same shift.
+ */
+export function remapExpandedHunksForRegionChange(
+ expandedHunks: Map,
+ change: SessionRegionChange
+): Map {
+ const remapped = new Map();
+ if (change.type === 'merge') {
+ const removed = change.lastIndex - change.firstIndex;
+ for (const [key, region] of expandedHunks) {
+ if (key <= change.firstIndex) {
+ remapped.set(key, region);
+ } else if (key > change.lastIndex) {
+ remapped.set(key - removed, region);
+ }
+ // Keys inside (firstIndex, lastIndex] were gaps the merge absorbed.
+ }
+ return remapped;
+ }
+ for (const [key, region] of expandedHunks) {
+ if (key < change.index) {
+ remapped.set(key, region);
+ } else if (key > change.index) {
+ remapped.set(key + 1, region);
+ } else {
+ // The gap this key described was split by the new region: keep its
+ // start-anchored expansion on the first half and its end-anchored
+ // expansion on the second.
+ if (region.fromStart > 0) {
+ remapped.set(key, { fromStart: region.fromStart, fromEnd: 0 });
+ }
+ if (region.fromEnd > 0) {
+ remapped.set(key + 1, { fromStart: 0, fromEnd: region.fromEnd });
+ }
+ }
+ }
+ return remapped;
+}
+
+/**
+ * Genuine session exit: when session passes reshaped the hunks, run the real
+ * full recompute so exit state matches a non-session edit pipeline, and clear
+ * the marker. Returns true when a recompute ran.
+ */
+export function finishEditSessionForDiff(
+ diff: FileDiffMetadata,
+ parseDiffOptions?: CreatePatchOptionsNonabortable
+): boolean {
+ if (diff.editSessionDirty !== true) {
+ return false;
+ }
+ diff.editSessionDirty = undefined;
+ Object.assign(diff, recomputeDiffHunksForEdit(diff, parseDiffOptions));
+ return true;
+}
+
+// Synthesizes a region for a window that touches no existing region. When the
+// window is empty on either side (pure insert/delete inside a gap), one
+// adjacent context line is absorbed so the re-diff anchors on real content.
+function synthesizeRegion(
+ diff: FileDiffMetadata,
+ window: ChangedLineWindow,
+ delta: number,
+ parseDiffOptions?: CreatePatchOptionsNonabortable
+): SessionRegionChange {
+ const { hunks } = diff;
+ let insertIndex = 0;
+ while (
+ insertIndex < hunks.length &&
+ hunks[insertIndex].additionLineIndex < window.start
+ ) {
+ insertIndex++;
+ }
+
+ // Constant pairing offset between old and new line indexes within this gap.
+ const pairOffset =
+ insertIndex < hunks.length
+ ? hunks[insertIndex].deletionLineIndex -
+ hunks[insertIndex].additionLineIndex
+ : hunks[insertIndex - 1].deletionLineIndex +
+ hunks[insertIndex - 1].deletionCount -
+ (hunks[insertIndex - 1].additionLineIndex +
+ hunks[insertIndex - 1].additionCount);
+
+ let { start, prevEnd, nextEnd } = window;
+ if (start === prevEnd || start === nextEnd) {
+ if (start > 0) {
+ start--;
+ } else if (nextEnd < diff.additionLines.length) {
+ prevEnd++;
+ nextEnd++;
+ }
+ }
+
+ const bounds: RegionBounds = {
+ additionStart: start,
+ additionEnd: nextEnd,
+ deletionStart: start + pairOffset,
+ deletionEnd: prevEnd + pairOffset,
+ };
+ const regionHunk = rediffRegion(diff, bounds, parseDiffOptions);
+ if (delta !== 0) {
+ for (let index = insertIndex; index < hunks.length; index++) {
+ shiftHunkAdditionCoords(hunks[index], delta);
+ }
+ }
+ const previousHunkCount = hunks.length;
+ hunks.splice(insertIndex, 0, regionHunk);
+ finalizeSessionHunks(diff, insertIndex);
+ return { type: 'insert', index: insertIndex, previousHunkCount };
+}
+
+// Re-diffs a region's old/new slices with zero context, producing one Hunk
+// spanning the region's full range. Zero result hunks yield a context-only
+// hunk (region persists while the session is active); several result hunks
+// merge into one hunkContent with the identical stretches between them
+// re-expressed as context blocks.
+function rediffRegion(
+ diff: FileDiffMetadata,
+ bounds: RegionBounds,
+ parseDiffOptions?: CreatePatchOptionsNonabortable
+): Hunk {
+ const deletionSlice = diff.deletionLines.slice(
+ bounds.deletionStart,
+ bounds.deletionEnd
+ );
+ const additionSlice = diff.additionLines.slice(
+ bounds.additionStart,
+ bounds.additionEnd
+ );
+ const additionCount = additionSlice.length;
+ const deletionCount = deletionSlice.length;
+
+ const hunkContent: (ContextContent | ChangeContent)[] = [];
+ let additionChangedLines = 0;
+ let deletionChangedLines = 0;
+
+ const pushContext = (
+ lines: number,
+ additionLineIndex: number,
+ deletionLineIndex: number
+ ) => {
+ if (lines > 0) {
+ hunkContent.push({
+ type: 'context',
+ lines,
+ additionLineIndex,
+ deletionLineIndex,
+ });
+ }
+ };
+
+ if (deletionSlice.join('') === additionSlice.join('')) {
+ pushContext(additionCount, bounds.additionStart, bounds.deletionStart);
+ } else {
+ const reparsed = parseDiffFromFile(
+ {
+ name: diff.prevName ?? diff.name,
+ contents: deletionSlice.join(''),
+ },
+ {
+ name: diff.name,
+ contents: additionSlice.join(''),
+ lang: diff.lang,
+ },
+ { ...parseDiffOptions, context: 0 }
+ );
+ // Walk the zero-context hunks, re-deriving the unchanged stretches
+ // between them from the addition side (context is paired, so both sides
+ // advance by the same amount).
+ let coveredAdditions = 0;
+ let coveredDeletions = 0;
+ for (const parsedHunk of reparsed.hunks) {
+ const contextLines = parsedHunk.additionLineIndex - coveredAdditions;
+ pushContext(
+ contextLines,
+ bounds.additionStart + coveredAdditions,
+ bounds.deletionStart + coveredDeletions
+ );
+ coveredAdditions += contextLines;
+ coveredDeletions += contextLines;
+ for (const content of parsedHunk.hunkContent) {
+ // Parsed content indexes are relative to the slice; offset them into
+ // full-file coordinates.
+ hunkContent.push(
+ offsetHunkContent(content, bounds.additionStart, bounds.deletionStart)
+ );
+ }
+ additionChangedLines += parsedHunk.additionLines;
+ deletionChangedLines += parsedHunk.deletionLines;
+ coveredAdditions += parsedHunk.additionCount;
+ coveredDeletions += parsedHunk.deletionCount;
+ }
+ pushContext(
+ additionCount - coveredAdditions,
+ bounds.additionStart + coveredAdditions,
+ bounds.deletionStart + coveredDeletions
+ );
+ }
+
+ const hunk: Hunk = {
+ collapsedBefore: 0,
+ additionStart: bounds.additionStart + 1,
+ additionCount,
+ additionLines: additionChangedLines,
+ additionLineIndex: bounds.additionStart,
+ deletionStart: bounds.deletionStart + 1,
+ deletionCount,
+ deletionLines: deletionChangedLines,
+ deletionLineIndex: bounds.deletionStart,
+ hunkContent,
+ hunkSpecs: `@@ -${bounds.deletionStart + 1},${deletionCount} +${bounds.additionStart + 1},${additionCount} @@`,
+ splitLineStart: 0,
+ splitLineCount: 0,
+ unifiedLineStart: 0,
+ unifiedLineCount: 0,
+ noEOFCRAdditions: false,
+ noEOFCRDeletions: false,
+ };
+ recomputeHunkRenderLineCounts(hunk);
+ return hunk;
+}
+
+function shiftHunkAdditionCoords(hunk: Hunk, delta: number): void {
+ hunk.additionLineIndex += delta;
+ hunk.additionStart += delta;
+ for (const content of hunk.hunkContent) {
+ content.additionLineIndex += delta;
+ }
+}
+
+function finalizeSessionHunks(diff: FileDiffMetadata, hunkIndex: number): void {
+ recomputeDiffRenderLineCounts(diff);
+ syncHunkNoEOFCRFromFullFile(diff, hunkIndex);
+}
diff --git a/packages/diffs/src/utils/updateDiffHunks.ts b/packages/diffs/src/utils/updateDiffHunks.ts
index c4717ae9c..d001fc130 100644
--- a/packages/diffs/src/utils/updateDiffHunks.ts
+++ b/packages/diffs/src/utils/updateDiffHunks.ts
@@ -156,12 +156,7 @@ export function recomputeDiffHunksForEdit(
}
const additionLines = diff.additionLines;
const recomputed = recomputeDiffHunks(diff, parseDiffOptions);
- if (
- additionLines.length > recomputed.additionLines.length &&
- hasTrailingEditorBlankLine(additionLines)
- ) {
- preserveTrailingEditorBlankLine(recomputed, additionLines);
- }
+ preserveTrailingEditorBlankLine(recomputed, additionLines);
return recomputed;
}
@@ -169,10 +164,19 @@ function hasTrailingEditorBlankLine(additionLines: string[]): boolean {
return additionLines.length > 1 && additionLines.at(-1) === '';
}
-function preserveTrailingEditorBlankLine(
- recomputed: FullDiffHunkUpdate,
+// Re-adds the editor's phantom trailing empty line (a document ending in a
+// newline exposes one extra empty line) as an addition row when the last
+// hunk's final change block can absorb it, so the caret keeps a rendered row.
+export function preserveTrailingEditorBlankLine(
+ recomputed: Pick<
+ FullDiffHunkUpdate,
+ 'hunks' | 'additionLines' | 'splitLineCount' | 'unifiedLineCount'
+ >,
additionLines: string[]
): void {
+ if (!hasTrailingEditorBlankLine(additionLines)) {
+ return;
+ }
const extraLineCount = additionLines.length - recomputed.additionLines.length;
if (extraLineCount <= 0) {
return;
@@ -366,7 +370,7 @@ function reparseHunkRegion(
return true;
}
-function syncHunkNoEOFCRFromFullFile(
+export function syncHunkNoEOFCRFromFullFile(
diff: FileDiffMetadata,
hunkIndex: number
): void {
@@ -417,7 +421,7 @@ function applyReparsedHunk(target: Hunk, parsed: Hunk): void {
recomputeHunkRenderLineCounts(target);
}
-function offsetHunkContent(
+export function offsetHunkContent(
content: HunkContent,
additionOffset: number,
deletionOffset: number
@@ -429,7 +433,7 @@ function offsetHunkContent(
};
}
-function recomputeHunkRenderLineCounts(hunk: Hunk): void {
+export function recomputeHunkRenderLineCounts(hunk: Hunk): void {
let splitLineCount = 0;
let unifiedLineCount = 0;
@@ -447,8 +451,11 @@ function recomputeHunkRenderLineCounts(hunk: Hunk): void {
hunk.unifiedLineCount = unifiedLineCount;
}
-function recomputeDiffRenderLineCounts(
- diff: HunkMetadataUpdate & Pick
+export function recomputeDiffRenderLineCounts(
+ diff: Pick<
+ FileDiffMetadata,
+ 'hunks' | 'splitLineCount' | 'unifiedLineCount' | 'additionLines'
+ >
): void {
let splitTotal = 0;
let unifiedTotal = 0;
diff --git a/packages/diffs/test/CodeView.edit.test.ts b/packages/diffs/test/CodeView.edit.test.ts
index 31f2c1ae6..bcbc0abb8 100644
--- a/packages/diffs/test/CodeView.edit.test.ts
+++ b/packages/diffs/test/CodeView.edit.test.ts
@@ -41,7 +41,7 @@ function createEditorHarness() {
const createEditor = (
options: CodeViewCreateEditorOptions
): StubEditor => {
- let detach: (() => void) | undefined;
+ let detach: ((recycle?: boolean) => void) | undefined;
const editor: StubEditor = {
edits: [],
fullCleanUps: 0,
@@ -58,7 +58,9 @@ function createEditorHarness() {
} else {
editor.fullCleanUps += 1;
}
- detach?.();
+ // Like the real editor, the detach closure learns whether this is a
+ // virtualized recycle or a genuine session end.
+ detach?.(recycle);
detach = undefined;
},
__postponeBgTokenizeToNextFrame() {},
@@ -634,6 +636,159 @@ describe('CodeView item edit mode', () => {
}
});
+ describe('edit-session hunks across virtualization', () => {
+ // A diff item whose session state is observable: two separated changes
+ // (lines 10 and 40 of 60) produce two hunks, and reverting one of them
+ // mid-session leaves a context-only hunk that only the genuine-exit
+ // recompute may collapse away.
+ function makeSessionDiffItem(id: string): CodeViewItem {
+ const oldContents =
+ Array.from({ length: 60 }, (_, index) => `line ${index}`).join('\n') +
+ '\n';
+ const newContents = oldContents
+ .replace('line 10\n', 'line 10 changed\n')
+ .replace('line 40\n', 'line 40 changed\n');
+ return {
+ id,
+ type: 'diff',
+ fileDiff: parseDiffFromFile(
+ { name: `${id}.txt`, contents: oldContents, cacheKey: `${id}:old` },
+ { name: `${id}.txt`, contents: newContents, cacheKey: `${id}:new` }
+ ),
+ version: 0,
+ edit: true,
+ };
+ }
+
+ function revertLineTen(item: CodeViewItem, viewer: CodeView) {
+ const rendered = viewer
+ .getRenderedItems()
+ .find((entry) => entry.id === item.id);
+ expect(rendered).toBeDefined();
+ const tokens: HighlightedToken[] = [[0, '', 'line 10']];
+ rendered!.instance.updateRenderCache(
+ new Map([[10, tokens]]),
+ 'light',
+ false
+ );
+ }
+
+ test('session-shaped hunks survive a recycle and remount', async () => {
+ const { cleanup } = installDom();
+ const { editors, createEditor } = createEditorHarness();
+ const viewer = new CodeView({ createEditor });
+ const edited = makeSessionDiffItem('edited');
+ const items: CodeViewItem[] = [
+ edited,
+ ...Array.from({ length: 39 }, (_, index) =>
+ makeEditFileItem(`file-${index}`, false, 30)
+ ),
+ ];
+ try {
+ const root = createRoot();
+ viewer.setup(root);
+ await renderItems(viewer, items);
+ await wait(10);
+
+ // Revert one hunk mid-session: it persists as a context-only region.
+ revertLineTen(edited, viewer);
+ expect(edited.type === 'diff' && edited.fileDiff.hunks.length).toBe(2);
+ expect(
+ edited.type === 'diff' && edited.fileDiff.hunks[0].hunkContent[0].type
+ ).toBe('context');
+ expect(edited.type === 'diff' && edited.fileDiff.editSessionDirty).toBe(
+ true
+ );
+
+ // Scroll out (recycle): no exit recompute may run.
+ root.scrollTop = 30_000;
+ dispatchScroll(root);
+ viewer.render(true);
+ await wait(0);
+ expect(editors[0].recycleCleanUps).toBe(1);
+ expect(edited.type === 'diff' && edited.fileDiff.hunks.length).toBe(2);
+ expect(edited.type === 'diff' && edited.fileDiff.editSessionDirty).toBe(
+ true
+ );
+
+ // Scroll back: the same editor re-attaches and the session-shaped
+ // hunks are still in place.
+ root.scrollTop = 0;
+ dispatchScroll(root);
+ viewer.render(true);
+ await wait(0);
+ expect(editors[0].edits.length).toBe(2);
+ expect(edited.type === 'diff' && edited.fileDiff.hunks.length).toBe(2);
+ expect(
+ edited.type === 'diff' && edited.fileDiff.hunks[0].hunkContent[0].type
+ ).toBe('context');
+ } finally {
+ viewer.cleanUp();
+ await wait(0);
+ cleanup();
+ }
+ });
+
+ test('ending a session after its instance was released still recomputes', async () => {
+ const { cleanup } = installDom();
+ const { editors, createEditor } = createEditorHarness();
+ const viewer = new CodeView({ createEditor });
+ const edited = makeSessionDiffItem('edited');
+ const items: CodeViewItem[] = [
+ edited,
+ ...Array.from({ length: 39 }, (_, index) =>
+ makeEditFileItem(`file-${index}`, false, 30)
+ ),
+ ];
+ try {
+ const root = createRoot();
+ viewer.setup(root);
+ await renderItems(viewer, items);
+ await wait(10);
+
+ revertLineTen(edited, viewer);
+ expect(edited.type === 'diff' && edited.fileDiff.hunks.length).toBe(2);
+
+ // Scroll the edited item out: its instance recycles and the detach
+ // closure is consumed non-destructively.
+ root.scrollTop = 30_000;
+ dispatchScroll(root);
+ viewer.render(true);
+ await wait(0);
+ expect(editors[0].recycleCleanUps).toBe(1);
+
+ // Ending the session while released must still run the exit
+ // recompute: the reverted, context-only region collapses away.
+ expect(viewer.updateItem({ ...edited, edit: false, version: 1 })).toBe(
+ true
+ );
+ viewer.render(true);
+ await wait(0);
+ expect(editors[0].fullCleanUps).toBeGreaterThanOrEqual(1);
+ expect(edited.type === 'diff' && edited.fileDiff.hunks.length).toBe(1);
+ expect(
+ edited.type === 'diff' && edited.fileDiff.editSessionDirty
+ ).toBeUndefined();
+
+ // Scrolling back renders the recomputed diff without errors.
+ root.scrollTop = 0;
+ dispatchScroll(root);
+ viewer.render(true);
+ await wait(0);
+ const remounted = viewer
+ .getRenderedItems()
+ .find((entry) => entry.id === 'edited');
+ expect(
+ remounted?.element.shadowRoot?.querySelector('[data-error-wrapper]')
+ ).toBeNull();
+ } finally {
+ viewer.cleanUp();
+ await wait(0);
+ cleanup();
+ }
+ });
+ });
+
describe('onItemEditComplete', () => {
test('fires once with the final contents when edit is turned off', async () => {
const { cleanup } = installDom();
diff --git a/packages/diffs/test/DiffHunksRendererRecompute.test.ts b/packages/diffs/test/DiffHunksRendererRecompute.test.ts
index e6a5d6a05..39ac044b3 100644
--- a/packages/diffs/test/DiffHunksRendererRecompute.test.ts
+++ b/packages/diffs/test/DiffHunksRendererRecompute.test.ts
@@ -6,7 +6,9 @@ import {
parseDiffFromFile,
} from '../src';
import { TextDocument } from '../src/editor/textDocument';
-import type { DiffsTextDocument, HighlightedToken } from '../src/types';
+import type { FileDiffMetadata, HighlightedToken } from '../src/types';
+import type { DiffsTextDocument } from '../src/types';
+import { finishEditSessionForDiff } from '../src/utils/editSessionHunks';
import { iterateOverDiff } from '../src/utils/iterateOverDiff';
afterAll(async () => {
@@ -196,6 +198,167 @@ describe('DiffHunksRenderer content-edit recompute split', () => {
});
});
+// While an editor session is active, hunk updates preserve the current region
+// skeleton: regions never merge/split/drop on their own, a reverted region
+// persists as a context-only hunk, and the real recompute runs once on
+// genuine session end.
+describe('DiffHunksRenderer edit-session hunk updates', () => {
+ const SESSION_LINE_COUNT = 30;
+
+ function sessionLines(edits: Record = {}): string[] {
+ return Array.from(
+ { length: SESSION_LINE_COUNT },
+ (_, index) => (edits[index + 1] ?? `line ${index + 1}`) + '\n'
+ );
+ }
+
+ const SESSION_OLD = sessionLines();
+ const SESSION_NEW = sessionLines({ 3: 'changed 3', 20: 'changed 20' });
+
+ async function createSessionRenderer(): Promise<{
+ renderer: DiffHunksRenderer;
+ diff: FileDiffMetadata;
+ }> {
+ const renderer = new DiffHunksRenderer({
+ theme: 'github-light',
+ diffStyle: 'split',
+ });
+ const diff = parseDiffFromFile(
+ { name: 'session.ts', contents: SESSION_OLD.join(''), cacheKey: 's:o' },
+ { name: 'session.ts', contents: SESSION_NEW.join(''), cacheKey: 's:n' }
+ );
+ await renderer.asyncRender(diff);
+ renderer.renderDiff(diff);
+ renderer.beginEditSession(diff);
+ return { renderer, diff };
+ }
+
+ test('reverting a hunk keeps it as a context-only region', async () => {
+ const { renderer, diff } = await createSessionRenderer();
+ const boundsBefore = diff.hunks.map((hunk) => ({
+ additionLineIndex: hunk.additionLineIndex,
+ additionCount: hunk.additionCount,
+ }));
+
+ const regionsChanged = renderer.updateRenderCache(
+ makeDirtyLines([[2, 'line 3']]),
+ 'light'
+ );
+
+ expect(regionsChanged).toBe(false);
+ expect(diff.hunks).toHaveLength(2);
+ expect(diff.hunks[0].additionLineIndex).toBe(
+ boundsBefore[0].additionLineIndex
+ );
+ expect(diff.hunks[0].additionCount).toBe(boundsBefore[0].additionCount);
+ expect(diff.hunks[0].hunkContent).toHaveLength(1);
+ expect(diff.hunks[0].hunkContent[0].type).toBe('context');
+ expect(diff.editSessionDirty).toBe(true);
+ });
+
+ test('a gap edit synthesizes a region and reports the escalation', async () => {
+ const { renderer, diff } = await createSessionRenderer();
+
+ const regionsChanged = renderer.updateRenderCache(
+ makeDirtyLines([[11, 'replaced in gap']]),
+ 'light'
+ );
+
+ expect(regionsChanged).toBe(true);
+ expect(diff.hunks).toHaveLength(3);
+ expect(diff.hunks[1].additionLineIndex).toBe(11);
+ });
+
+ // The regression guard for the same-pass contamination trap: a line-count
+ // pass tokenizes every shifted line as dirty at post-edit indexes while
+ // diff.additionLines still holds pre-edit content. Region work must wait
+ // for applyDocumentChange, which scans against the pass snapshot.
+ test('an Enter keystroke does not disturb other regions', async () => {
+ const { renderer, diff } = await createSessionRenderer();
+ const secondRegionBefore = {
+ deletionLineIndex: diff.hunks[1].deletionLineIndex,
+ deletionCount: diff.hunks[1].deletionCount,
+ additionCount: diff.hunks[1].additionCount,
+ hunkContent: structuredClone(diff.hunks[1].hunkContent),
+ };
+
+ // Enter in the middle of "changed 3" (line index 2).
+ const postEditLines = [
+ ...SESSION_NEW.slice(0, 2),
+ 'chan\n',
+ 'ged 3\n',
+ ...SESSION_NEW.slice(3),
+ ];
+ // The tokenizer emits every line from the change to the document end as
+ // dirty, using post-edit indexes.
+ const denseDirty: Array<[number, string]> = [];
+ for (let line = 2; line < postEditLines.length; line++) {
+ denseDirty.push([line, postEditLines[line].replace('\n', '')]);
+ }
+ renderer.updateRenderCache(makeDirtyLines(denseDirty), 'light', true);
+ renderer.applyDocumentChange(makeTextDocument(postEditLines));
+
+ expect(diff.hunks).toHaveLength(2);
+ // The edited region grew by the inserted line...
+ expect(diff.hunks[0].additionCount).toBeGreaterThan(0);
+ // ...while the other region only shifted, keeping its shape and its
+ // old-side anchors.
+ expect(diff.hunks[1].deletionLineIndex).toBe(
+ secondRegionBefore.deletionLineIndex
+ );
+ expect(diff.hunks[1].deletionCount).toBe(secondRegionBefore.deletionCount);
+ expect(diff.hunks[1].additionCount).toBe(secondRegionBefore.additionCount);
+ expect(diff.additionLines.join('')).toBe(postEditLines.join(''));
+ });
+
+ test('emptying the document behaves like the non-session shim', async () => {
+ const { renderer, diff } = await createSessionRenderer();
+ renderer.applyDocumentChange(makeTextDocument(['']));
+
+ expect(diff.additionLines).toEqual(['']);
+ const result = renderer.renderDiff();
+ expect(result).toBeDefined();
+ if (result == null) return;
+ expect(renderer.renderFullHTML(result)).toContain('change-addition');
+ });
+
+ test('genuine session end recomputes; a zero-edit session does not', async () => {
+ const { renderer, diff } = await createSessionRenderer();
+ const untouchedHunks = diff.hunks;
+
+ // Zero-edit end leaves patch-derived hunks untouched.
+ expect(finishEditSessionForDiff(diff)).toBe(false);
+ expect(diff.hunks).toBe(untouchedHunks);
+
+ // Revert the first hunk mid-session, then end: the context-only region
+ // collapses away in the recompute.
+ renderer.updateRenderCache(makeDirtyLines([[2, 'line 3']]), 'light');
+ expect(diff.hunks[0].hunkContent[0].type).toBe('context');
+ expect(finishEditSessionForDiff(diff)).toBe(true);
+ expect(diff.hunks).toHaveLength(1);
+ expect(diff.hunks[0].hunkContent.some((c) => c.type === 'change')).toBe(
+ true
+ );
+ expect(diff.editSessionDirty).toBeUndefined();
+ });
+
+ test('a session-shaped diff renders through a bounded window', async () => {
+ const { renderer, diff } = await createSessionRenderer();
+ renderer.updateRenderCache(makeDirtyLines([[2, 'line 3']]), 'light');
+ expect(diff.hunks[0].hunkContent[0].type).toBe('context');
+
+ const result = renderer.renderDiff(diff, {
+ startingLine: 0,
+ totalLines: 10,
+ bufferBefore: 0,
+ bufferAfter: 0,
+ });
+ expect(result).toBeDefined();
+ if (result == null) return;
+ expect(result.rowCount).toBeGreaterThan(0);
+ });
+});
+
// Deleting every character empties the editor's document, whose text is "".
// splitFileContents("") is [], so a naive recompute drops the addition side to
// zero lines — but the editor always keeps one (empty) line, so the addition
diff --git a/packages/diffs/test/editSessionHunks.test.ts b/packages/diffs/test/editSessionHunks.test.ts
new file mode 100644
index 000000000..632c9cc7a
--- /dev/null
+++ b/packages/diffs/test/editSessionHunks.test.ts
@@ -0,0 +1,369 @@
+import { describe, expect, test } from 'bun:test';
+
+import type { FileDiffMetadata, HunkExpansionRegion } from '../src/types';
+import {
+ applySessionChangedLines,
+ applySessionEditWindow,
+ findChangedLineWindow,
+ finishEditSessionForDiff,
+ normalizeEditorLines,
+ remapExpandedHunksForRegionChange,
+} from '../src/utils/editSessionHunks';
+import { iterateOverDiff } from '../src/utils/iterateOverDiff';
+import { parseDiffFromFile } from '../src/utils/parseDiffFromFile';
+import { getTrailingContextRangeSize } from '../src/utils/virtualDiffLayout';
+
+// 30-line file with two separated changes -> two hunks with a collapsible
+// unchanged gap between them and trailing unchanged context after the second.
+function makeLines(
+ count: number,
+ edits: Record = {}
+): string[] {
+ return Array.from(
+ { length: count },
+ (_, index) => (edits[index + 1] ?? `l${index + 1}`) + '\n'
+ );
+}
+
+function makeDiff(
+ newEdits: Record = { 3: 'changed 3', 20: 'changed 20' }
+): FileDiffMetadata {
+ return parseDiffFromFile(
+ { name: 'a.ts', contents: makeLines(30).join('') },
+ { name: 'a.ts', contents: makeLines(30, newEdits).join('') }
+ );
+}
+
+function hunkBounds(diff: FileDiffMetadata) {
+ return diff.hunks.map((hunk) => ({
+ additionLineIndex: hunk.additionLineIndex,
+ additionCount: hunk.additionCount,
+ deletionLineIndex: hunk.deletionLineIndex,
+ deletionCount: hunk.deletionCount,
+ }));
+}
+
+function countRenderedRows(diff: FileDiffMetadata): number {
+ let rows = 0;
+ iterateOverDiff({
+ diff,
+ diffStyle: 'split',
+ expandedHunks: true,
+ callback: () => {
+ rows++;
+ },
+ });
+ return rows;
+}
+
+describe('normalizeEditorLines', () => {
+ test('drops only the phantom trailing empty line', () => {
+ expect(normalizeEditorLines(['a\n', 'b\n', ''])).toEqual(['a\n', 'b\n']);
+ expect(normalizeEditorLines(['a\n', 'b'])).toEqual(['a\n', 'b']);
+ expect(normalizeEditorLines([''])).toEqual(['']);
+ });
+});
+
+describe('findChangedLineWindow', () => {
+ test('locates a replaced line', () => {
+ expect(
+ findChangedLineWindow(['a\n', 'b\n', 'c\n'], ['a\n', 'x\n', 'c\n'])
+ ).toEqual({ start: 1, prevEnd: 2, nextEnd: 2 });
+ });
+
+ test('locates a pure insert', () => {
+ expect(
+ findChangedLineWindow(['a\n', 'c\n'], ['a\n', 'b\n', 'c\n'])
+ ).toEqual({ start: 1, prevEnd: 1, nextEnd: 2 });
+ });
+
+ test('returns undefined for identical lines', () => {
+ expect(findChangedLineWindow(['a\n'], ['a\n'])).toBeUndefined();
+ });
+});
+
+describe('applySessionEditWindow', () => {
+ test('re-diff inside a region keeps boundaries and reports no change', () => {
+ const diff = makeDiff();
+ const boundsBefore = hunkBounds(diff);
+ diff.additionLines[2] = 'changed again\n';
+
+ const change = applySessionEditWindow(diff, {
+ start: 2,
+ prevEnd: 3,
+ nextEnd: 3,
+ });
+
+ expect(change).toBeUndefined();
+ expect(hunkBounds(diff)).toEqual(boundsBefore);
+ expect(diff.editSessionDirty).toBe(true);
+ });
+
+ test('a reverted region persists as a context-only hunk', () => {
+ const diff = makeDiff();
+ const boundsBefore = hunkBounds(diff);
+ const rowsBefore = countRenderedRows(diff);
+ diff.additionLines[2] = 'l3\n';
+
+ const change = applySessionEditWindow(diff, {
+ start: 2,
+ prevEnd: 3,
+ nextEnd: 3,
+ });
+
+ expect(change).toBeUndefined();
+ expect(diff.hunks).toHaveLength(2);
+ expect(hunkBounds(diff)).toEqual(boundsBefore);
+ expect(diff.hunks[0].hunkContent).toEqual([
+ {
+ type: 'context',
+ lines: boundsBefore[0].additionCount,
+ additionLineIndex: boundsBefore[0].additionLineIndex,
+ deletionLineIndex: boundsBefore[0].deletionLineIndex,
+ },
+ ]);
+ expect(diff.hunks[0].additionLines).toBe(0);
+ // The context-only hunk still renders every one of its rows.
+ expect(countRenderedRows(diff)).toBe(rowsBefore);
+ });
+
+ test('an insert inside a region grows it and shifts later regions', () => {
+ const diff = makeDiff();
+ const boundsBefore = hunkBounds(diff);
+ diff.additionLines.splice(3, 0, 'inserted\n');
+
+ const change = applySessionEditWindow(diff, {
+ start: 3,
+ prevEnd: 3,
+ nextEnd: 4,
+ });
+
+ expect(change).toBeUndefined();
+ expect(diff.hunks).toHaveLength(2);
+ expect(diff.hunks[0].additionCount).toBe(boundsBefore[0].additionCount + 1);
+ expect(diff.hunks[0].deletionCount).toBe(boundsBefore[0].deletionCount);
+ expect(diff.hunks[1].additionLineIndex).toBe(
+ boundsBefore[1].additionLineIndex + 1
+ );
+ expect(diff.hunks[1].deletionLineIndex).toBe(
+ boundsBefore[1].deletionLineIndex
+ );
+ // Trailing unchanged context must stay symmetric between sides.
+ expect(() =>
+ getTrailingContextRangeSize({ fileDiff: diff, errorPrefix: 'test' })
+ ).not.toThrow();
+ });
+
+ test('an edit spanning the gap merges both regions and absorbs paired gap lines', () => {
+ const diff = makeDiff();
+ const boundsBefore = hunkBounds(diff);
+ diff.additionLines[5] = 'edited 6\n';
+ diff.additionLines[20] = 'edited 21\n';
+
+ const change = applySessionEditWindow(diff, {
+ start: 5,
+ prevEnd: 21,
+ nextEnd: 21,
+ });
+
+ expect(change).toEqual({
+ type: 'merge',
+ firstIndex: 0,
+ lastIndex: 1,
+ previousHunkCount: 2,
+ });
+ expect(diff.hunks).toHaveLength(1);
+ const merged = diff.hunks[0];
+ expect(merged.additionLineIndex).toBe(boundsBefore[0].additionLineIndex);
+ expect(merged.additionCount).toBe(
+ boundsBefore[1].additionLineIndex +
+ boundsBefore[1].additionCount -
+ boundsBefore[0].additionLineIndex
+ );
+ // Gap lines are absorbed in paired correspondence, so the deletion side
+ // spans the same range.
+ expect(merged.deletionLineIndex).toBe(boundsBefore[0].deletionLineIndex);
+ expect(merged.deletionCount).toBe(
+ boundsBefore[1].deletionLineIndex +
+ boundsBefore[1].deletionCount -
+ boundsBefore[0].deletionLineIndex
+ );
+ // The previously collapsed gap now renders as context inside the region.
+ expect(
+ merged.hunkContent.some((content) => content.type === 'context')
+ ).toBe(true);
+ });
+
+ test('a pure insert into a gap synthesizes a region anchored on a context line', () => {
+ const diff = makeDiff();
+ const boundsBefore = hunkBounds(diff);
+ diff.additionLines.splice(12, 0, 'new a\n', 'new b\n');
+
+ const change = applySessionEditWindow(diff, {
+ start: 12,
+ prevEnd: 12,
+ nextEnd: 14,
+ });
+
+ expect(change).toEqual({ type: 'insert', index: 1, previousHunkCount: 2 });
+ expect(diff.hunks).toHaveLength(3);
+ const synthesized = diff.hunks[1];
+ // One preceding context line was absorbed to anchor the re-diff.
+ expect(synthesized.additionLineIndex).toBe(11);
+ expect(synthesized.additionCount).toBe(3);
+ expect(synthesized.deletionLineIndex).toBe(11);
+ expect(synthesized.deletionCount).toBe(1);
+ expect(synthesized.additionLines).toBe(2);
+ expect(synthesized.deletionLines).toBe(0);
+ // The following region shifted by the insert, deletion side untouched.
+ expect(diff.hunks[2].additionLineIndex).toBe(
+ boundsBefore[1].additionLineIndex + 2
+ );
+ expect(diff.hunks[2].deletionLineIndex).toBe(
+ boundsBefore[1].deletionLineIndex
+ );
+ expect(() =>
+ getTrailingContextRangeSize({ fileDiff: diff, errorPrefix: 'test' })
+ ).not.toThrow();
+ });
+
+ test('reverting every region keeps one context-only hunk per region', () => {
+ const diff = makeDiff();
+ diff.additionLines[2] = 'l3\n';
+ applySessionEditWindow(diff, { start: 2, prevEnd: 3, nextEnd: 3 });
+ diff.additionLines[19] = 'l20\n';
+ applySessionEditWindow(diff, { start: 19, prevEnd: 20, nextEnd: 20 });
+
+ expect(diff.hunks).toHaveLength(2);
+ for (const hunk of diff.hunks) {
+ expect(hunk.hunkContent).toHaveLength(1);
+ expect(hunk.hunkContent[0].type).toBe('context');
+ }
+ });
+});
+
+describe('applySessionChangedLines', () => {
+ test('changed lines inside regions re-diff in place', () => {
+ const diff = makeDiff();
+ const boundsBefore = hunkBounds(diff);
+ diff.additionLines[2] = 'retyped 3\n';
+ diff.additionLines[19] = 'retyped 20\n';
+
+ const changes = applySessionChangedLines(diff, [2, 19]);
+
+ expect(changes).toEqual([]);
+ expect(hunkBounds(diff)).toEqual(boundsBefore);
+ });
+
+ test('a changed line inside a gap synthesizes a region', () => {
+ const diff = makeDiff();
+ diff.additionLines[11] = 'replaced in gap\n';
+
+ const changes = applySessionChangedLines(diff, [11]);
+
+ expect(changes).toEqual([
+ { type: 'insert', index: 1, previousHunkCount: 2 },
+ ]);
+ expect(diff.hunks).toHaveLength(3);
+ expect(diff.hunks[1].additionLineIndex).toBe(11);
+ expect(diff.hunks[1].additionCount).toBe(1);
+ expect(diff.hunks[1].deletionCount).toBe(1);
+ });
+
+ test('several changed lines in one gap synthesize a single region', () => {
+ const diff = makeDiff();
+ diff.additionLines[10] = 'replaced a\n';
+ diff.additionLines[13] = 'replaced b\n';
+
+ const changes = applySessionChangedLines(diff, [10, 13]);
+
+ expect(changes).toHaveLength(1);
+ expect(diff.hunks).toHaveLength(3);
+ expect(diff.hunks[1].additionLineIndex).toBe(10);
+ expect(diff.hunks[1].additionCount).toBe(4);
+ // Unchanged lines between the two edits become context inside the region.
+ expect(
+ diff.hunks[1].hunkContent.some((content) => content.type === 'context')
+ ).toBe(true);
+ });
+});
+
+describe('remapExpandedHunksForRegionChange', () => {
+ const region = (fromStart: number, fromEnd: number): HunkExpansionRegion => ({
+ fromStart,
+ fromEnd,
+ });
+
+ test('merge drops absorbed gap keys and shifts later ones', () => {
+ const map = new Map([
+ [0, region(2, 0)],
+ [1, region(3, 1)],
+ [2, region(0, 4)],
+ [3, region(5, 5)],
+ ]);
+ const remapped = remapExpandedHunksForRegionChange(map, {
+ type: 'merge',
+ firstIndex: 1,
+ lastIndex: 2,
+ previousHunkCount: 3,
+ });
+ expect(remapped).toEqual(
+ new Map([
+ [0, region(2, 0)],
+ [1, region(3, 1)],
+ [2, region(5, 5)],
+ ])
+ );
+ });
+
+ test('insert splits the affected gap key across the two new gaps', () => {
+ const map = new Map([
+ [0, region(1, 0)],
+ [1, region(5, 3)],
+ [2, region(0, 2)],
+ ]);
+ const remapped = remapExpandedHunksForRegionChange(map, {
+ type: 'insert',
+ index: 1,
+ previousHunkCount: 2,
+ });
+ expect(remapped).toEqual(
+ new Map([
+ [0, region(1, 0)],
+ [1, region(5, 0)],
+ [2, region(0, 3)],
+ [3, region(0, 2)],
+ ])
+ );
+ });
+});
+
+describe('finishEditSessionForDiff', () => {
+ test('a dirty session recomputes to the plain edit pipeline result', () => {
+ const diff = makeDiff();
+ // Revert the first hunk, then edit inside the gap: session-shaped hunks.
+ diff.additionLines[2] = 'l3\n';
+ applySessionEditWindow(diff, { start: 2, prevEnd: 3, nextEnd: 3 });
+ diff.additionLines[11] = 'gap edit\n';
+ applySessionChangedLines(diff, [11]);
+ expect(diff.hunks).toHaveLength(3);
+
+ expect(finishEditSessionForDiff(diff)).toBe(true);
+ expect(diff.editSessionDirty).toBeUndefined();
+
+ const expected = parseDiffFromFile(
+ { name: 'a.ts', contents: diff.deletionLines.join('') },
+ { name: 'a.ts', contents: diff.additionLines.join('') }
+ );
+ expect(diff.hunks).toEqual(expected.hunks);
+ expect(diff.splitLineCount).toBe(expected.splitLineCount);
+ expect(diff.unifiedLineCount).toBe(expected.unifiedLineCount);
+ });
+
+ test('a zero-edit session leaves patch-derived hunks untouched', () => {
+ const diff = makeDiff();
+ const hunksBefore = diff.hunks;
+ expect(finishEditSessionForDiff(diff)).toBe(false);
+ expect(diff.hunks).toBe(hunksBefore);
+ });
+});
From f0a215b18f0bf7c6e100175cf4c441a3245002fd Mon Sep 17 00:00:00 2001
From: Amadeus Demarzi
Date: Fri, 10 Jul 2026 12:22:29 -0700
Subject: [PATCH 02/11] feat(diffs): Make the editor DOM layer collapse-aware
Prepare the editor for diffs whose collapsed unchanged regions stay
collapsed during editing: rendered rows can then be a sparse subset of
document lines, which several editor code paths assumed were dense.
With expansion still forced during editing, behavior is unchanged.
- New visibility oracle: `isAdditionLineRenderable` in
virtualDiffLayout derives from hunks plus expansion state (the same
inputs as iterateOverDiff), exposed to the editor as the optional
`DiffsEditableComponent.isLineRenderable`. FileDiff implements it;
plain files leave it unset and every line stays renderable.
- `#rerender` filters dirty lines through the oracle, scans the row
patch loop from child index 0 (a sparse row can sit at a smaller
index than its dense position, and overshooting appended duplicate
rows), and only creates new rows beyond the last rendered row.
- `#applyChange` only widens the render window toward a caret whose
line can actually gain a row; `#scrollToLine` refuses to park its
retry state on a collapsed line, which previously became a
non-terminating scroll loop against separator geometry.
- `__syncRenderView` stamps hunk separator rows contenteditable=false
in both diff styles, alongside annotation and deleted rows.
- `FileDiff.syncRenderViewToEditor` converts the stored render range
(rendered-row units for the windowed AST pipeline) into the
document-line units the editor consumes; the two only coincide while
everything renders. The RenderRange type documents both readings.
---
packages/diffs/src/components/FileDiff.ts | 78 ++++++-
packages/diffs/src/editor/editor.ts | 80 +++++--
packages/diffs/src/types.ts | 18 ++
packages/diffs/src/utils/virtualDiffLayout.ts | 67 ++++++
.../diffs/test/editorCollapsedEdit.test.ts | 214 ++++++++++++++++++
.../test/isAdditionLineRenderable.test.ts | 104 +++++++++
6 files changed, 547 insertions(+), 14 deletions(-)
create mode 100644 packages/diffs/test/editorCollapsedEdit.test.ts
create mode 100644 packages/diffs/test/isAdditionLineRenderable.test.ts
diff --git a/packages/diffs/src/components/FileDiff.ts b/packages/diffs/src/components/FileDiff.ts
index 775de5e10..9c845b8cd 100644
--- a/packages/diffs/src/components/FileDiff.ts
+++ b/packages/diffs/src/components/FileDiff.ts
@@ -3,6 +3,7 @@ import { toHtml } from 'hast-util-to-html';
import {
CUSTOM_HEADER_SLOT_ID,
+ DEFAULT_COLLAPSED_CONTEXT_THRESHOLD,
DEFAULT_THEMES,
DEFAULT_TOKENIZE_MAX_LENGTH,
DIFFS_TAG_NAME,
@@ -80,12 +81,15 @@ import { getLineAnnotationName } from '../utils/getLineAnnotationName';
import { getOrCreateCodeNode } from '../utils/getOrCreateCodeNode';
import { upsertHostThemeStyle } from '../utils/hostTheme';
import { hydratePartialDiff } from '../utils/hydratePartialDiff';
+import { isDefaultRenderRange } from '../utils/isDefaultRenderRange';
import { isDiffPlainText } from '../utils/isDiffPlainText';
import { isStyleNode } from '../utils/isStyleNode';
+import { iterateOverDiff } from '../utils/iterateOverDiff';
import { parseDiffFromFile } from '../utils/parseDiffFromFile';
import { prerenderHTMLIfNecessary } from '../utils/prerenderHTMLIfNecessary';
import { getMeasuredScrollbarGutter } from '../utils/scrollbarGutter';
import { setPreNodeProperties } from '../utils/setWrapperNodeProps';
+import { isAdditionLineRenderable } from '../utils/virtualDiffLayout';
import type { WorkerPoolManager } from '../worker';
import { DiffsContainerLoaded } from './web-components';
@@ -1176,7 +1180,7 @@ export class FileDiff<
const fileContainer = this.fileContainer;
const fileDiff = this.fileDiffCache;
const lineAnnotations = this.lineAnnotations;
- const renderRange = this.renderRange;
+ const renderRange = this.computeEditorRenderRange(this.renderRange);
if (
editor != null &&
fileContainer != null &&
@@ -1203,6 +1207,56 @@ export class FileDiff<
}
}
+ // The stored render range is in rendered-row units for the windowed AST
+ // pipeline, but the editor consumes render ranges in document-line units.
+ // Derive the addition-side document window covered by the rendered rows:
+ // startingLine = first addition line with a row in the window, totalLines =
+ // last such line - first + 1, and 0 when the window holds no addition rows
+ // (e.g. a pure-deletion run taller than the viewport).
+ private computeEditorRenderRange(
+ renderRange: RenderRange | undefined
+ ): RenderRange | undefined {
+ const fileDiff = this.fileDiffCache;
+ if (
+ renderRange == null ||
+ fileDiff == null ||
+ isDefaultRenderRange(renderRange)
+ ) {
+ return renderRange;
+ }
+ const {
+ diffStyle = 'split',
+ expandUnchanged = false,
+ collapsedContextThreshold = DEFAULT_COLLAPSED_CONTEXT_THRESHOLD,
+ } = this.options;
+ let firstLineNumber: number | undefined;
+ let lastLineNumber: number | undefined;
+ iterateOverDiff({
+ diff: fileDiff,
+ diffStyle,
+ startingLine: renderRange.startingLine,
+ totalLines: renderRange.totalLines,
+ expandedHunks: expandUnchanged
+ ? true
+ : this.hunksRenderer.getExpandedHunksMap(),
+ collapsedContextThreshold,
+ callback: ({ additionLine }) => {
+ if (additionLine != null) {
+ firstLineNumber ??= additionLine.lineNumber;
+ lastLineNumber = additionLine.lineNumber;
+ }
+ },
+ });
+ if (firstLineNumber == null || lastLineNumber == null) {
+ return { ...renderRange, startingLine: 0, totalLines: 0 };
+ }
+ return {
+ ...renderRange,
+ startingLine: firstLineNumber - 1,
+ totalLines: lastLineNumber - firstLineNumber + 1,
+ };
+ }
+
public attachEditor(
editor: DiffsEditor
): (recycle?: boolean) => void {
@@ -1312,6 +1366,28 @@ export class FileDiff<
}
}
+ // Editor-facing visibility oracle: whether a one-based new-file line has
+ // (or will have on scroll) a rendered row under the current expansion
+ // state. See isAdditionLineRenderable.
+ public isLineRenderable(lineNumber: number): boolean {
+ const fileDiff = this.fileDiffCache;
+ if (fileDiff == null) {
+ return true;
+ }
+ const {
+ expandUnchanged = false,
+ collapsedContextThreshold = DEFAULT_COLLAPSED_CONTEXT_THRESHOLD,
+ } = this.options;
+ return isAdditionLineRenderable({
+ fileDiff,
+ lineNumber,
+ expandedHunks: expandUnchanged
+ ? true
+ : this.hunksRenderer.getExpandedHunksMap(),
+ collapsedContextThreshold,
+ });
+ }
+
// Whether render() may run the session-exit recompute on dirty metadata.
// False while an editor is attached (the session is live). CodeView-managed
// instances override this: their sessions survive recycling with no editor
diff --git a/packages/diffs/src/editor/editor.ts b/packages/diffs/src/editor/editor.ts
index eb80155aa..f50ced872 100644
--- a/packages/diffs/src/editor/editor.ts
+++ b/packages/diffs/src/editor/editor.ts
@@ -814,14 +814,22 @@ export class Editor implements DiffsEditor {
contentEl.ariaLabel = fileOrDiff.name;
}
+ // Annotation rows, unified deleted rows, and hunk separators are
+ // non-document rows living inside the contenteditable column; mark them
+ // read-only so the caret can never enter them. Separators render in both
+ // diff styles whenever a collapsed region exists.
if (
(lineAnnotations !== undefined && lineAnnotations.length > 0) ||
- (this.#isDiff && this.#diffSyle === 'unified')
+ this.#isDiff
) {
for (const child of this.#contentElement.children) {
const el = child as HTMLElement;
- const { lineAnnotation, lineType } = el.dataset;
- if (lineAnnotation !== undefined || lineType === 'change-deletion') {
+ const { lineAnnotation, lineType, separator } = el.dataset;
+ if (
+ lineAnnotation !== undefined ||
+ separator !== undefined ||
+ lineType === 'change-deletion'
+ ) {
el.setAttribute('contenteditable', 'false');
}
}
@@ -892,6 +900,14 @@ export class Editor implements DiffsEditor {
}
};
+ // Whether a zero-based document line has (or will have on scroll) a
+ // rendered row. False only for lines hidden inside a collapsed unchanged
+ // region of a diff host; hosts without collapsible regions treat every
+ // line as renderable.
+ #isLineRenderable(line: number): boolean {
+ return this.#fileInstance?.isLineRenderable?.(line + 1) ?? true;
+ }
+
get #diffSyle(): 'unified' | 'split' {
return this.#fileInstance?.options.diffStyle ?? 'split';
}
@@ -2140,11 +2156,23 @@ export class Editor implements DiffsEditor {
if (dirtyLines.size > 0) {
const children = contentEl.children;
- const dirtyLineIndexes = new Set(dirtyLines.keys());
+ // Lines hidden inside a collapsed region have no row to patch, and
+ // letting them reach the create branch below would append rows for
+ // them; the tokenizer still received them (dirtyLines is forwarded to
+ // the host untouched).
+ const dirtyLineIndexes = new Set();
+ for (const lineIndex of dirtyLines.keys()) {
+ if (this.#isLineRenderable(lineIndex)) {
+ dirtyLineIndexes.add(lineIndex);
+ }
+ }
- // update line elements that have been changed in the document
- const startingLine = renderRange?.startingLine ?? 0;
- for (let i = change.startLine - startingLine; i < children.length; i++) {
+ // Update line elements that have been changed in the document. The
+ // scan starts at 0 because rendered rows can be sparse (collapsed
+ // regions): a dirty line's row may sit at a smaller child index than
+ // its dense line-arithmetic position, and overshooting it would fall
+ // through to the create branch below as a duplicate row.
+ for (let i = 0; i < children.length; i++) {
const child = children[i] as HTMLElement | undefined;
if (child !== undefined) {
const lineNumber = getLineNumberAttr(child);
@@ -2153,7 +2181,7 @@ export class Editor implements DiffsEditor {
continue;
}
const lineIndex = lineNumber - 1;
- if (dirtyLines.has(lineIndex)) {
+ if (dirtyLineIndexes.has(lineIndex)) {
const tokens = dirtyLines.get(lineIndex)!;
child.replaceChildren(...renderLineTokens(tokens));
dirtyLineIndexes.delete(lineIndex);
@@ -2164,9 +2192,27 @@ export class Editor implements DiffsEditor {
}
}
- // create new line elements for the new lines
+ // Create new line elements for the new lines — only beyond the last
+ // existing rendered row: rows are appended to the column end, so any
+ // earlier missing row belongs at a collapsed-region boundary and is
+ // owned by the full re-render that follows a hunk-structure change.
if (dirtyLineIndexes.size > 0) {
+ let lastRenderedLineNumber = 0;
+ for (let i = children.length - 1; i >= 0; i--) {
+ const child = children[i] as HTMLElement;
+ const lineNumber = getLineNumberAttr(child);
+ if (
+ lineNumber !== undefined &&
+ child.dataset.lineType !== 'change-deletion'
+ ) {
+ lastRenderedLineNumber = lineNumber;
+ break;
+ }
+ }
for (const lineIndex of dirtyLineIndexes) {
+ if (lineIndex < lastRenderedLineNumber) {
+ continue;
+ }
const tokens = dirtyLines.get(lineIndex)!;
const lineNumber = String(lineIndex + 1);
h(
@@ -2491,7 +2537,10 @@ export class Editor implements DiffsEditor {
virtualCaret.style.top = modelLinePosition.top + 'px';
this.#fileContainer?.shadowRoot?.appendChild(virtualCaret);
virtualCaret.scrollIntoView({ block: 'center', inline: 'nearest' });
- if (modelLinePosition.height > 0) {
+ // A collapsed line reports its separator's (nonzero) geometry, so
+ // parking the retry for it would re-scroll on every sync forever;
+ // only renderable lines may keep the retry alive.
+ if (modelLinePosition.height > 0 && this.#isLineRenderable(line)) {
this.#scrollingToLine = line;
this.#scrollingToLineChar = char;
this.#scrollingToLineNoFocus = noFocus;
@@ -2534,8 +2583,9 @@ export class Editor implements DiffsEditor {
virtualCaret.scrollIntoView({ block: 'center', inline: 'nearest' });
if (
- this.#scrollingToLine === line &&
- (yFix === 0 || this.#scrollingToLineFixed)
+ (this.#scrollingToLine === line &&
+ (yFix === 0 || this.#scrollingToLineFixed)) ||
+ !this.#isLineRenderable(line)
) {
this.#scrollingToLine = undefined;
this.#scrollingToLineChar = undefined;
@@ -3918,7 +3968,11 @@ export class Editor implements DiffsEditor {
// next scroll.
if (
change.startLine <= renderRangeEndLine &&
- widenedTotalLines <= maxWidenLines
+ widenedTotalLines <= maxWidenLines &&
+ // Widening exists to give the caret's line a row; a caret target
+ // hidden in a collapsed region can never gain one, so take the
+ // buffer-only path instead of building rows toward it.
+ this.#isLineRenderable(primarySelection.end.line)
) {
if (primarySelection.end.line > renderRangeEndLine) {
// The line count grew below the window, so the buffer spacer must be
diff --git a/packages/diffs/src/types.ts b/packages/diffs/src/types.ts
index 7a77b1884..ee6fd5865 100644
--- a/packages/diffs/src/types.ts
+++ b/packages/diffs/src/types.ts
@@ -835,6 +835,17 @@ export interface RenderedDiffASTCache {
isDirty?: boolean;
}
+/**
+ * A window of rendered content. Two unit interpretations exist:
+ *
+ * - Renderer consumers (`iterateOverDiff` window predicates, windowed AST
+ * requests, buffers, sticky specs) read `startingLine`/`totalLines` as
+ * dense rendered-row indexes for the active diff style.
+ * - The editor reads them as zero-based document-line indexes of the new
+ * file. `FileDiff.computeEditorRenderRange` converts the renderer range on
+ * the editor-bound copy; the two coincide only while every line renders
+ * (`expandUnchanged`).
+ */
export interface RenderRange {
startingLine: number;
totalLines: number;
@@ -1018,6 +1029,13 @@ export interface DiffsEditableComponent<
* Return the scroll container element.
*/
getScrollContainer?: () => HTMLElement | undefined;
+ /**
+ * Whether the given one-based new-file line currently has (or will have on
+ * scroll) a rendered row. False only for lines hidden inside a collapsed
+ * unchanged region. Components without collapsible regions leave this
+ * unimplemented and the editor treats every line as renderable.
+ */
+ isLineRenderable?: (lineNumber: number) => boolean;
/**
* Attach an editor to this component. The returned detach closure receives
* `recycle: true` when the editor is only being released by a virtualized
diff --git a/packages/diffs/src/utils/virtualDiffLayout.ts b/packages/diffs/src/utils/virtualDiffLayout.ts
index 33036228a..1ba26f626 100644
--- a/packages/diffs/src/utils/virtualDiffLayout.ts
+++ b/packages/diffs/src/utils/virtualDiffLayout.ts
@@ -235,6 +235,73 @@ export function getTrailingExpandedRegion({
};
}
+export interface IsAdditionLineRenderableProps {
+ fileDiff: FileDiffMetadata;
+ /** One-based line number in the new file. */
+ lineNumber: number;
+ expandedHunks: Map | true | undefined;
+ collapsedContextThreshold: number;
+}
+
+/**
+ * Whether a one-based new-file line currently has (or will have on scroll) a
+ * rendered row under the given expansion state — the editor-facing
+ * visibility oracle. False only for lines hidden inside a collapsed
+ * unchanged region; lines outside the diff's modeled range report true so
+ * callers keep their existing missing-row handling. Computed from the same
+ * inputs as `iterateOverDiff` so layout math and the oracle cannot diverge.
+ */
+export function isAdditionLineRenderable({
+ fileDiff,
+ lineNumber,
+ expandedHunks,
+ collapsedContextThreshold,
+}: IsAdditionLineRenderableProps): boolean {
+ if (expandedHunks === true || fileDiff.isPartial) {
+ return true;
+ }
+
+ for (const [hunkIndex, hunk] of fileDiff.hunks.entries()) {
+ if (lineNumber < hunk.additionStart) {
+ // Inside the collapsed gap before this hunk: renderable only within
+ // the expanded slices at the gap's edges.
+ const region = getExpandedRegion({
+ isPartial: fileDiff.isPartial,
+ rangeSize: hunk.collapsedBefore,
+ expandedHunks,
+ hunkIndex,
+ collapsedContextThreshold,
+ });
+ const gapStart = hunk.additionStart - region.rangeSize;
+ return (
+ region.renderAll ||
+ lineNumber < gapStart + region.fromStart ||
+ lineNumber >= hunk.additionStart - region.fromEnd
+ );
+ }
+ if (lineNumber < hunk.additionStart + hunk.additionCount) {
+ return true;
+ }
+ }
+
+ const trailingRegion = getTrailingExpandedRegion({
+ fileDiff,
+ hunkIndex: fileDiff.hunks.length - 1,
+ expandedHunks,
+ collapsedContextThreshold,
+ errorPrefix: 'isAdditionLineRenderable',
+ });
+ if (trailingRegion == null || trailingRegion.renderAll) {
+ return true;
+ }
+ const lastHunk = fileDiff.hunks[fileDiff.hunks.length - 1];
+ const trailingStart = lastHunk.additionStart + lastHunk.additionCount;
+ return (
+ lineNumber < trailingStart + trailingRegion.fromStart ||
+ lineNumber >= trailingStart + trailingRegion.rangeSize
+ );
+}
+
export function getHunkSeparatorHeight({
type,
metrics,
diff --git a/packages/diffs/test/editorCollapsedEdit.test.ts b/packages/diffs/test/editorCollapsedEdit.test.ts
new file mode 100644
index 000000000..ad98d834d
--- /dev/null
+++ b/packages/diffs/test/editorCollapsedEdit.test.ts
@@ -0,0 +1,214 @@
+import { afterAll, describe, expect, test } from 'bun:test';
+
+import { FileDiff } from '../src/components/FileDiff';
+import { DEFAULT_THEMES } from '../src/constants';
+import { Editor } from '../src/editor/editor';
+import { disposeHighlighter } from '../src/highlighter/shared_highlighter';
+import { installDom, wait } from './domHarness';
+
+afterAll(async () => {
+ await disposeHighlighter();
+});
+
+// The editor attaches to the additions (new-file) side of a diff: the
+// [data-code] element without data-deletions, lines under data-content.
+function findAdditionContent(container: HTMLElement): HTMLElement | undefined {
+ const shadow = container.shadowRoot;
+ if (shadow == null) {
+ return undefined;
+ }
+ for (const code of shadow.querySelectorAll('[data-code]')) {
+ if (code.dataset.deletions !== undefined) {
+ continue;
+ }
+ for (const child of code.children) {
+ const el = child as HTMLElement;
+ if (el.dataset.content !== undefined) {
+ return el;
+ }
+ }
+ }
+ return undefined;
+}
+
+function findAdditionGutter(container: HTMLElement): HTMLElement | undefined {
+ const content = findAdditionContent(container);
+ const code = content?.parentElement;
+ for (const child of code?.children ?? []) {
+ const el = child as HTMLElement;
+ if (el.dataset.gutter !== undefined) {
+ return el;
+ }
+ }
+ return undefined;
+}
+
+function renderedLineNumbers(container: HTMLElement): number[] {
+ const content = findAdditionContent(container);
+ const numbers: number[] = [];
+ for (const line of content?.querySelectorAll('[data-line]') ?? []) {
+ numbers.push(Number(line.getAttribute('data-line')));
+ }
+ return numbers;
+}
+
+interface CollapsedEditFixture {
+ container: HTMLElement;
+ editor: Editor;
+ fileDiff: FileDiff;
+ cleanup(): Promise;
+}
+
+// A 60-line file with changes at lines 10 and 50: two hunks with a large
+// collapsible gap between them plus collapsible leading/trailing context.
+// Editing starts with the still-forced expansion, then the collapse is
+// re-enabled mid-session — the standalone vector for exercising
+// collapse-during-edit before the option forcing is removed.
+async function createCollapsedEditFixture(): Promise {
+ const dom = installDom();
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+
+ const oldContents =
+ Array.from({ length: 60 }, (_, index) => `line ${index + 1}`).join('\n') +
+ '\n';
+ const newContents = oldContents
+ .replace('line 10\n', 'line 10 changed\n')
+ .replace('line 50\n', 'line 50 changed\n');
+
+ const fileDiff = new FileDiff({
+ disableFileHeader: true,
+ theme: DEFAULT_THEMES,
+ diffStyle: 'split',
+ });
+ const editor = new Editor();
+ fileDiff.render({
+ oldFile: { name: 'edit.ts', contents: oldContents },
+ newFile: { name: 'edit.ts', contents: newContents },
+ fileContainer: container,
+ forceRender: true,
+ });
+ editor.edit(fileDiff);
+ for (let attempt = 0; attempt < 40; attempt++) {
+ const content = findAdditionContent(container);
+ if (content != null && content.getAttribute('contenteditable') === 'true') {
+ break;
+ }
+ await wait(0);
+ }
+
+ // Re-enable collapse mid-session: allowed for standalone instances, and
+ // Editor.edit's option forcing only runs once at attach.
+ fileDiff.setOptions({ ...fileDiff.options, expandUnchanged: false });
+ fileDiff.rerender();
+ await wait(10);
+
+ return {
+ container,
+ editor,
+ fileDiff,
+ async cleanup() {
+ await wait(10);
+ editor.cleanUp();
+ fileDiff.cleanUp();
+ dom.cleanup();
+ },
+ };
+}
+
+function typeAt(
+ editor: Editor,
+ line: number,
+ character: number,
+ text: string
+): void {
+ const position = { line, character };
+ editor.setSelections([{ start: position, end: position, direction: 'none' }]);
+ editor.applyEdits(
+ [{ range: { start: position, end: position }, newText: text }],
+ true
+ );
+}
+
+describe('diff editor: collapsed regions during edit', () => {
+ test('renders sparse rows with read-only separators and column parity', async () => {
+ const fixture = await createCollapsedEditFixture();
+ const { container, fileDiff } = fixture;
+ try {
+ const lineNumbers = renderedLineNumbers(container);
+ // The gap between the hunks is collapsed: far fewer than 60 rows, and
+ // a mid-gap line has no row.
+ expect(lineNumbers.length).toBeLessThan(60);
+ expect(lineNumbers).not.toContain(30);
+ expect(fileDiff.isLineRenderable(30)).toBe(false);
+ expect(fileDiff.isLineRenderable(10)).toBe(true);
+
+ // Separator rows inside the contenteditable column are read-only.
+ const content = findAdditionContent(container);
+ const separators = content?.querySelectorAll('[data-separator]') ?? [];
+ expect(separators.length).toBeGreaterThan(0);
+ for (const separator of separators) {
+ expect(separator.getAttribute('contenteditable')).toBe('false');
+ }
+
+ // The gutter and content columns stay index-parallel (separators emit
+ // paired rows), which caret geometry relies on.
+ const gutter = findAdditionGutter(container);
+ expect(gutter?.childElementCount).toBe(content?.childElementCount);
+ } finally {
+ await fixture.cleanup();
+ }
+ });
+
+ test('typing below a collapsed gap patches the row without duplicates', async () => {
+ const fixture = await createCollapsedEditFixture();
+ const { container, editor } = fixture;
+ try {
+ // Line 50 (index 49) sits below the collapsed gap.
+ typeAt(fixture.editor, 49, 0, 'X');
+ await wait(0);
+ expect(editor.getState().file.contents).toContain('Xline 50 changed');
+
+ const content = findAdditionContent(container);
+ const rows = content?.querySelectorAll('[data-line="50"]') ?? [];
+ expect(rows.length).toBe(1);
+ expect(rows[0].textContent).toBe('Xline 50 changed');
+
+ // No duplicates, and no rows materialized inside the collapsed gaps.
+ // (The document-end phantom line may gain an appended row, matching the
+ // fully expanded behavior.)
+ const lineNumbers = renderedLineNumbers(container);
+ expect(new Set(lineNumbers).size).toBe(lineNumbers.length);
+ for (const lineNumber of lineNumbers) {
+ expect(
+ lineNumber <= 14 ||
+ (lineNumber >= 46 && lineNumber <= 54) ||
+ lineNumber === 61
+ ).toBe(true);
+ }
+ } finally {
+ await fixture.cleanup();
+ }
+ });
+
+ test('a line-count edit below the gap re-renders without duplicates', async () => {
+ const fixture = await createCollapsedEditFixture();
+ const { container, editor } = fixture;
+ try {
+ typeAt(editor, 49, 'line 50 changed'.length + 1, '\nNEW LINE');
+ await wait(20);
+
+ expect(editor.getState().file.contents).toContain(
+ 'line 50 changed\nNEW LINE'
+ );
+ const lineNumbers = renderedLineNumbers(container);
+ expect(new Set(lineNumbers).size).toBe(lineNumbers.length);
+ // The collapsed gap stays collapsed across the full re-render.
+ expect(lineNumbers).not.toContain(30);
+ const content = findAdditionContent(container);
+ expect(content?.textContent).toContain('NEW LINE');
+ } finally {
+ await fixture.cleanup();
+ }
+ });
+});
diff --git a/packages/diffs/test/isAdditionLineRenderable.test.ts b/packages/diffs/test/isAdditionLineRenderable.test.ts
new file mode 100644
index 000000000..8ac37665d
--- /dev/null
+++ b/packages/diffs/test/isAdditionLineRenderable.test.ts
@@ -0,0 +1,104 @@
+import { describe, expect, test } from 'bun:test';
+
+import type { FileDiffMetadata, HunkExpansionRegion } from '../src/types';
+import { iterateOverDiff } from '../src/utils/iterateOverDiff';
+import { parseDiffFromFile } from '../src/utils/parseDiffFromFile';
+import { isAdditionLineRenderable } from '../src/utils/virtualDiffLayout';
+
+const COLLAPSED_CONTEXT_THRESHOLD = 1;
+
+function makeDiff(): FileDiffMetadata {
+ const oldContents =
+ Array.from({ length: 40 }, (_, index) => `line ${index + 1}`).join('\n') +
+ '\n';
+ const newContents = oldContents
+ .replace('line 10\n', 'line 10 changed\n')
+ .replace('line 30\n', 'line 30 changed\n');
+ return parseDiffFromFile(
+ { name: 'a.ts', contents: oldContents },
+ { name: 'a.ts', contents: newContents }
+ );
+}
+
+// Collects the one-based new-file line numbers iterateOverDiff emits rows for
+// under the given expansion state — the ground truth the oracle must match.
+function collectRenderedAdditionLines(
+ diff: FileDiffMetadata,
+ expandedHunks: Map | true | undefined
+): Set {
+ const rendered = new Set();
+ iterateOverDiff({
+ diff,
+ diffStyle: 'split',
+ expandedHunks,
+ collapsedContextThreshold: COLLAPSED_CONTEXT_THRESHOLD,
+ callback: ({ additionLine }) => {
+ if (additionLine != null) {
+ rendered.add(additionLine.lineNumber);
+ }
+ },
+ });
+ return rendered;
+}
+
+function assertOracleMatches(
+ diff: FileDiffMetadata,
+ expandedHunks: Map | true | undefined
+): void {
+ const rendered = collectRenderedAdditionLines(diff, expandedHunks);
+ for (let lineNumber = 1; lineNumber <= 40; lineNumber++) {
+ expect({
+ lineNumber,
+ renderable: isAdditionLineRenderable({
+ fileDiff: diff,
+ lineNumber,
+ expandedHunks,
+ collapsedContextThreshold: COLLAPSED_CONTEXT_THRESHOLD,
+ }),
+ }).toEqual({ lineNumber, renderable: rendered.has(lineNumber) });
+ }
+}
+
+describe('isAdditionLineRenderable', () => {
+ test('matches iterateOverDiff with everything collapsed', () => {
+ assertOracleMatches(makeDiff(), new Map());
+ });
+
+ test('matches iterateOverDiff with partially expanded regions', () => {
+ assertOracleMatches(
+ makeDiff(),
+ new Map([
+ // Leading gap of the first hunk, expanded from its start.
+ [0, { fromStart: 3, fromEnd: 0 }],
+ // Gap between the hunks, expanded from both edges.
+ [1, { fromStart: 2, fromEnd: 4 }],
+ // Trailing pseudo-key: context after the last hunk.
+ [2, { fromStart: 3, fromEnd: 0 }],
+ ])
+ );
+ });
+
+ test('matches iterateOverDiff with a fully expanded gap', () => {
+ assertOracleMatches(
+ makeDiff(),
+ new Map([[1, { fromStart: 99, fromEnd: 0 }]])
+ );
+ });
+
+ test('reports every line renderable when everything is expanded', () => {
+ assertOracleMatches(makeDiff(), true);
+ });
+
+ test('reports lines beyond the modeled range as renderable', () => {
+ const diff = makeDiff();
+ // The editor document exposes one phantom line past the file end.
+ expect(
+ isAdditionLineRenderable({
+ fileDiff: diff,
+ lineNumber: 41,
+ expandedHunks: new Map(),
+ collapsedContextThreshold: COLLAPSED_CONTEXT_THRESHOLD,
+ })
+ ).toBe(true);
+ });
+});
From f8099f1292f9192a050b60ac29fc07545ef40c02 Mon Sep 17 00:00:00 2001
From: Amadeus Demarzi
Date: Fri, 10 Jul 2026 12:33:10 -0700
Subject: [PATCH 03/11] feat(diffs): Add fold-skip caret motion and
reveal-on-jump for collapsed regions
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Give the editor code-fold semantics over a diff's collapsed unchanged
regions, ahead of edit mode keeping them collapsed:
- Arrow up/down (and shift-extended selection) skip over a collapsed
region atomically to the nearest renderable line, staying put when
nothing renders in that direction. The skip target comes from a
single hunk-metadata walk (getNearestRenderableAdditionLine), not a
per-line probe across the gap.
- Jumps that must land inside a collapsed region — setSelections,
search-match navigation, cmd+d/find-next, cmd+Home/End, undo/redo
caret restores — expand the region minimally first via the new
`revealLine` host API: one deterministic bump from the nearest gap
edge sized to reach the target plus the normal expansion step,
routed through expandHunk so CodeView's deferred expansion staging
applies. Search counts still include hidden matches; navigating to
one reveals it.
- VirtualizedFileDiff's line-visibility oracle now accounts for staged
pendingExpansions so the caret-scroll retry can park while a reveal
waits for the next layout consume.
Behavior is unchanged while editing still forces expandUnchanged.
---
packages/diffs/src/components/FileDiff.ts | 121 +++++++-
.../src/components/VirtualizedFileDiff.ts | 44 +++
packages/diffs/src/editor/editor.ts | 82 +++++-
packages/diffs/src/editor/selection.ts | 54 +++-
packages/diffs/src/types.ts | 16 ++
packages/diffs/src/utils/virtualDiffLayout.ts | 86 ++++++
.../diffs/test/editorCollapsedEdit.test.ts | 6 +-
.../diffs/test/editorFoldNavigation.test.ts | 262 ++++++++++++++++++
.../test/isAdditionLineRenderable.test.ts | 60 +++-
9 files changed, 707 insertions(+), 24 deletions(-)
create mode 100644 packages/diffs/test/editorFoldNavigation.test.ts
diff --git a/packages/diffs/src/components/FileDiff.ts b/packages/diffs/src/components/FileDiff.ts
index 9c845b8cd..365a54f3d 100644
--- a/packages/diffs/src/components/FileDiff.ts
+++ b/packages/diffs/src/components/FileDiff.ts
@@ -89,7 +89,12 @@ import { parseDiffFromFile } from '../utils/parseDiffFromFile';
import { prerenderHTMLIfNecessary } from '../utils/prerenderHTMLIfNecessary';
import { getMeasuredScrollbarGutter } from '../utils/scrollbarGutter';
import { setPreNodeProperties } from '../utils/setWrapperNodeProps';
-import { isAdditionLineRenderable } from '../utils/virtualDiffLayout';
+import {
+ getExpandedRegion,
+ getNearestRenderableAdditionLine,
+ getTrailingExpandedRegion,
+ isAdditionLineRenderable,
+} from '../utils/virtualDiffLayout';
import type { WorkerPoolManager } from '../worker';
import { DiffsContainerLoaded } from './web-components';
@@ -1171,7 +1176,7 @@ export class FileDiff<
onPostRender?.(fileContainer, this, phase);
}
- private get fileDiffCache(): FileDiffMetadata | undefined {
+ protected get fileDiffCache(): FileDiffMetadata | undefined {
return this.hunksRenderer.diffCache ?? this.fileDiff;
}
@@ -1388,6 +1393,118 @@ export class FileDiff<
});
}
+ // Fold-skip companion to isLineRenderable: the nearest renderable one-based
+ // new-file line at or beyond lineNumber in the given direction.
+ public getNearestRenderableLine(
+ lineNumber: number,
+ direction: 'up' | 'down'
+ ): number | undefined {
+ const fileDiff = this.fileDiffCache;
+ if (fileDiff == null) {
+ return lineNumber;
+ }
+ const {
+ expandUnchanged = false,
+ collapsedContextThreshold = DEFAULT_COLLAPSED_CONTEXT_THRESHOLD,
+ } = this.options;
+ return getNearestRenderableAdditionLine({
+ fileDiff,
+ lineNumber,
+ direction,
+ expandedHunks: expandUnchanged
+ ? true
+ : this.hunksRenderer.getExpandedHunksMap(),
+ collapsedContextThreshold,
+ });
+ }
+
+ // Expand collapsed context so a one-based new-file line can render: one
+ // deterministic expansion from the nearest gap edge, sized to reach the
+ // target plus the normal expansion step (clamped to the gap at render).
+ // Routed through expandHunk so subclass expansion flows (CodeView's
+ // deferred pendingExpansions) apply.
+ public revealLine(lineNumber: number): boolean {
+ const fileDiff = this.fileDiffCache;
+ const {
+ expandUnchanged = false,
+ collapsedContextThreshold = DEFAULT_COLLAPSED_CONTEXT_THRESHOLD,
+ expansionLineCount = 100,
+ } = this.options;
+ if (fileDiff == null || fileDiff.isPartial || expandUnchanged) {
+ return false;
+ }
+ const expandedHunks = this.hunksRenderer.getExpandedHunksMap();
+
+ for (const [hunkIndex, hunk] of fileDiff.hunks.entries()) {
+ if (lineNumber < hunk.additionStart) {
+ const region = getExpandedRegion({
+ isPartial: fileDiff.isPartial,
+ rangeSize: hunk.collapsedBefore,
+ expandedHunks,
+ hunkIndex,
+ collapsedContextThreshold,
+ });
+ const gapStart = hunk.additionStart - region.rangeSize;
+ if (
+ region.renderAll ||
+ lineNumber < gapStart + region.fromStart ||
+ lineNumber >= hunk.additionStart - region.fromEnd
+ ) {
+ return false;
+ }
+ const fromStartDistance =
+ lineNumber - (gapStart + region.fromStart) + 1;
+ const fromEndDistance =
+ hunk.additionStart - region.fromEnd - lineNumber;
+ if (fromStartDistance <= fromEndDistance) {
+ this.expandHunk(
+ hunkIndex,
+ 'up',
+ fromStartDistance + expansionLineCount
+ );
+ } else {
+ this.expandHunk(
+ hunkIndex,
+ 'down',
+ fromEndDistance + expansionLineCount
+ );
+ }
+ return true;
+ }
+ if (lineNumber < hunk.additionStart + hunk.additionCount) {
+ return false;
+ }
+ }
+
+ const trailingRegion = getTrailingExpandedRegion({
+ fileDiff,
+ hunkIndex: fileDiff.hunks.length - 1,
+ expandedHunks,
+ collapsedContextThreshold,
+ errorPrefix: 'FileDiff.revealLine',
+ });
+ if (trailingRegion == null || trailingRegion.renderAll) {
+ return false;
+ }
+ const lastHunk = fileDiff.hunks[fileDiff.hunks.length - 1];
+ const trailingStart = lastHunk.additionStart + lastHunk.additionCount;
+ if (
+ lineNumber < trailingStart + trailingRegion.fromStart ||
+ lineNumber >= trailingStart + trailingRegion.rangeSize
+ ) {
+ return false;
+ }
+ this.expandHunk(
+ fileDiff.hunks.length,
+ 'up',
+ lineNumber -
+ (trailingStart + trailingRegion.fromStart) +
+ 1 +
+ expansionLineCount
+ );
+ return true;
+ }
+
// Whether render() may run the session-exit recompute on dirty metadata.
// False while an editor is attached (the session is live). CodeView-managed
// instances override this: their sessions survive recycling with no editor
diff --git a/packages/diffs/src/components/VirtualizedFileDiff.ts b/packages/diffs/src/components/VirtualizedFileDiff.ts
index 781e38728..3803dafc9 100644
--- a/packages/diffs/src/components/VirtualizedFileDiff.ts
+++ b/packages/diffs/src/components/VirtualizedFileDiff.ts
@@ -43,6 +43,7 @@ import {
getLeadingHunkSeparatorLayout,
getTrailingExpandedRegion,
getTrailingHunkSeparatorLayout,
+ isAdditionLineRenderable,
} from '../utils/virtualDiffLayout';
import type { WorkerPoolManager } from '../worker';
import type { CodeView } from './CodeView';
@@ -896,6 +897,49 @@ export class VirtualizedFileDiff<
super.loadFilesIfNecessary();
}
+ // In advanced (CodeView) mode, expansions are staged in pendingExpansions
+ // until the next layout consume, so the renderer's expansion map lags a
+ // just-requested reveal. Account for the staged expansions so callers (the
+ // editor's caret-scroll retry) see the post-consume visibility.
+ override isLineRenderable(lineNumber: number): boolean {
+ if (super.isLineRenderable(lineNumber)) {
+ return true;
+ }
+ const { pendingExpansions } = this;
+ const fileDiff = this.fileDiffCache;
+ if (
+ pendingExpansions == null ||
+ pendingExpansions.length === 0 ||
+ fileDiff == null
+ ) {
+ return false;
+ }
+ const {
+ expansionLineCount = 100,
+ collapsedContextThreshold = DEFAULT_COLLAPSED_CONTEXT_THRESHOLD,
+ } = this.options;
+ const staged = new Map(this.hunksRenderer.getExpandedHunksMap());
+ for (const expansion of pendingExpansions) {
+ const region = {
+ ...(staged.get(expansion.hunkIndex) ?? { fromStart: 0, fromEnd: 0 }),
+ };
+ const count = expansion.expansionLineCountOverride ?? expansionLineCount;
+ if (expansion.direction === 'up' || expansion.direction === 'both') {
+ region.fromStart += count;
+ }
+ if (expansion.direction === 'down' || expansion.direction === 'both') {
+ region.fromEnd += count;
+ }
+ staged.set(expansion.hunkIndex, region);
+ }
+ return isAdditionLineRenderable({
+ fileDiff,
+ lineNumber,
+ expandedHunks: staged,
+ collapsedContextThreshold,
+ });
+ }
+
// Session region changes alter the rendered row set without a line-count
// change, so the layout caches need the same invalidation a document change
// gets; rerender() defers the actual render through the virtualizer queue.
diff --git a/packages/diffs/src/editor/editor.ts b/packages/diffs/src/editor/editor.ts
index f50ced872..03649ca75 100644
--- a/packages/diffs/src/editor/editor.ts
+++ b/packages/diffs/src/editor/editor.ts
@@ -46,7 +46,11 @@ import {
type SearchPanelMode,
SearchPanelWidget,
} from './searchPanel';
-import type { AutoSurround, CursorMoveOptions } from './selection';
+import type {
+ AutoSurround,
+ CursorMoveOptions,
+ ResolveRenderableLine,
+} from './selection';
import {
applyDeleteCharacterToSelections,
applyDeleteHardLineForwardToSelections,
@@ -523,6 +527,10 @@ export class Editor implements DiffsEditor {
return { direction, start, end };
});
this.#updateSelections(resolvedSelections);
+ const primarySelection = resolvedSelections.at(-1);
+ if (primarySelection !== undefined) {
+ this.#revealLineIfCollapsed(getCaretPosition(primarySelection).line);
+ }
this.#scrollToPrimaryCaret();
}
@@ -908,6 +916,32 @@ export class Editor implements DiffsEditor {
return 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.
+ get #resolveRenderableLine(): ResolveRenderableLine | undefined {
+ const fileInstance = this.#fileInstance;
+ if (fileInstance?.getNearestRenderableLine == null) {
+ return undefined;
+ }
+ return (line, direction) => {
+ const nearest = fileInstance.getNearestRenderableLine!(
+ line + 1,
+ direction
+ );
+ return nearest == null ? undefined : nearest - 1;
+ };
+ }
+
+ // Jump targets (search matches, undo restores, setSelections, doc-end
+ // 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 {
+ if (!this.#isLineRenderable(line)) {
+ this.#fileInstance?.revealLine?.(line + 1);
+ }
+ }
+
get #diffSyle(): 'unified' | 'split' {
return this.#fileInstance?.options.diffStyle ?? 'split';
}
@@ -1340,11 +1374,12 @@ export class Editor implements DiffsEditor {
mvShortcut !== undefined &&
textDocument !== undefined
) {
- const cursorMoveOptions: CursorMoveOptions | undefined = this.#isWrap
- ? {
- getSoftLineOffsets: (line) => this.#wrapLineText(line),
- }
- : undefined;
+ const cursorMoveOptions: CursorMoveOptions = {
+ getSoftLineOffsets: this.#isWrap
+ ? (line) => this.#wrapLineText(line)
+ : undefined,
+ resolveRenderableLine: this.#resolveRenderableLine,
+ };
if (e.shiftKey) {
this.#updateSelections(
mapSelectionShift(
@@ -1774,6 +1809,10 @@ export class Editor implements DiffsEditor {
const nextMatch = findNexMatch(textDocument, selections);
if (nextMatch !== undefined) {
this.#updateSelections(nextMatch);
+ const primaryMatch = nextMatch.at(-1);
+ if (primaryMatch !== undefined) {
+ this.#revealLineIfCollapsed(getCaretPosition(primaryMatch).line);
+ }
this.#scrollToPrimaryCaret();
}
}
@@ -1915,9 +1954,13 @@ export class Editor implements DiffsEditor {
case 'moveCursorToDocEnd':
{
const atEnd = command === 'moveCursorToDocEnd';
- this.#updateSelections([
- getDocumentBoundarySelection(textDocument, atEnd, this.#isDiff),
- ]);
+ const boundarySelection = getDocumentBoundarySelection(
+ textDocument,
+ atEnd,
+ this.#isDiff
+ );
+ this.#updateSelections([boundarySelection]);
+ this.#revealLineIfCollapsed(getCaretPosition(boundarySelection).line);
this.#scrollToPrimaryCaret();
}
break;
@@ -1928,11 +1971,16 @@ export class Editor implements DiffsEditor {
const atEnd = command === 'expandSelectionDocEnd';
const selections = this.#selections;
if (selections !== undefined) {
+ const boundarySelection = getDocumentBoundarySelection(
+ textDocument,
+ atEnd,
+ this.#isDiff
+ );
this.#updateSelections(
- extendSelections(
- selections,
- getDocumentBoundarySelection(textDocument, atEnd, this.#isDiff)
- )
+ extendSelections(selections, boundarySelection)
+ );
+ this.#revealLineIfCollapsed(
+ getCaretPosition(boundarySelection).line
);
this.#scrollToPrimaryCaret();
}
@@ -3554,6 +3602,9 @@ export class Editor implements DiffsEditor {
endOffset
);
this.#updateSelections([nextSelection]);
+ // Search counts include matches hidden in collapsed regions; navigating
+ // to one reveals it.
+ this.#revealLineIfCollapsed(getCaretPosition(nextSelection).line);
this.#scrollToPrimaryCaret(true); // scroll to the primary caret and don't focus
this.#retainSearchPanelFocus = retainFocus;
};
@@ -4010,6 +4061,11 @@ export class Editor implements DiffsEditor {
// focus to update the native window selection, and scroll to the caret
// to mock the 'contenteditable' behavior
if (options?.skipFocus !== true) {
+ // An undo/redo caret restore can land inside a collapsed region.
+ const revealTarget = newSelections.at(-1);
+ if (revealTarget !== undefined) {
+ this.#revealLineIfCollapsed(getCaretPosition(revealTarget).line);
+ }
if (this.#primaryCaretElement !== undefined) {
requestAnimationFrame(() => {
this.#primaryCaretElement?.scrollIntoView({
diff --git a/packages/diffs/src/editor/selection.ts b/packages/diffs/src/editor/selection.ts
index 45fbf0b7b..e4bd8ddaa 100644
--- a/packages/diffs/src/editor/selection.ts
+++ b/packages/diffs/src/editor/selection.ts
@@ -24,6 +24,14 @@ export const DirectionForward = 1;
export interface CursorMoveOptions {
getSoftLineOffsets?: (line: number) => ArrayLike | undefined;
+ /**
+ * Fold-skip resolver for vertical motion crossing document lines: the
+ * nearest renderable line at or beyond the target in the move direction,
+ * or undefined when everything that way is hidden inside a collapsed
+ * region (the caret then stays put). Soft-line moves within one document
+ * line never consult it.
+ */
+ resolveRenderableLine?: ResolveRenderableLine;
}
interface SoftLineInfo {
@@ -122,6 +130,17 @@ export function resolveIndentEdits(
return [edits, newSelection];
}
+/**
+ * The nearest zero-based document line at or beyond `line` in `direction`
+ * that has a rendered row, or undefined when everything that way is hidden
+ * inside a collapsed region. Vertical caret motion uses it to skip collapsed
+ * regions atomically, like code folds.
+ */
+export type ResolveRenderableLine = (
+ line: number,
+ direction: 'up' | 'down'
+) => number | undefined;
+
/**
* Maps the cursor move to all selections.
*/
@@ -160,8 +179,13 @@ export function mapCursorMove(
if (moved !== undefined) {
line = moved.line;
character = moved.character;
- } else {
- line = Math.max(0, line - 1);
+ } else if (line > 0) {
+ // Fold-skip: when the line above is collapsed, jump to the nearest
+ // renderable line above it; stay put when nothing above renders.
+ line =
+ options.resolveRenderableLine == null
+ ? line - 1
+ : (options.resolveRenderableLine(line - 1, 'up') ?? line);
}
} else if (shortcut === 'down') {
const moved = moveBySoftLine(textDocument, line, character, 1, options);
@@ -169,7 +193,16 @@ export function mapCursorMove(
line = moved.line;
character = moved.character;
} else {
- line = Math.min(Math.max(lineCount - 1, 0), line + 1);
+ const maxLine = Math.max(lineCount - 1, 0);
+ if (line < maxLine) {
+ line =
+ options.resolveRenderableLine == null
+ ? line + 1
+ : Math.min(
+ options.resolveRenderableLine(line + 1, 'down') ?? line,
+ maxLine
+ );
+ }
}
} else if (isCollapsedSelection(selection)) {
const lineLength = textDocument.getLineLength(line);
@@ -238,7 +271,20 @@ function moveBySoftLine(
return { line, character };
}
- targetLine = nextLine;
+ // Fold-skip: crossing to another document line skips collapsed regions;
+ // when nothing renders in that direction the caret stays put.
+ const resolvedLine =
+ options.resolveRenderableLine == null
+ ? nextLine
+ : options.resolveRenderableLine(
+ nextLine,
+ direction < 0 ? 'up' : 'down'
+ );
+ if (resolvedLine === undefined) {
+ return { line, character };
+ }
+
+ targetLine = Math.min(resolvedLine, textDocument.lineCount - 1);
const targetCount = getSoftLineCount(textDocument, targetLine, options);
target = getSoftLineInfoAtIndex(
textDocument,
diff --git a/packages/diffs/src/types.ts b/packages/diffs/src/types.ts
index ee6fd5865..b2bce2edc 100644
--- a/packages/diffs/src/types.ts
+++ b/packages/diffs/src/types.ts
@@ -1036,6 +1036,22 @@ export interface DiffsEditableComponent<
* unimplemented and the editor treats every line as renderable.
*/
isLineRenderable?: (lineNumber: number) => boolean;
+ /**
+ * The nearest renderable one-based new-file line at or beyond `lineNumber`
+ * in the given direction, or undefined when every line that way is hidden
+ * inside collapsed regions. Sequential caret motion uses this to skip
+ * collapsed regions like code folds.
+ */
+ getNearestRenderableLine?: (
+ lineNumber: number,
+ direction: 'up' | 'down'
+ ) => number | undefined;
+ /**
+ * Expand collapsed context so the given one-based new-file line can
+ * render. Returns true when an expansion was performed (a re-render will
+ * follow, possibly deferred).
+ */
+ revealLine?: (lineNumber: number) => boolean;
/**
* Attach an editor to this component. The returned detach closure receives
* `recycle: true` when the editor is only being released by a virtualized
diff --git a/packages/diffs/src/utils/virtualDiffLayout.ts b/packages/diffs/src/utils/virtualDiffLayout.ts
index 1ba26f626..379373c4b 100644
--- a/packages/diffs/src/utils/virtualDiffLayout.ts
+++ b/packages/diffs/src/utils/virtualDiffLayout.ts
@@ -302,6 +302,92 @@ export function isAdditionLineRenderable({
);
}
+export interface GetNearestRenderableAdditionLineProps extends IsAdditionLineRenderableProps {
+ direction: 'up' | 'down';
+}
+
+/**
+ * The nearest renderable new-file line at or beyond `lineNumber` in the
+ * given direction (one-based), or undefined when every line that way is
+ * hidden inside collapsed regions. Sequential caret motion uses this to skip
+ * over collapsed regions like code folds; it walks the hunk metadata once
+ * instead of probing line by line across a gap.
+ */
+export function getNearestRenderableAdditionLine({
+ fileDiff,
+ lineNumber,
+ direction,
+ expandedHunks,
+ collapsedContextThreshold,
+}: GetNearestRenderableAdditionLineProps): number | undefined {
+ if (expandedHunks === true || fileDiff.isPartial) {
+ return lineNumber;
+ }
+
+ // Renderable [start, end) line ranges in ascending order, plus the
+ // exclusive end of the modeled range — anything past it (the editor's
+ // phantom document-end line) counts as renderable.
+ const ranges: Array<[start: number, end: number]> = [];
+ let modeledEnd = 1;
+ for (const [hunkIndex, hunk] of fileDiff.hunks.entries()) {
+ const region = getExpandedRegion({
+ isPartial: fileDiff.isPartial,
+ rangeSize: hunk.collapsedBefore,
+ expandedHunks,
+ hunkIndex,
+ collapsedContextThreshold,
+ });
+ const gapStart = hunk.additionStart - region.rangeSize;
+ if (region.renderAll) {
+ ranges.push([gapStart, hunk.additionStart]);
+ } else {
+ if (region.fromStart > 0) {
+ ranges.push([gapStart, gapStart + region.fromStart]);
+ }
+ if (region.fromEnd > 0) {
+ ranges.push([hunk.additionStart - region.fromEnd, hunk.additionStart]);
+ }
+ }
+ ranges.push([hunk.additionStart, hunk.additionStart + hunk.additionCount]);
+ modeledEnd = hunk.additionStart + hunk.additionCount;
+ }
+ const trailingRegion = getTrailingExpandedRegion({
+ fileDiff,
+ hunkIndex: fileDiff.hunks.length - 1,
+ expandedHunks,
+ collapsedContextThreshold,
+ errorPrefix: 'getNearestRenderableAdditionLine',
+ });
+ if (trailingRegion != null) {
+ const trailingStart = modeledEnd;
+ modeledEnd = trailingStart + trailingRegion.rangeSize;
+ if (trailingRegion.renderAll) {
+ ranges.push([trailingStart, modeledEnd]);
+ } else if (trailingRegion.fromStart > 0) {
+ ranges.push([trailingStart, trailingStart + trailingRegion.fromStart]);
+ }
+ }
+
+ if (lineNumber >= modeledEnd) {
+ return lineNumber;
+ }
+ if (direction === 'down') {
+ for (const [start, end] of ranges) {
+ if (end > lineNumber) {
+ return Math.max(start, lineNumber);
+ }
+ }
+ return undefined;
+ }
+ for (let index = ranges.length - 1; index >= 0; index--) {
+ const [start, end] = ranges[index];
+ if (start <= lineNumber) {
+ return Math.min(end - 1, lineNumber);
+ }
+ }
+ return undefined;
+}
+
export function getHunkSeparatorHeight({
type,
metrics,
diff --git a/packages/diffs/test/editorCollapsedEdit.test.ts b/packages/diffs/test/editorCollapsedEdit.test.ts
index ad98d834d..fa1ac30c5 100644
--- a/packages/diffs/test/editorCollapsedEdit.test.ts
+++ b/packages/diffs/test/editorCollapsedEdit.test.ts
@@ -167,7 +167,7 @@ describe('diff editor: collapsed regions during edit', () => {
// Line 50 (index 49) sits below the collapsed gap.
typeAt(fixture.editor, 49, 0, 'X');
await wait(0);
- expect(editor.getState().file.contents).toContain('Xline 50 changed');
+ expect(editor.getFile()?.contents).toContain('Xline 50 changed');
const content = findAdditionContent(container);
const rows = content?.querySelectorAll('[data-line="50"]') ?? [];
@@ -198,9 +198,7 @@ describe('diff editor: collapsed regions during edit', () => {
typeAt(editor, 49, 'line 50 changed'.length + 1, '\nNEW LINE');
await wait(20);
- expect(editor.getState().file.contents).toContain(
- 'line 50 changed\nNEW LINE'
- );
+ expect(editor.getFile()?.contents).toContain('line 50 changed\nNEW LINE');
const lineNumbers = renderedLineNumbers(container);
expect(new Set(lineNumbers).size).toBe(lineNumbers.length);
// The collapsed gap stays collapsed across the full re-render.
diff --git a/packages/diffs/test/editorFoldNavigation.test.ts b/packages/diffs/test/editorFoldNavigation.test.ts
new file mode 100644
index 000000000..35f4d5bb2
--- /dev/null
+++ b/packages/diffs/test/editorFoldNavigation.test.ts
@@ -0,0 +1,262 @@
+import { afterAll, describe, expect, test } from 'bun:test';
+
+import { FileDiff } from '../src/components/FileDiff';
+import { DEFAULT_THEMES } from '../src/constants';
+import { Editor } from '../src/editor/editor';
+import { isMoveCursorShortcut } from '../src/editor/platform';
+import { disposeHighlighter } from '../src/highlighter/shared_highlighter';
+import { installDom, wait } from './domHarness';
+
+afterAll(async () => {
+ await disposeHighlighter();
+});
+
+function findAdditionContent(container: HTMLElement): HTMLElement | undefined {
+ const shadow = container.shadowRoot;
+ if (shadow == null) {
+ return undefined;
+ }
+ for (const code of shadow.querySelectorAll('[data-code]')) {
+ if (code.dataset.deletions !== undefined) {
+ continue;
+ }
+ for (const child of code.children) {
+ const el = child as HTMLElement;
+ if (el.dataset.content !== undefined) {
+ return el;
+ }
+ }
+ }
+ return undefined;
+}
+
+interface FoldFixture {
+ container: HTMLElement;
+ editor: Editor;
+ fileDiff: FileDiff;
+ content: HTMLElement;
+ cleanup(): Promise;
+}
+
+// 60-line file, changes at lines 10 and 50: hunks cover lines 6-14 and
+// 46-54, with the gap 15-45 collapsed once the fixture re-enables collapse
+// mid-session (the standalone pre-flip vector).
+async function createFoldFixture(): Promise {
+ const dom = installDom();
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+
+ const oldContents =
+ Array.from({ length: 60 }, (_, index) => `line ${index + 1}`).join('\n') +
+ '\n';
+ const newContents = oldContents
+ .replace('line 10\n', 'line 10 changed\n')
+ .replace('line 50\n', 'line 50 changed\n');
+
+ const fileDiff = new FileDiff({
+ disableFileHeader: true,
+ theme: DEFAULT_THEMES,
+ diffStyle: 'split',
+ });
+ const editor = new Editor();
+ fileDiff.render({
+ oldFile: { name: 'edit.ts', contents: oldContents },
+ newFile: { name: 'edit.ts', contents: newContents },
+ fileContainer: container,
+ forceRender: true,
+ });
+ editor.edit(fileDiff);
+ for (let attempt = 0; attempt < 40; attempt++) {
+ const content = findAdditionContent(container);
+ if (content != null && content.getAttribute('contenteditable') === 'true') {
+ break;
+ }
+ await wait(0);
+ }
+
+ fileDiff.setOptions({ ...fileDiff.options, expandUnchanged: false });
+ fileDiff.rerender();
+ await wait(10);
+
+ return {
+ container,
+ editor,
+ fileDiff,
+ // Re-query after the collapse re-render: it can rebuild the column the
+ // editor re-listens on.
+ content: findAdditionContent(container)!,
+ async cleanup() {
+ await wait(10);
+ editor.cleanUp();
+ fileDiff.cleanUp();
+ dom.cleanup();
+ },
+ };
+}
+
+function setCaret(editor: Editor, line: number, character = 0) {
+ const position = { line, character };
+ editor.setSelections([{ start: position, end: position, direction: 'none' }]);
+}
+
+function pressKey(
+ content: HTMLElement,
+ key: string,
+ init: KeyboardEventInit = {}
+) {
+ const KeyboardEventCtor = content.ownerDocument.defaultView!.KeyboardEvent;
+ content.dispatchEvent(
+ new KeyboardEventCtor('keydown', { key, bubbles: true, ...init })
+ );
+}
+
+// isMoveCursorShortcut only reads key/modifier fields, so a plain shape
+// stands in for a KeyboardEvent outside an installed DOM.
+function keyEvent(key: string): KeyboardEvent {
+ const shape: Pick<
+ KeyboardEvent,
+ 'key' | 'altKey' | 'ctrlKey' | 'metaKey' | 'shiftKey'
+ > = { key, altKey: false, ctrlKey: false, metaKey: false, shiftKey: false };
+ // oxlint-disable-next-line typescript/consistent-type-assertions
+ return shape as KeyboardEvent;
+}
+
+describe('diff editor: fold-skip navigation', () => {
+ test('page keys are not editor-handled cursor shortcuts', () => {
+ // Page keys fall through to native contenteditable caret movement, which
+ // can only land on rendered rows once separators are read-only.
+ expect(isMoveCursorShortcut(keyEvent('PageDown'))).toBeUndefined();
+ expect(isMoveCursorShortcut(keyEvent('PageUp'))).toBeUndefined();
+ });
+
+ test('arrow-down at a hunk boundary skips the collapsed gap', async () => {
+ const fixture = await createFoldFixture();
+ const { editor, content } = fixture;
+ try {
+ // Line 14 (index 13) is the last rendered line of the first hunk.
+ setCaret(editor, 13);
+ await wait(0);
+ pressKey(content, 'ArrowDown');
+ const selections = editor.getState().selections;
+ // The caret jumps over lines 15-45 to line 46 (index 45).
+ expect(selections?.at(-1)?.start.line).toBe(45);
+ } finally {
+ await fixture.cleanup();
+ }
+ });
+
+ test('arrow-up at a hunk boundary skips the collapsed gap', async () => {
+ const fixture = await createFoldFixture();
+ const { editor, content } = fixture;
+ try {
+ setCaret(editor, 45);
+ await wait(0);
+ pressKey(content, 'ArrowUp');
+ expect(editor.getState().selections?.at(-1)?.start.line).toBe(13);
+ } finally {
+ await fixture.cleanup();
+ }
+ });
+
+ test('shift+arrow-down builds a selection spanning the gap', async () => {
+ const fixture = await createFoldFixture();
+ const { editor, content } = fixture;
+ try {
+ setCaret(editor, 13);
+ await wait(0);
+ pressKey(content, 'ArrowDown', { shiftKey: true });
+ const selection = editor.getState().selections?.at(-1);
+ // Fold semantics: the selection covers the hidden lines.
+ expect(selection?.start.line).toBe(13);
+ expect(selection?.end.line).toBe(45);
+ } finally {
+ await fixture.cleanup();
+ }
+ });
+});
+
+describe('diff editor: reveal-on-jump', () => {
+ test('setSelections into a collapsed region expands it', async () => {
+ const fixture = await createFoldFixture();
+ const { container, editor, fileDiff } = fixture;
+ try {
+ expect(fileDiff.isLineRenderable(30)).toBe(false);
+
+ // Jump the caret to line 30 (index 29), hidden inside the gap.
+ setCaret(editor, 29);
+ await wait(20);
+
+ expect(fileDiff.isLineRenderable(30)).toBe(true);
+ const content = findAdditionContent(container);
+ expect(content?.querySelector('[data-line="30"]')).not.toBeNull();
+ } finally {
+ await fixture.cleanup();
+ }
+ });
+
+ test('undo whose caret restore lands in a collapsed region expands it', async () => {
+ const fixture = await createFoldFixture();
+ const { editor, fileDiff } = fixture;
+ try {
+ // Edit line 30 while its gap is temporarily expanded, then collapse
+ // everything again by rebuilding the diff state: the undo caret restore
+ // must reveal the region on its own.
+ setCaret(editor, 29);
+ await wait(20);
+ editor.applyEdits(
+ [
+ {
+ range: {
+ start: { line: 29, character: 0 },
+ end: { line: 29, character: 0 },
+ },
+ newText: 'edited ',
+ },
+ ],
+ true
+ );
+ await wait(20);
+
+ expect(editor.canUndo).toBe(true);
+ editor.undo();
+ await wait(20);
+ expect(editor.getFile()?.contents).not.toContain('edited line 30');
+ // The caret restore targeted line 30, so it stays revealed.
+ expect(fileDiff.isLineRenderable(30)).toBe(true);
+ } finally {
+ await fixture.cleanup();
+ }
+ });
+
+ test('a same-line-count replace inside the gap materializes a rendered hunk', async () => {
+ const fixture = await createFoldFixture();
+ const { container, editor, fileDiff } = fixture;
+ try {
+ const hunksBefore = fileDiff.fileDiff!.hunks.length;
+ // Mirrors search replaceAll: a buffer edit with no active selection
+ // into a line hidden inside the collapsed gap.
+ editor.applyEdits(
+ [
+ {
+ range: {
+ start: { line: 29, character: 0 },
+ end: { line: 29, character: 'line 30'.length },
+ },
+ newText: 'REPLACED',
+ },
+ ],
+ true
+ );
+ // The deferred escalation re-render runs through the rAF queue.
+ await wait(30);
+
+ expect(fileDiff.fileDiff!.hunks.length).toBe(hunksBefore + 1);
+ const content = findAdditionContent(container);
+ const row = content?.querySelector('[data-line="30"]');
+ expect(row).not.toBeNull();
+ expect(row?.textContent).toBe('REPLACED');
+ } finally {
+ await fixture.cleanup();
+ }
+ });
+});
diff --git a/packages/diffs/test/isAdditionLineRenderable.test.ts b/packages/diffs/test/isAdditionLineRenderable.test.ts
index 8ac37665d..e9e68c969 100644
--- a/packages/diffs/test/isAdditionLineRenderable.test.ts
+++ b/packages/diffs/test/isAdditionLineRenderable.test.ts
@@ -3,7 +3,10 @@ import { describe, expect, test } from 'bun:test';
import type { FileDiffMetadata, HunkExpansionRegion } from '../src/types';
import { iterateOverDiff } from '../src/utils/iterateOverDiff';
import { parseDiffFromFile } from '../src/utils/parseDiffFromFile';
-import { isAdditionLineRenderable } from '../src/utils/virtualDiffLayout';
+import {
+ getNearestRenderableAdditionLine,
+ isAdditionLineRenderable,
+} from '../src/utils/virtualDiffLayout';
const COLLAPSED_CONTEXT_THRESHOLD = 1;
@@ -102,3 +105,58 @@ describe('isAdditionLineRenderable', () => {
).toBe(true);
});
});
+
+describe('getNearestRenderableAdditionLine', () => {
+ // The 40-line fixture's hunks (context 4) cover lines 6-14 and 26-34;
+ // gaps are 1-5, 15-25, and trailing 35-40.
+ function nearest(
+ lineNumber: number,
+ direction: 'up' | 'down',
+ expandedHunks: Map = new Map()
+ ): number | undefined {
+ return getNearestRenderableAdditionLine({
+ fileDiff: makeDiff(),
+ lineNumber,
+ direction,
+ expandedHunks,
+ collapsedContextThreshold: COLLAPSED_CONTEXT_THRESHOLD,
+ });
+ }
+
+ test('returns renderable lines unchanged', () => {
+ expect(nearest(10, 'down')).toBe(10);
+ expect(nearest(10, 'up')).toBe(10);
+ });
+
+ test('skips down across the collapsed gap to the next hunk', () => {
+ expect(nearest(15, 'down')).toBe(26);
+ });
+
+ test('skips up across the collapsed gap to the previous hunk', () => {
+ expect(nearest(25, 'up')).toBe(14);
+ });
+
+ test('lands on partially expanded gap edges', () => {
+ const expanded = new Map([
+ [1, { fromStart: 2, fromEnd: 3 }],
+ ]);
+ // Gap 15-25: lines 15-16 and 23-25 render.
+ expect(nearest(15, 'down', expanded)).toBe(15);
+ expect(nearest(17, 'down', expanded)).toBe(23);
+ expect(nearest(22, 'up', expanded)).toBe(16);
+ });
+
+ test('stays put inside the collapsed trailing gap going down', () => {
+ expect(nearest(36, 'down')).toBeUndefined();
+ expect(nearest(36, 'up')).toBe(34);
+ });
+
+ test('stays put inside the collapsed leading gap going up', () => {
+ expect(nearest(3, 'up')).toBeUndefined();
+ expect(nearest(3, 'down')).toBe(6);
+ });
+
+ test('treats lines beyond the modeled range as renderable', () => {
+ expect(nearest(41, 'down')).toBe(41);
+ });
+});
From e40bd361e6c9f26dbdc5edd0eb89b861e422ebb3 Mon Sep 17 00:00:00 2001
From: Amadeus Demarzi
Date: Fri, 10 Jul 2026 12:36:31 -0700
Subject: [PATCH 04/11] feat(diffs): Keep hunk separator expansion interactive
during editing
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Separator expand buttons keep working while an editor is attached:
their pointer wiring was already independent of the edit-mode gutter
lockdown and only unreachable because no separators rendered during
editing. With collapse live mid-session, a click reveals the gap, the
revealed context lines are immediately editable (a gap edit
synthesizes a session region), and the expansion re-render rebuilds
its AST from diff.additionLines — which the session paths keep current
every pass, so live edits survive the render-cache clear.
Covered by jsdom tests on the standalone collapse-during-edit vector:
a real separator button click mid-edit, then typing and undoing inside
the newly revealed context.
---
.../diffs/src/renderers/DiffHunksRenderer.ts | 5 +-
.../diffs/test/editorCollapsedEdit.test.ts | 69 +++++++++++++++++++
2 files changed, 73 insertions(+), 1 deletion(-)
diff --git a/packages/diffs/src/renderers/DiffHunksRenderer.ts b/packages/diffs/src/renderers/DiffHunksRenderer.ts
index b3b8c5a1d..70f7ff3d4 100644
--- a/packages/diffs/src/renderers/DiffHunksRenderer.ts
+++ b/packages/diffs/src/renderers/DiffHunksRenderer.ts
@@ -347,7 +347,10 @@ export class DiffHunksRenderer {
region.fromEnd += expansionLineCount;
}
// NOTE(amadeus): If our render cache is not highlighted, we need to clear
- // it, otherwise we won't have the correct AST lines
+ // it, otherwise we won't have the correct AST lines. Clearing is safe
+ // mid-edit-session even though the dirty cache carries live edits: both
+ // session hunk-update paths keep diff.additionLines current every pass,
+ // so the rebuilt AST reproduces the live document.
if (this.renderCache?.highlighted !== true) {
this.clearRenderCache();
}
diff --git a/packages/diffs/test/editorCollapsedEdit.test.ts b/packages/diffs/test/editorCollapsedEdit.test.ts
index fa1ac30c5..e52d51cec 100644
--- a/packages/diffs/test/editorCollapsedEdit.test.ts
+++ b/packages/diffs/test/editorCollapsedEdit.test.ts
@@ -191,6 +191,75 @@ describe('diff editor: collapsed regions during edit', () => {
}
});
+ test('clicking a separator expand button mid-edit reveals rows and keeps edits', async () => {
+ const fixture = await createCollapsedEditFixture();
+ const { container, editor } = fixture;
+ try {
+ // An edit made before the expansion, to prove the reveal re-render
+ // does not clobber the live document.
+ typeAt(editor, 9, 0, 'EDIT-');
+ await wait(0);
+
+ const content = findAdditionContent(container)!;
+ const expandButton = content
+ .querySelector('[data-separator][data-expand-index="1"]')
+ ?.querySelector('[data-expand-button]');
+ expect(expandButton).not.toBeNull();
+ const MouseEventCtor = content.ownerDocument.defaultView!.MouseEvent;
+ expandButton!.dispatchEvent(
+ new MouseEventCtor('click', { bubbles: true, composed: true })
+ );
+ await wait(10);
+
+ // The gap between the hunks is fully revealed (expansionLineCount
+ // clamps to the gap), and the pre-expansion edit survives.
+ const revealed = findAdditionContent(container)!;
+ expect(revealed.querySelector('[data-line="30"]')).not.toBeNull();
+ expect(revealed.querySelector('[data-line="10"]')?.textContent).toBe(
+ 'EDIT-line 10 changed'
+ );
+ expect(editor.getFile()?.contents).toContain('EDIT-line 10 changed');
+ } finally {
+ await fixture.cleanup();
+ }
+ });
+
+ test('typing into context revealed mid-edit lands correctly and undoes', async () => {
+ const fixture = await createCollapsedEditFixture();
+ const { container, editor, fileDiff } = fixture;
+ try {
+ // Reveal the gap through the same flow a separator click uses.
+ fileDiff.handleExpandHunk(1, 'both');
+ await wait(10);
+ const content = findAdditionContent(container)!;
+ expect(content.querySelector('[data-line="30"]')).not.toBeNull();
+
+ // Type into the revealed context line: a gap edit that synthesizes a
+ // session region.
+ typeAt(editor, 29, 0, 'typed ');
+ await wait(30);
+
+ expect(editor.getFile()?.contents).toContain('typed line 30');
+ const rows =
+ findAdditionContent(container)!.querySelectorAll('[data-line="30"]');
+ expect(rows.length).toBe(1);
+ expect(rows[0].textContent).toBe('typed line 30');
+ const lineNumbers = renderedLineNumbers(container);
+ expect(new Set(lineNumbers).size).toBe(lineNumbers.length);
+
+ // Undo restores the context text; the row stays rendered.
+ editor.undo();
+ await wait(30);
+ expect(editor.getFile()?.contents).not.toContain('typed line 30');
+ const restored =
+ findAdditionContent(container)!.querySelectorAll('[data-line="30"]');
+ expect(restored.length).toBe(1);
+ expect(restored[0].textContent).toBe('line 30');
+ } finally {
+ await fixture.cleanup();
+ }
+ });
+
test('a line-count edit below the gap re-renders without duplicates', async () => {
const fixture = await createCollapsedEditFixture();
const { container, editor } = fixture;
From e3a12dc07b0c388e84d3dccb3e0a7b69a806f36e Mon Sep 17 00:00:00 2001
From: Amadeus Demarzi
Date: Fri, 10 Jul 2026 12:47:13 -0700
Subject: [PATCH 05/11] feat(diffs): Stop forcing expandUnchanged while editing
a diff
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Entering edit mode on a diff no longer expands every collapsed
unchanged region. Collapsed regions stay collapsed: the caret skips
over them like code folds, jumps that must land inside one expand it
minimally, separator expand buttons keep working, and the layout no
longer jumps when editing starts or ends. Edits re-diff within the
current hunk regions and a genuine session end restores the normal
recompute (all built up in the preceding commits).
- Editor.edit drops expandUnchanged from its option normalization —
from the destructuring too, so the setOptions fallback (which
replaces options wholesale) can no longer strip a host's own
expandUnchanged when another forced option triggers it.
- CodeView's edit-forced option facade stops serving
expandUnchanged: true for edited diff items; the key rejoins the
plain pass-through options.
- The React contentEditable option merge stops injecting
expandUnchanged: true.
- Editors need full file contents, so attaching to a partial diff now
triggers loadDiffFiles hydration directly (previously the forced
expansion render did it as a side effect).
- The genuine session-end recompute now drives layout invalidation
explicitly (estimated-height reset plus a virtualizer height
reconcile); the expandUnchanged option flip that used to invalidate
layout at exit is gone. CodeView applies the same invalidation when
it reaps a session whose instance was released.
- Docs: the Editor guide describes the fold behavior, and the demo SSR
preloads drop the baked-in expandUnchanged (mockData.generated.ts
regenerated).
---
.../_examples/LiveDiffEditor/constants.ts | 1 -
.../(diffs)/_examples/LiveEditor/constants.ts | 3 +-
.../app/(diffs)/_home/mockData.generated.ts | 8 +--
apps/docs/app/(diffs)/_home/mockData.ts | 21 ++++----
apps/docs/app/(diffs)/docs/Editor/content.mdx | 13 +++--
packages/diffs/src/components/CodeView.ts | 12 ++---
packages/diffs/src/components/FileDiff.ts | 15 ++++--
.../src/components/VirtualizedFileDiff.ts | 20 +++++--
packages/diffs/src/editor/editor.ts | 3 --
.../src/react/utils/useFileDiffInstance.ts | 1 -
packages/diffs/test/CodeView.edit.test.ts | 54 +++++++++++++++----
.../diffs/test/editorCollapsedEdit.test.ts | 37 +++++++++++++
12 files changed, 133 insertions(+), 55 deletions(-)
diff --git a/apps/docs/app/(diffs)/_examples/LiveDiffEditor/constants.ts b/apps/docs/app/(diffs)/_examples/LiveDiffEditor/constants.ts
index 67dce9ead..543470c68 100644
--- a/apps/docs/app/(diffs)/_examples/LiveDiffEditor/constants.ts
+++ b/apps/docs/app/(diffs)/_examples/LiveDiffEditor/constants.ts
@@ -22,7 +22,6 @@ export const LIVE_DIFF_EDITOR_EXAMPLE: PreloadFileDiffOptions = {
useTokenTransformer: true,
enableGutterUtility: false,
enableLineSelection: false,
- expandUnchanged: true,
lineHoverHighlight: 'disabled',
},
};
diff --git a/apps/docs/app/(diffs)/_examples/LiveEditor/constants.ts b/apps/docs/app/(diffs)/_examples/LiveEditor/constants.ts
index 94db57bf6..cceb0df34 100644
--- a/apps/docs/app/(diffs)/_examples/LiveEditor/constants.ts
+++ b/apps/docs/app/(diffs)/_examples/LiveEditor/constants.ts
@@ -81,7 +81,6 @@ export const LIVE_EDITOR_OPTIONS: MultiFileDiffProps['options'] = {
useTokenTransformer: true,
enableGutterUtility: false,
enableLineSelection: false,
- expandUnchanged: true,
lineHoverHighlight: 'disabled',
};
@@ -97,7 +96,7 @@ export const LIVE_EDITOR_EXAMPLE: PreloadMultiFileDiffOptions = {
// File-mode options for the Live editing example. They mirror the editor's
// enforced contentEditable state (see LIVE_EDITOR_OPTIONS) so the SSR-rendered
// File matches what the editor attaches to, avoiding a rerender flash on
-// hydration. Diff-only keys (diffStyle/expandUnchanged) don't apply to a File.
+// hydration. The diff-only diffStyle key doesn't apply to a File.
export const LIVE_EDITOR_FILE_OPTIONS: FileOptions = {
theme: DEFAULT_THEMES,
themeType: 'dark',
diff --git a/apps/docs/app/(diffs)/_home/mockData.generated.ts b/apps/docs/app/(diffs)/_home/mockData.generated.ts
index f3e448f12..db8e254a0 100644
--- a/apps/docs/app/(diffs)/_home/mockData.generated.ts
+++ b/apps/docs/app/(diffs)/_home/mockData.generated.ts
@@ -30,8 +30,8 @@ export const GENERATED_AUI_SESSIONS: AuiSession[] = [
status: 'added',
before: '',
after:
- "import {\n DEFAULT_THEMES,\n type FileDiffMetadata,\n parseDiffFromFile,\n} from '@pierre/diffs';\nimport type { DiffBasePropsReact } from '@pierre/diffs/react';\nimport type { GitStatus, GitStatusEntry } from '@pierre/trees';\n\nimport { GENERATED_AUI_SESSIONS } from './mockData.generated';\n\n// Render options shared by the agent demo's SSR preload (Home.tsx) and its\n// client FileDiff (AgentUi). Beyond the visual options, these bake in the exact\n// state the editor enforces when it attaches to an editable FileDiff: the token\n// transformer on, gutter utility and line selection off, every unchanged line\n// expanded, and line-hover highlighting disabled. The editor only re-renders an\n// attached surface when these aren't already set, so if the prerendered markup\n// omits them (especially `expandUnchanged` and `useTokenTransformer`) the\n// hydrated DOM no longer matches the editor's line model — which mis-positions\n// the caret/selection and breaks editing. Sharing one constant also keeps the\n// server and client diffStyle in lockstep so the prerendered HTML always\n// matches what the client renders.\nexport const AUI_DIFF_OPTIONS: DiffBasePropsReact['options'] = {\n theme: DEFAULT_THEMES,\n themeType: 'dark',\n disableFileHeader: true,\n overflow: 'wrap',\n diffStyle: 'unified',\n useTokenTransformer: true,\n enableGutterUtility: false,\n enableLineSelection: false,\n expandUnchanged: true,\n lineHoverHighlight: 'disabled',\n};\n\n// A single file the agent changed in a session. `before`/`after` are full file\n// snapshots (real repo contents for the live session) from which we derive the\n// diff, the tree's git status, and the +/- decoration counts.\nexport interface AuiChangedFile {\n path: string;\n status: GitStatus;\n before: string;\n after: string;\n additions: number;\n deletions: number;\n}\n\nexport interface AuiSession {\n changedFiles: AuiChangedFile[];\n}\n\n// The live session(s) come from the generated snapshot of this repo.\nexport const AUI_SESSIONS: AuiSession[] = GENERATED_AUI_SESSIONS;\n\n// The flat path list the FileTree renders for a session.\nexport function getSessionPaths(session: AuiSession): string[] {\n return session.changedFiles.map((file) => file.path);\n}\n\n// Every directory id (FileTree dir ids end with `/`) that appears in a session,\n// used to seed the tree fully expanded so all changed files are visible.\nexport function getSessionDirectoryPaths(session: AuiSession): string[] {\n const dirs = new Set();\n for (const file of session.changedFiles) {\n const segments = file.path.split('/');\n let prefix = '';\n for (let index = 0; index < segments.length - 1; index += 1) {\n prefix += `${segments[index]}/`;\n dirs.add(prefix);\n }\n }\n return [...dirs];\n}\n\n// The git status colouring the FileTree applies per row.\nexport function getSessionGitStatus(session: AuiSession): GitStatusEntry[] {\n return session.changedFiles.map((file) => ({\n path: file.path,\n status: file.status,\n }));\n}\n\n// Builds the diff metadata (@pierre/diffs) for one changed file. `nextAfter`\n// lets the caller substitute live in-editor edits for the snapshot's `after`.\nexport function getFileDiff(\n file: AuiChangedFile,\n nextAfter?: string\n): FileDiffMetadata {\n return parseDiffFromFile(\n { name: file.path, contents: file.before },\n { name: file.path, contents: nextAfter ?? file.after }\n );\n}\n",
- additions: 92,
+ "import {\n DEFAULT_THEMES,\n type FileDiffMetadata,\n parseDiffFromFile,\n} from '@pierre/diffs';\nimport type { DiffBasePropsReact } from '@pierre/diffs/react';\nimport type { GitStatus, GitStatusEntry } from '@pierre/trees';\n\nimport { GENERATED_AUI_SESSIONS } from './mockData.generated';\n\n// Render options shared by the agent demo's SSR preload (Home.tsx) and its\n// client FileDiff (AgentUi). Beyond the visual options, these bake in the\n// exact state the editor enforces when it attaches to an editable FileDiff:\n// the token transformer on, gutter utility and line selection off, and\n// line-hover highlighting disabled. The editor only re-renders an attached\n// surface when these aren't already set, so if the prerendered markup omits\n// them (especially `useTokenTransformer`) the hydrated DOM no longer matches\n// the editor's line model — which mis-positions the caret/selection and\n// breaks editing. Sharing one constant also keeps the server and client\n// diffStyle in lockstep so the prerendered HTML always matches what the\n// client renders.\nexport const AUI_DIFF_OPTIONS: DiffBasePropsReact['options'] = {\n theme: DEFAULT_THEMES,\n themeType: 'dark',\n disableFileHeader: true,\n overflow: 'wrap',\n diffStyle: 'unified',\n useTokenTransformer: true,\n enableGutterUtility: false,\n enableLineSelection: false,\n lineHoverHighlight: 'disabled',\n};\n\n// A single file the agent changed in a session. `before`/`after` are full file\n// snapshots (real repo contents for the live session) from which we derive the\n// diff, the tree's git status, and the +/- decoration counts.\nexport interface AuiChangedFile {\n path: string;\n status: GitStatus;\n before: string;\n after: string;\n additions: number;\n deletions: number;\n}\n\nexport interface AuiSession {\n changedFiles: AuiChangedFile[];\n}\n\n// The live session(s) come from the generated snapshot of this repo.\nexport const AUI_SESSIONS: AuiSession[] = GENERATED_AUI_SESSIONS;\n\n// The flat path list the FileTree renders for a session.\nexport function getSessionPaths(session: AuiSession): string[] {\n return session.changedFiles.map((file) => file.path);\n}\n\n// Every directory id (FileTree dir ids end with `/`) that appears in a session,\n// used to seed the tree fully expanded so all changed files are visible.\nexport function getSessionDirectoryPaths(session: AuiSession): string[] {\n const dirs = new Set();\n for (const file of session.changedFiles) {\n const segments = file.path.split('/');\n let prefix = '';\n for (let index = 0; index < segments.length - 1; index += 1) {\n prefix += `${segments[index]}/`;\n dirs.add(prefix);\n }\n }\n return [...dirs];\n}\n\n// The directory new files/folders are created under when the fullscreen\n// explorer's toolbar \"New file\" / \"New folder\" buttons are used. It's an\n// ancestor of the demo's changed files, so it already exists (and starts\n// expanded) in the full tree below.\nexport const AUI_EXPLORER_NEW_DIR = 'apps/docs/app/(diffs)/_home/';\n\n// A curated, realistic project tree for the fullscreen editor's left-hand file\n// explorer. Unlike the Changes panel (which lists only what the agent touched),\n// this reads like a real workspace sidebar: it includes the session's changed\n// files verbatim — so their git-status colours land on the right rows and\n// selecting one opens its diff — surrounded by the neighbouring sources that\n// make the tree feel complete. It's static demo content, not a live repo scan.\nexport const AUI_FULL_TREE_PATHS: string[] = [\n 'apps/docs/app/(diffs)/_home/AgentDemoSection.tsx',\n 'apps/docs/app/(diffs)/_home/AgentUi.tsx',\n 'apps/docs/app/(diffs)/_home/Home.tsx',\n 'apps/docs/app/(diffs)/_home/agent-ui.css',\n 'apps/docs/app/(diffs)/_home/mockData.ts',\n 'apps/docs/app/(diffs)/_home/mockData.generated.ts',\n 'apps/docs/app/(diffs)/_home/preloadAuiDiffs.ts',\n 'apps/docs/app/(diffs)/edit/live/page.tsx',\n 'apps/docs/app/(diffs)/edit/page.tsx',\n 'apps/docs/app/(diffs)/playground/PlaygroundClient.tsx',\n 'apps/docs/app/(diffs)/playground/page.tsx',\n 'apps/docs/app/(diffs)/layout.tsx',\n 'apps/docs/app/globals.css',\n 'apps/docs/app/layout.tsx',\n 'apps/docs/components/Footer.tsx',\n 'apps/docs/components/Header.tsx',\n 'apps/docs/components/ui/button-group.tsx',\n 'apps/docs/scripts/generate-aui-mock-data.ts',\n 'apps/docs/next.config.mjs',\n 'apps/docs/package.json',\n 'apps/docs/tsconfig.json',\n 'packages/diffs/src/editor/editor.ts',\n 'packages/diffs/src/react/FileDiff.tsx',\n 'packages/diffs/src/index.ts',\n 'packages/diffs/src/style.css',\n 'packages/diffs/package.json',\n 'packages/diffs/README.md',\n 'packages/trees/src/react/FileTree.tsx',\n 'packages/trees/src/render/FileTree.ts',\n 'packages/trees/src/index.ts',\n 'packages/trees/package.json',\n 'packages/icons/src/index.ts',\n 'packages/icons/package.json',\n '.gitignore',\n 'AGENTS.md',\n 'README',\n 'package.json',\n 'pnpm-workspace.yaml',\n 'tsconfig.json',\n];\n\n// The repo's real root README, shown verbatim when the explorer opens it so at\n// least one \"browse\" file in the demo is genuine rather than a stand-in.\nconst ROOT_README_CONTENTS = `PIERRE COMPUTER COMPANY █\nOPEN SOURCE [TYPESCRIPT]\n\n~~~\n\nCONTACT: SUPPORT@PIERRE.CO\nLOCATION: USA\nSTATUS: ONLINE\n\n~~~\n\nOPEN SOURCE PROJECTS:\n - [Diffs](https://diffs.com)\n - [Trees](https://trees.software)\n`;\n\n// Verbatim contents for specific explorer paths. Anything not listed here falls\n// back to a generated stand-in via getPlaceholderContents.\nconst AUI_PLACEHOLDER_CONTENTS: Record = {\n README: ROOT_README_CONTENTS,\n};\n\n// Contents shown when the fullscreen explorer opens a file that isn't part of\n// the agent's change set. Real files (e.g. the root README) come through\n// verbatim; everything else gets a friendly stand-in so browsing the full tree\n// never lands on a blank surface.\nexport function getPlaceholderContents(path: string): string {\n const verbatim = AUI_PLACEHOLDER_CONTENTS[path];\n if (verbatim != null) {\n return verbatim;\n }\n return `// ${path}\n//\n// Placeholder file in the Pierre Diffs demo workspace. This file isn't part of\n// the current agent change set, so there's nothing to diff — it's a stand-in so\n// you can browse the full project tree.\n\nexport {};\n`;\n}\n\n// The git status colouring the FileTree applies per row.\nexport function getSessionGitStatus(session: AuiSession): GitStatusEntry[] {\n return session.changedFiles.map((file) => ({\n path: file.path,\n status: file.status,\n }));\n}\n\n// Builds the diff metadata (@pierre/diffs) for one changed file. `nextAfter`\n// lets the caller substitute live in-editor edits for the snapshot's `after`.\nexport function getFileDiff(\n file: AuiChangedFile,\n nextAfter?: string\n): FileDiffMetadata {\n return parseDiffFromFile(\n { name: file.path, contents: file.before },\n { name: file.path, contents: nextAfter ?? file.after }\n );\n}\n",
+ additions: 188,
deletions: 0,
},
{
@@ -39,8 +39,8 @@ export const GENERATED_AUI_SESSIONS: AuiSession[] = [
status: 'added',
before: '',
after:
- "/* Scoped agent-UI chrome for the homepage agent-review demo. Everything lives\n under `.aui` so it never leaks into the rest of the docs app. Colours use\n light-dark(); the `.aui[data-theme-type]` rules pin color-scheme to the\n demo's own toggle. The UI sits in normal page flow as a fixed-height card. */\n.aui {\n --ide-surface: light-dark(#ffffff, #070707);\n --ide-panel: light-dark(#f8f8f8, #0f0f10);\n --ide-border: light-dark(rgb(0 0 0 / 0.08), rgb(255 255 255 / 0.07));\n --ide-fg: light-dark(#18181b, #e4e4e7);\n --ide-fg-muted: light-dark(#71717a, #a1a1aa);\n --ide-tab-active: light-dark(#ffffff, #1c1c1f);\n --ide-tab-hover: light-dark(rgb(0 0 0 / 0.04), rgb(255 255 255 / 0.05));\n --ide-accent: light-dark(#2563eb, #3b82f6);\n\n display: grid;\n grid-template-rows: minmax(0, 1fr);\n height: 560px;\n max-height: 72vh;\n border: 1px solid var(--ide-border);\n border-radius: 12px;\n overflow: hidden;\n box-shadow: 0 1px 2px rgb(0 0 0 / 0.05);\n background-color: var(--ide-surface);\n color: var(--ide-fg);\n font-family: var(--font-geist-sans), system-ui, sans-serif;\n font-size: 13px;\n}\n\n.aui[data-theme-type='light'] {\n color-scheme: light;\n}\n\n.aui[data-theme-type='dark'] {\n color-scheme: dark;\n}\n\n/* Give the demo more room on desktop. */\n@media (width >= 1024px) {\n .aui {\n height: 760px;\n max-height: 75vh;\n }\n}\n\n.aui button {\n font-family: inherit;\n cursor: pointer;\n}\n\n.aui code {\n font-family: var(--font-berkeley-mono), monospace;\n font-size: 0.9em;\n padding: 1px 4px;\n border-radius: 4px;\n background-color: var(--ide-tab-hover);\n}\n\n.aui-body {\n display: grid;\n grid-template-columns: minmax(0, 1fr) 380px;\n min-height: 0;\n}\n\n/* --- Center column ------------------------------------------------------- */\n\n.aui-center {\n display: flex;\n flex-direction: column;\n min-width: 0;\n min-height: 0;\n}\n\n.aui-center-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 12px;\n height: 44px;\n padding-inline: 12px;\n border-bottom: 1px solid var(--ide-border);\n}\n\n.aui-breadcrumb {\n display: flex;\n align-items: center;\n min-width: 0;\n overflow: hidden;\n font-size: 14px;\n color: var(--ide-fg-muted);\n}\n\n.aui-crumb:not(:last-child)::after {\n content: '/';\n margin-inline: 2px;\n opacity: 0.5;\n}\n\n.aui-crumb[data-leaf='true'] {\n color: var(--ide-fg);\n font-weight: 500;\n}\n\n.aui-surface-wrap {\n position: relative;\n flex: 1;\n min-height: 0;\n overflow: auto;\n overscroll-behavior: contain;\n}\n\n.aui-surface {\n display: block;\n}\n\n.aui-empty {\n display: flex;\n align-items: center;\n justify-content: center;\n height: 100%;\n color: var(--ide-fg-muted);\n}\n\n.aui-composer {\n display: flex;\n flex-direction: column;\n gap: 8px;\n margin: 12px;\n padding: 10px 10px 8px;\n border: 1px solid var(--ide-border);\n border-radius: 12px;\n background-color: var(--ide-panel);\n}\n\n.aui-composer:focus-within {\n border-color: var(--ide-accent);\n}\n\n/* Snippets sent from the selection action's \"Add to chat\" stack above the\n composer input as removable code attachments. */\n.aui-composer-attachments {\n display: flex;\n flex-direction: column;\n gap: 6px;\n max-height: 160px;\n margin: 0;\n padding: 0;\n overflow-y: auto;\n list-style: none;\n}\n\n.aui-attachment {\n position: relative;\n border: 1px solid var(--ide-border);\n border-radius: 8px;\n overflow: hidden;\n background-color: var(--ide-surface);\n}\n\n.aui-attachment-code {\n display: block;\n overflow-x: auto;\n}\n\n.aui-attachment-remove {\n position: absolute;\n top: 4px;\n right: 4px;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 20px;\n height: 20px;\n border: 0;\n border-radius: 6px;\n background-color: color-mix(in srgb, var(--ide-surface) 70%, transparent);\n color: var(--ide-fg-muted);\n}\n\n.aui-attachment-remove:hover {\n background-color: var(--ide-tab-hover);\n color: var(--ide-fg);\n}\n\n.aui-composer-input {\n width: 100%;\n min-height: 40px;\n padding: 2px 4px;\n border: none;\n background: none;\n color: var(--ide-fg);\n font-size: 13px;\n font-family: inherit;\n line-height: 1.5;\n resize: none;\n}\n\n.aui-composer-input:focus {\n outline: none;\n}\n\n.aui-composer-input::placeholder {\n color: var(--ide-fg-muted);\n}\n\n.aui-composer-toolbar {\n display: flex;\n align-items: center;\n gap: 6px;\n}\n\n.aui-composer-select,\n.aui-composer-send {\n display: inline-flex;\n align-items: center;\n border: 0;\n border-radius: 9px;\n background: none;\n color: var(--ide-fg-muted);\n\n &[disabled] {\n cursor: default;\n }\n}\n\n.aui-composer-select {\n gap: 5px;\n height: 28px;\n padding-inline: 8px;\n background-color: var(--ide-tab-hover);\n font-size: 12px;\n}\n\n.aui-composer-send {\n justify-content: center;\n width: 28px;\n height: 28px;\n background-color: var(--ide-accent);\n color: #ffffff;\n\n &[disabled] {\n opacity: 0.6;\n }\n}\n\n/* --- Right changes panel ------------------------------------------------- */\n\n.aui-changes {\n display: flex;\n flex-direction: column;\n min-height: 0;\n border-left: 1px solid var(--ide-border);\n background-color: var(--ide-panel);\n}\n\n.aui-changes-tabs {\n display: flex;\n align-items: center;\n gap: 4px;\n height: 44px;\n padding-inline: 10px;\n border-bottom: 1px solid var(--ide-border);\n}\n\n.aui-changes-tabs button {\n padding: 4px 10px;\n border: none;\n border-radius: 6px;\n background-color: transparent;\n color: var(--ide-fg-muted);\n font-size: 12px;\n}\n\n.aui-changes-tabs button[aria-selected='true'] {\n background-color: var(--ide-tab-active);\n color: var(--ide-fg);\n}\n\n.aui-changes-tabs button[disabled] {\n cursor: default;\n opacity: 0.6;\n}\n\n.aui-tree {\n flex: 1;\n min-height: 0;\n overflow: auto;\n padding-top: 6px;\n}\n\n/* --- Mobile -------------------------------------------------------------- */\n\n/* On narrow screens there isn't room for the changes sidebar, so collapse to a\n single full-width column showing just the active file. Placed last so it wins\n over the base `.aui-body`/`.aui-changes` rules above (media queries don't add\n specificity). */\n@media (width < 640px) {\n .aui-body {\n grid-template-columns: minmax(0, 1fr);\n }\n\n .aui-changes {\n display: none;\n }\n}\n",
- additions: 304,
+ "/* Scoped agent-UI chrome for the homepage agent-review demo. Everything lives\n under `.aui` so it never leaks into the rest of the docs app. Colours use\n light-dark(); the `.aui[data-theme-type]` rules pin color-scheme to the\n demo's own toggle. The UI sits in normal page flow as a fixed-height card. */\n.aui {\n --ide-surface: light-dark(#ffffff, #070707);\n --ide-panel: light-dark(#f8f8f8, #0f0f10);\n --ide-border: light-dark(rgb(0 0 0 / 0.08), rgb(255 255 255 / 0.07));\n --ide-fg: light-dark(#18181b, #e4e4e7);\n --ide-fg-muted: light-dark(#71717a, #a1a1aa);\n --ide-tab-active: light-dark(#ffffff, #1c1c1f);\n --ide-tab-hover: light-dark(rgb(0 0 0 / 0.04), rgb(255 255 255 / 0.05));\n --ide-accent: light-dark(#2563eb, #3b82f6);\n\n display: grid;\n /* Single body row that fills the card. There's no separate titlebar: the\n macOS-style traffic-light controls live inline (left of the breadcrumb in\n the windowed card, in the explorer header in fullscreen). */\n grid-template-rows: minmax(0, 1fr);\n height: 560px;\n max-height: 72vh;\n border: 1px solid var(--ide-border);\n border-radius: 12px;\n overflow: hidden;\n box-shadow: 0 1px 2px rgb(0 0 0 / 0.05);\n background-color: var(--ide-surface);\n color: var(--ide-fg);\n font-family: var(--font-geist-sans), system-ui, sans-serif;\n font-size: 13px;\n\n /* Shared element for the windowed <-> fullscreen morph. Both variants render\n a single `.aui`, so the cross-route View Transition (driven manually in\n AgentUi.tsx) FLIP-animates this element's size/position between them. */\n view-transition-name: aui-window;\n}\n\n.aui[data-theme-type='light'] {\n color-scheme: light;\n}\n\n.aui[data-theme-type='dark'] {\n color-scheme: dark;\n}\n\n/* Give the demo more room on desktop. */\n@media (width >= 1024px) {\n .aui {\n height: 760px;\n max-height: 75vh;\n }\n}\n\n.aui button {\n font-family: inherit;\n cursor: pointer;\n}\n\n.aui code {\n font-family: var(--font-berkeley-mono), monospace;\n font-size: 0.9em;\n padding: 1px 4px;\n border-radius: 4px;\n background-color: var(--ide-tab-hover);\n}\n\n/* --- Window controls (traffic lights) ----------------------------------- */\n\n/* macOS traffic lights. Decorative dots are s; the actionable one (green\n in the windowed card, red/green in fullscreen) is a Link/button that drives\n the view transition. Glyphs reveal on cluster hover like the real control.\n The shared view-transition-name lets the cluster glide between its windowed\n (left of breadcrumb) and fullscreen (explorer header) positions during the\n cross-route morph. */\n.aui-traffic {\n display: flex;\n align-items: center;\n gap: 8px;\n view-transition-name: aui-traffic;\n}\n\n.aui-dot {\n position: relative;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 12px;\n height: 12px;\n padding: 0;\n border: 0;\n border-radius: 50%;\n background-color: var(--dot);\n box-shadow: inset 0 0 0 0.5px rgb(0 0 0 / 0.18);\n transition: transform 0.12s ease;\n}\n\n.aui-dot.is-close {\n --dot: #ff5f57;\n}\n\n.aui-dot.is-min {\n --dot: #febc2e;\n}\n\n.aui-dot.is-zoom {\n --dot: #28c840;\n}\n\na.aui-dot,\nbutton.aui-dot {\n cursor: pointer;\n}\n\n.aui-dot::after {\n position: absolute;\n inset: 0;\n content: '';\n text-align: center;\n font-size: 10px;\n font-weight: 700;\n line-height: 11px;\n color: rgb(0 0 0 / 0.6);\n opacity: 0;\n transition: opacity 0.12s ease;\n}\n\n.aui-traffic:hover .aui-dot::after {\n opacity: 1;\n}\n\n.aui-traffic:hover .aui-dot.is-close::after {\n content: '\\00d7';\n}\n\n.aui-traffic:hover .aui-dot.is-min::after {\n content: '\\2013';\n}\n\n.aui-traffic:hover .aui-dot.is-zoom::after {\n content: '\\002b';\n}\n\na.aui-dot.is-zoom:hover,\nbutton.aui-dot.is-zoom:hover {\n transform: scale(1.15);\n}\n\n.aui-dot:focus-visible {\n outline: 2px solid var(--ide-accent);\n outline-offset: 2px;\n}\n\n.aui-body {\n display: grid;\n grid-template-columns: minmax(0, 1fr) 380px;\n min-height: 0;\n}\n\n/* --- Center column ------------------------------------------------------- */\n\n.aui-center {\n display: flex;\n flex-direction: column;\n min-width: 0;\n min-height: 0;\n}\n\n.aui-center-header {\n display: flex;\n align-items: center;\n gap: 12px;\n height: 44px;\n padding-inline: 12px;\n border-bottom: 1px solid var(--ide-border);\n}\n\n.aui-breadcrumb {\n display: flex;\n align-items: center;\n min-width: 0;\n overflow: hidden;\n font-size: 14px;\n color: var(--ide-fg-muted);\n}\n\n.aui-crumb:not(:last-child)::after {\n content: '/';\n margin-inline: 2px;\n opacity: 0.5;\n}\n\n.aui-crumb[data-leaf='true'] {\n color: var(--ide-fg);\n font-weight: 500;\n}\n\n.aui-surface-wrap {\n position: relative;\n flex: 1;\n min-height: 0;\n overflow: auto;\n overscroll-behavior: contain;\n}\n\n.aui-surface {\n display: block;\n}\n\n.aui-empty {\n display: flex;\n align-items: center;\n justify-content: center;\n height: 100%;\n color: var(--ide-fg-muted);\n}\n\n.aui-composer {\n display: flex;\n flex-direction: column;\n gap: 8px;\n margin: 12px;\n padding: 10px 10px 8px;\n border: 1px solid var(--ide-border);\n border-radius: 12px;\n background-color: var(--ide-panel);\n}\n\n.aui-composer:focus-within {\n border-color: var(--ide-accent);\n}\n\n/* Snippets sent from the selection action's \"Add to chat\" stack above the\n composer input as removable code attachments. */\n.aui-composer-attachments {\n display: flex;\n flex-direction: column;\n gap: 6px;\n max-height: 160px;\n margin: 0;\n padding: 0;\n overflow-y: auto;\n list-style: none;\n}\n\n.aui-attachment {\n position: relative;\n border: 1px solid var(--ide-border);\n border-radius: 8px;\n overflow: hidden;\n background-color: var(--ide-surface);\n}\n\n.aui-attachment-header {\n display: flex;\n align-items: center;\n gap: 5px;\n min-width: 0;\n height: 28px;\n padding: 0 32px 0 8px;\n border-bottom: 1px solid var(--ide-border);\n color: var(--ide-fg-muted);\n font-size: 12px;\n line-height: 1;\n}\n\n.aui-attachment-header svg {\n flex: 0 0 auto;\n width: 14px;\n height: 14px;\n}\n\n.aui-attachment-file {\n min-width: 0;\n overflow: hidden;\n color: var(--ide-fg);\n font-weight: 500;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.aui-attachment-lines {\n flex: 0 0 auto;\n}\n\n.aui-attachment-code {\n display: block;\n max-height: 132px;\n overflow: auto;\n}\n\n.aui-attachment-remove {\n position: absolute;\n top: 4px;\n right: 4px;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 20px;\n height: 20px;\n border: 0;\n border-radius: 6px;\n background-color: color-mix(in srgb, var(--ide-surface) 70%, transparent);\n color: var(--ide-fg-muted);\n}\n\n.aui-attachment-remove:hover {\n background-color: var(--ide-tab-hover);\n color: var(--ide-fg);\n}\n\n.aui-composer-input {\n width: 100%;\n min-height: 40px;\n padding: 2px 4px;\n border: none;\n background: none;\n color: var(--ide-fg);\n font-size: 13px;\n font-family: inherit;\n line-height: 1.5;\n resize: none;\n}\n\n.aui-composer-input:focus {\n outline: none;\n}\n\n.aui-composer-input::placeholder {\n color: var(--ide-fg-muted);\n}\n\n.aui-composer-toolbar {\n display: flex;\n align-items: center;\n gap: 6px;\n}\n\n.aui-composer-select,\n.aui-composer-send {\n display: inline-flex;\n align-items: center;\n border: 0;\n border-radius: 9px;\n background: none;\n color: var(--ide-fg-muted);\n\n &[disabled] {\n cursor: default;\n }\n}\n\n.aui-composer-select {\n gap: 5px;\n height: 28px;\n padding-inline: 8px;\n background-color: var(--ide-tab-hover);\n font-size: 12px;\n}\n\n.aui-composer-send {\n justify-content: center;\n width: 28px;\n height: 28px;\n background-color: var(--ide-accent);\n color: #ffffff;\n\n &[disabled] {\n opacity: 0.6;\n }\n}\n\n/* --- Right changes panel ------------------------------------------------- */\n\n.aui-changes {\n display: flex;\n flex-direction: column;\n min-height: 0;\n border-left: 1px solid var(--ide-border);\n background-color: var(--ide-panel);\n}\n\n.aui-changes-tabs {\n display: flex;\n align-items: center;\n gap: 4px;\n height: 44px;\n padding-inline: 10px;\n border-bottom: 1px solid var(--ide-border);\n}\n\n.aui-changes-tabs button {\n padding: 4px 10px;\n border: none;\n border-radius: 6px;\n background-color: transparent;\n color: var(--ide-fg-muted);\n font-size: 12px;\n}\n\n.aui-changes-tabs button[aria-selected='true'] {\n background-color: var(--ide-tab-active);\n color: var(--ide-fg);\n}\n\n.aui-changes-tabs button[disabled] {\n cursor: default;\n opacity: 0.6;\n}\n\n/* Live count of changed files (formerly in the titlebar), shown as a pill on\n the selected Changes tab. */\n.aui-changes-count {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n min-width: 16px;\n height: 16px;\n margin-left: 6px;\n padding-inline: 5px;\n border-radius: 999px;\n background-color: var(--ide-tab-hover);\n color: var(--ide-fg-muted);\n font-size: 11px;\n font-variant-numeric: tabular-nums;\n line-height: 1;\n}\n\n.aui-changes-tabs button[aria-selected='true'] .aui-changes-count {\n color: var(--ide-fg);\n}\n\n.aui-tree {\n flex: 1;\n min-height: 0;\n overflow: auto;\n padding-top: 6px;\n}\n\n/* --- Fullscreen variant (/edit/live) ------------------------------------- */\n\n/* The standalone route fills the viewport with no surrounding page chrome and\n adds a full project file explorer on the left, keeping the changes panel on\n the right — like a real editor. Higher specificity than the windowed `.aui`\n rules (including those inside the desktop media query, since media queries\n don't add specificity) so it wins regardless of source order. */\n.aui[data-variant='fullscreen'] {\n position: fixed;\n inset: 0;\n z-index: 60;\n width: 100vw;\n height: 100dvh;\n max-height: none;\n border: 0;\n border-radius: 0;\n box-shadow: none;\n}\n\n/* Three columns: the full file explorer (left), the editor (center), and the\n existing changes panel (right, unchanged from the windowed layout). The left\n panel only renders in the fullscreen variant, so the windowed card keeps its\n two-column body. */\n.aui[data-variant='fullscreen'] .aui-body {\n grid-template-columns: minmax(220px, 300px) minmax(0, 1fr) 340px;\n}\n\n/* The fullscreen editor gets the breathing room of the full viewport, so widen\n the composer's max line measure a touch via centered padding. */\n.aui[data-variant='fullscreen'] .aui-composer {\n margin-inline: max(12px, calc((100% - 900px) / 2));\n}\n\n/* --- Left file explorer (fullscreen only) -------------------------------- */\n\n.aui-files {\n display: flex;\n flex-direction: column;\n min-height: 0;\n border-right: 1px solid var(--ide-border);\n background-color: var(--ide-panel);\n}\n\n.aui-files-toolbar {\n display: flex;\n align-items: center;\n gap: 8px;\n height: 44px;\n padding-inline: 12px 8px;\n border-bottom: 1px solid var(--ide-border);\n}\n\n/* Window controls sit on the left; the file actions fill the rest and align\n right. */\n.aui-files-toolbar .aui-traffic {\n margin-right: auto;\n}\n\n.aui-files-actions {\n display: flex;\n align-items: center;\n gap: 2px;\n}\n\n.aui-files-action {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 26px;\n height: 26px;\n border: 0;\n border-radius: 6px;\n background-color: transparent;\n color: var(--ide-fg-muted);\n cursor: pointer;\n}\n\n.aui-files-action svg {\n width: 15px;\n height: 15px;\n}\n\n.aui-files-action:hover {\n background-color: var(--ide-tab-active);\n color: var(--ide-fg);\n}\n\n.aui-files-action[aria-pressed='true'] {\n background-color: var(--ide-tab-active);\n color: var(--ide-fg);\n}\n\n.aui-files-tree {\n flex: 1;\n min-height: 0;\n overflow: auto;\n padding-top: 6px;\n}\n\n/* --- Cross-route morph (View Transitions API) ---------------------------- */\n\n/* React assigns `view-transition-name: aui-window` to the `.aui` element during\n the navigation between the windowed card and the fullscreen route, so the\n browser FLIP-morphs its size/position. Tune the timing for an editor-window\n \"zoom\" feel and cross-fade the differing contents over the same duration. */\n::view-transition-group(aui-window) {\n animation-duration: 0.42s;\n animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1);\n}\n\n::view-transition-old(aui-window),\n::view-transition-new(aui-window) {\n animation-duration: 0.42s;\n animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1);\n}\n\n::view-transition-group(aui-traffic) {\n animation-duration: 0.42s;\n animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1);\n}\n\n@media (prefers-reduced-motion: reduce) {\n ::view-transition-group(aui-window),\n ::view-transition-old(aui-window),\n ::view-transition-new(aui-window),\n ::view-transition-group(aui-traffic),\n ::view-transition-old(aui-traffic),\n ::view-transition-new(aui-traffic) {\n animation: none;\n }\n}\n\n/* --- Mobile -------------------------------------------------------------- */\n\n/* On narrow screens there isn't room for the changes sidebar, so collapse to a\n single full-width column showing just the active file. Placed last so it wins\n over the base `.aui-body`/`.aui-changes` rules above (media queries don't add\n specificity). */\n@media (width < 640px) {\n .aui-body {\n grid-template-columns: minmax(0, 1fr);\n }\n\n .aui-changes {\n display: none;\n }\n\n /* The fullscreen route is a file navigator first, so keep the explorer on the\n left even when there's no room for it in the embedded windowed card; the\n changes panel stays hidden at this width. */\n .aui[data-variant='fullscreen'] .aui-body {\n grid-template-columns: minmax(0, 44%) minmax(0, 1fr);\n }\n}\n",
+ additions: 591,
deletions: 0,
},
{
diff --git a/apps/docs/app/(diffs)/_home/mockData.ts b/apps/docs/app/(diffs)/_home/mockData.ts
index c4b56341e..1f72bf504 100644
--- a/apps/docs/app/(diffs)/_home/mockData.ts
+++ b/apps/docs/app/(diffs)/_home/mockData.ts
@@ -9,16 +9,16 @@ import type { GitStatus, GitStatusEntry } from '@pierre/trees';
import { GENERATED_AUI_SESSIONS } from './mockData.generated';
// Render options shared by the agent demo's SSR preload (Home.tsx) and its
-// client FileDiff (AgentUi). Beyond the visual options, these bake in the exact
-// state the editor enforces when it attaches to an editable FileDiff: the token
-// transformer on, gutter utility and line selection off, every unchanged line
-// expanded, and line-hover highlighting disabled. The editor only re-renders an
-// attached surface when these aren't already set, so if the prerendered markup
-// omits them (especially `expandUnchanged` and `useTokenTransformer`) the
-// hydrated DOM no longer matches the editor's line model — which mis-positions
-// the caret/selection and breaks editing. Sharing one constant also keeps the
-// server and client diffStyle in lockstep so the prerendered HTML always
-// matches what the client renders.
+// client FileDiff (AgentUi). Beyond the visual options, these bake in the
+// exact state the editor enforces when it attaches to an editable FileDiff:
+// the token transformer on, gutter utility and line selection off, and
+// line-hover highlighting disabled. The editor only re-renders an attached
+// surface when these aren't already set, so if the prerendered markup omits
+// them (especially `useTokenTransformer`) the hydrated DOM no longer matches
+// the editor's line model — which mis-positions the caret/selection and
+// breaks editing. Sharing one constant also keeps the server and client
+// diffStyle in lockstep so the prerendered HTML always matches what the
+// client renders.
export const AUI_DIFF_OPTIONS: DiffBasePropsReact['options'] = {
theme: DEFAULT_THEMES,
themeType: 'dark',
@@ -28,7 +28,6 @@ export const AUI_DIFF_OPTIONS: DiffBasePropsReact['options'] = {
useTokenTransformer: true,
enableGutterUtility: false,
enableLineSelection: false,
- expandUnchanged: true,
lineHoverHighlight: 'disabled',
};
diff --git a/apps/docs/app/(diffs)/docs/Editor/content.mdx b/apps/docs/app/(diffs)/docs/Editor/content.mdx
index 1408f7942..35f41a8da 100644
--- a/apps/docs/app/(diffs)/docs/Editor/content.mdx
+++ b/apps/docs/app/(diffs)/docs/Editor/content.mdx
@@ -51,11 +51,14 @@ You render the surface first, then attach editing:
annotations when present).
When attaching, edit mode normalizes component options that conflict with
-editing and triggers a re-render: it turns on the token transformer, turns off
-gutter utilities, line selection, and line-hover highlighting, and expands any
-collapsed unchanged regions. The diff layout is preserved, so a `FileDiff` or
-`MultiFileDiff` stays in whatever view it was rendered in; in unified diffs,
-deleted lines and annotation lines remain read-only.
+editing and triggers a re-render: it turns on the token transformer and turns
+off gutter utilities, line selection, and line-hover highlighting. Collapsed
+unchanged regions stay collapsed: arrow keys skip over them like code folds,
+jumps that must land inside one (search matches, undo, Cmd/Ctrl+End) expand it
+just enough to show the target, and the separator expand buttons keep working.
+The diff layout is preserved, so a `FileDiff` or `MultiFileDiff` stays in
+whatever view it was rendered in; in unified diffs, deleted lines and annotation
+lines remain read-only.
### React
diff --git a/packages/diffs/src/components/CodeView.ts b/packages/diffs/src/components/CodeView.ts
index 4df46c526..2a06bee68 100644
--- a/packages/diffs/src/components/CodeView.ts
+++ b/packages/diffs/src/components/CodeView.ts
@@ -333,7 +333,6 @@ const CODE_VIEW_EDIT_FORCED_OPTION_KEYS: ReadonlySet = new Set([
'enableGutterUtility',
'enableLineSelection',
'lineHoverHighlight',
- 'expandUnchanged',
]);
type CodeViewPassThroughOptions = Pick<
@@ -2108,8 +2107,10 @@ export class CodeView {
if (
itemSnapshot?.type === 'diff' &&
finishEditSessionForDiff(itemSnapshot.fileDiff) &&
- item != null
+ item != null &&
+ item.type === 'diff'
) {
+ item.instance.invalidateEditSessionLayout();
this.markItemLayoutDirty(item);
this.render();
}
@@ -2232,8 +2233,6 @@ export class CodeView {
// Edit-forced options: while the item is in edit mode these serve the
// values Editor.edit requires so it never falls back to
// instance.setOptions (which throws for CodeView-managed instances).
- // Diffs additionally require expandUnchanged so every editable line of
- // the new file is renderable.
defineItemOption(prototype, 'useTokenTransformer', (receiver) =>
this.isReceiverEdited(receiver, 'diff')
? true
@@ -2254,11 +2253,6 @@ export class CodeView {
? 'disabled'
: this.options.lineHoverHighlight
);
- defineItemOption(prototype, 'expandUnchanged', (receiver) =>
- this.isReceiverEdited(receiver, 'diff')
- ? true
- : this.options.expandUnchanged
- );
defineItemOption(
prototype,
diff --git a/packages/diffs/src/components/FileDiff.ts b/packages/diffs/src/components/FileDiff.ts
index 365a54f3d..680aa729d 100644
--- a/packages/diffs/src/components/FileDiff.ts
+++ b/packages/diffs/src/components/FileDiff.ts
@@ -1273,6 +1273,11 @@ export class FileDiff<
if (this.type === 'file-diff') {
this.hunksRenderer.beginEditSession(this.fileDiffCache);
}
+ // The editor sync below refuses partial diffs (it needs the full file
+ // contents); kick off hydration so the loaded re-render re-runs it.
+ if (this.fileDiff?.isPartial === true) {
+ this.loadFilesIfNecessary();
+ }
this.syncRenderViewToEditor();
return (recycle?: boolean) => {
this.editor = undefined;
@@ -1287,9 +1292,11 @@ export class FileDiff<
}
// Genuine session exit: restore recompute-shaped hunks (a context-only
- // region collapses away, boundaries re-derive) and repaint. Safe on a
- // cleaned-up instance — the recompute is pure metadata work and rerender is
- // enabled-guarded.
+ // region collapses away, boundaries re-derive) and repaint through the
+ // session render path, which also invalidates virtualized layout — nothing
+ // else invalidates layout at exit now that editing does not flip
+ // expandUnchanged. Safe on a cleaned-up instance: the recompute is pure
+ // metadata work and the deferred rerender is enabled-guarded.
private finishEditSession(): void {
this.hunksRenderer.endEditSession();
const fileDiff = this.fileDiffCache;
@@ -1297,7 +1304,7 @@ export class FileDiff<
fileDiff != null &&
finishEditSessionForDiff(fileDiff, this.options.parseDiffOptions)
) {
- this.rerender();
+ this.escalateEditSessionRender();
}
}
diff --git a/packages/diffs/src/components/VirtualizedFileDiff.ts b/packages/diffs/src/components/VirtualizedFileDiff.ts
index 3803dafc9..15c69d6e0 100644
--- a/packages/diffs/src/components/VirtualizedFileDiff.ts
+++ b/packages/diffs/src/components/VirtualizedFileDiff.ts
@@ -940,10 +940,15 @@ export class VirtualizedFileDiff<
});
}
- // Session region changes alter the rendered row set without a line-count
- // change, so the layout caches need the same invalidation a document change
- // gets; rerender() defers the actual render through the virtualizer queue.
- protected override escalateEditSessionRender(): void {
+ /**
+ * Invalidate layout after an edit session changed the rendered row set
+ * without a line-count change (a mid-session region change or the exit
+ * recompute): estimated heights bake the hunk shapes in, and nothing else
+ * invalidates them now that editing does not flip expandUnchanged. Public
+ * so CodeView can run it when reaping a session whose instance was already
+ * released.
+ */
+ public invalidateEditSessionLayout(): void {
this.getSimpleVirtualizer()?.markDOMDirty();
this.resetLayoutCache({
forceSimpleRecompute: this.isSimpleMode(),
@@ -953,6 +958,13 @@ export class VirtualizedFileDiff<
if (!this.isSimpleMode()) {
this.computeApproximateSize(true);
}
+ this.getSimpleVirtualizer()?.requestHeightReconcile(this);
+ }
+
+ // Session region changes need the same invalidation a document change
+ // gets; rerender() defers the actual render through the virtualizer queue.
+ protected override escalateEditSessionRender(): void {
+ this.invalidateEditSessionLayout();
this.rerender();
}
diff --git a/packages/diffs/src/editor/editor.ts b/packages/diffs/src/editor/editor.ts
index 03649ca75..7e73715d4 100644
--- a/packages/diffs/src/editor/editor.ts
+++ b/packages/diffs/src/editor/editor.ts
@@ -328,7 +328,6 @@ export class Editor implements DiffsEditor {
useTokenTransformer,
enableGutterUtility,
enableLineSelection,
- expandUnchanged,
lineHoverHighlight = 'disabled',
...rest
} = fileInstance.options;
@@ -336,7 +335,6 @@ export class Editor implements DiffsEditor {
useTokenTransformer !== true ||
enableGutterUtility === true ||
enableLineSelection === true ||
- (expandUnchanged !== true && fileInstance.type === 'file-diff') ||
lineHoverHighlight !== 'disabled'
) {
fileInstance.setOptions({
@@ -344,7 +342,6 @@ export class Editor implements DiffsEditor {
useTokenTransformer: true,
enableGutterUtility: false,
enableLineSelection: false,
- expandUnchanged: true,
lineHoverHighlight: 'disabled',
});
fileInstance.rerender();
diff --git a/packages/diffs/src/react/utils/useFileDiffInstance.ts b/packages/diffs/src/react/utils/useFileDiffInstance.ts
index 25dc5d469..c768eb546 100644
--- a/packages/diffs/src/react/utils/useFileDiffInstance.ts
+++ b/packages/diffs/src/react/utils/useFileDiffInstance.ts
@@ -205,7 +205,6 @@ function mergeFileDiffOptions({
useTokenTransformer: true,
enableGutterUtility: false,
enableLineSelection: false,
- expandUnchanged: true,
lineHoverHighlight: 'disabled',
}
: null),
diff --git a/packages/diffs/test/CodeView.edit.test.ts b/packages/diffs/test/CodeView.edit.test.ts
index bcbc0abb8..ebb4f367b 100644
--- a/packages/diffs/test/CodeView.edit.test.ts
+++ b/packages/diffs/test/CodeView.edit.test.ts
@@ -186,7 +186,9 @@ describe('CodeView item edit mode', () => {
if (renderedB.type !== 'diff') {
throw new Error('expected a rendered diff item');
}
- expect(renderedB.instance.options.expandUnchanged).toBe(true);
+ // expandUnchanged is not edit-forced: collapsed unchanged regions stay
+ // collapsed during editing, so the item serves the pass-through value.
+ expect(renderedB.instance.options.expandUnchanged).toBe(false);
// ...while non-edited siblings keep the parent options.
expect(renderedC.instance.options.useTokenTransformer).toBe(false);
expect(renderedC.instance.options.enableLineSelection).toBe(true);
@@ -467,12 +469,12 @@ describe('CodeView item edit mode', () => {
}
});
- test('layout height tracks the edit-forced expandUnchanged expansion', async () => {
+ test('entering edit mode keeps the collapsed layout height', async () => {
const { cleanup } = installDom();
const { createEditor } = createEditorHarness();
const viewer = new CodeView({ createEditor });
- // A diff with a large unchanged region, so expandUnchanged materially
- // changes the item's height (collapsed context rows become real rows).
+ // A diff with a large unchanged region: collapsed regions stay collapsed
+ // during editing, so entering edit mode must not change the layout.
const oldContents = Array.from(
{ length: 60 },
(_, index) => `line ${index}`
@@ -493,15 +495,9 @@ describe('CodeView item edit mode', () => {
await renderItems(viewer, [item]);
const collapsedHeight = viewer.getScrollHeight();
- // Edit mode forces expandUnchanged for the item, which must flow into
- // the computed layout height immediately — a stale collapsed height is
- // what made remounts render taller than their layout slot (the scroll
- // jump this pins).
await applyItemUpdate(viewer, { ...item, edit: true, version: 1 });
- const expandedHeight = viewer.getScrollHeight();
- expect(expandedHeight).toBeGreaterThan(collapsedHeight);
+ expect(viewer.getScrollHeight()).toBe(collapsedHeight);
- // Toggling edit off restores the collapsed layout.
await applyItemUpdate(viewer, { ...item, edit: false, version: 2 });
expect(viewer.getScrollHeight()).toBe(collapsedHeight);
} finally {
@@ -729,6 +725,42 @@ describe('CodeView item edit mode', () => {
}
});
+ test('ending a session reconciles the item layout height', async () => {
+ const { cleanup } = installDom();
+ const { createEditor } = createEditorHarness();
+ const viewer = new CodeView({ createEditor });
+ const edited = makeSessionDiffItem('edited');
+ const below = makeEditFileItem('below', false, 10);
+ try {
+ viewer.setup(createRoot());
+ await renderItems(viewer, [edited, below]);
+ await wait(10);
+ const heightDuring = viewer.getScrollHeight();
+
+ // Reverting one hunk keeps it rendered as a context-only region, so
+ // the mid-session layout height is unchanged.
+ revertLineTen(edited, viewer);
+ viewer.render(true);
+ await wait(20);
+ expect(viewer.getScrollHeight()).toBe(heightDuring);
+
+ // Exit runs the recompute: the reverted region collapses away and
+ // the layout must shrink with it — a stale estimated height here is
+ // what made items overlap.
+ expect(viewer.updateItem({ ...edited, edit: false, version: 1 })).toBe(
+ true
+ );
+ viewer.render(true);
+ await wait(30);
+ expect(edited.type === 'diff' && edited.fileDiff.hunks.length).toBe(1);
+ expect(viewer.getScrollHeight()).toBeLessThan(heightDuring);
+ } finally {
+ viewer.cleanUp();
+ await wait(0);
+ cleanup();
+ }
+ });
+
test('ending a session after its instance was released still recomputes', async () => {
const { cleanup } = installDom();
const { editors, createEditor } = createEditorHarness();
diff --git a/packages/diffs/test/editorCollapsedEdit.test.ts b/packages/diffs/test/editorCollapsedEdit.test.ts
index e52d51cec..c88042203 100644
--- a/packages/diffs/test/editorCollapsedEdit.test.ts
+++ b/packages/diffs/test/editorCollapsedEdit.test.ts
@@ -130,6 +130,43 @@ function typeAt(
);
}
+describe('diff editor: attach-time option normalization', () => {
+ test('the fallback setOptions preserves a host expandUnchanged option', async () => {
+ const dom = installDom();
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+ // useTokenTransformer: false triggers Editor.edit's setOptions fallback,
+ // which replaces options wholesale — the host's own expandUnchanged must
+ // flow through it.
+ const fileDiff = new FileDiff({
+ disableFileHeader: true,
+ theme: DEFAULT_THEMES,
+ diffStyle: 'split',
+ expandUnchanged: true,
+ useTokenTransformer: false,
+ });
+ const editor = new Editor();
+ try {
+ fileDiff.render({
+ oldFile: { name: 'edit.ts', contents: 'a\nb\n' },
+ newFile: { name: 'edit.ts', contents: 'a\nB\n' },
+ fileContainer: container,
+ forceRender: true,
+ });
+ editor.edit(fileDiff);
+ await wait(10);
+
+ expect(fileDiff.options.useTokenTransformer).toBe(true);
+ expect(fileDiff.options.expandUnchanged).toBe(true);
+ } finally {
+ await wait(10);
+ editor.cleanUp();
+ fileDiff.cleanUp();
+ dom.cleanup();
+ }
+ });
+});
+
describe('diff editor: collapsed regions during edit', () => {
test('renders sparse rows with read-only separators and column parity', async () => {
const fixture = await createCollapsedEditFixture();
From 07ac1836cc6989a22ffc0d847f1e7561c997ec50 Mon Sep 17 00:00:00 2001
From: Amadeus Demarzi
Date: Fri, 10 Jul 2026 12:52:41 -0700
Subject: [PATCH 06/11] feat(diffs): Preserve expansion state best-effort
across session exit
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Expanding collapsed context while editing (separator clicks or
reveal-on-jump) no longer resets when the session ends: expanded gap
edges are snapshotted as old-side line ranges before the exit
recompute — old-side coordinates are the ones edits cannot move — and
remapped onto the recomputed hunks afterward. A preserved range that
still touches a gap's start or end edge restores that edge's
expansion; ranges whose gap disappeared just collapse.
The exit flow consolidates into FileDiff.completeEditSession
(marker-guarded, idempotent), shared by the live editor-detach path
and CodeView's reap of sessions whose instance virtualization already
released. The virtualized escalation now reports the layout change to
its virtualizer so item tops re-derive from the changed heights.
---
packages/diffs/src/components/CodeView.ts | 26 ++--
packages/diffs/src/components/FileDiff.ts | 49 ++++++--
.../src/components/VirtualizedFileDiff.ts | 11 +-
.../diffs/src/renderers/DiffHunksRenderer.ts | 7 ++
packages/diffs/src/utils/editSessionHunks.ts | 116 ++++++++++++++++++
packages/diffs/test/editSessionHunks.test.ts | 64 ++++++++++
.../diffs/test/editorCollapsedEdit.test.ts | 30 +++++
7 files changed, 277 insertions(+), 26 deletions(-)
diff --git a/packages/diffs/src/components/CodeView.ts b/packages/diffs/src/components/CodeView.ts
index 2a06bee68..71e1bbb80 100644
--- a/packages/diffs/src/components/CodeView.ts
+++ b/packages/diffs/src/components/CodeView.ts
@@ -2100,19 +2100,21 @@ export class CodeView {
this.attachedEditors.delete(id);
// When the session's instance was released by virtualization, the
// cleanUp above had no detach closure left to run the exit recompute,
- // so finish the session directly against the diff metadata (idempotent:
- // the dirty marker clears on the first run). The metadata comes from
- // the live item, or from the last change's snapshot for removed items.
+ // so finish the session here (idempotent: the dirty marker clears on
+ // the first run). A live item goes through its instance, which also
+ // preserves expansion state and invalidates layout; removed items fall
+ // back to a plain metadata recompute from the last change's snapshot.
const itemSnapshot = item?.item ?? record.state.lastChange?.item;
- if (
- itemSnapshot?.type === 'diff' &&
- finishEditSessionForDiff(itemSnapshot.fileDiff) &&
- item != null &&
- item.type === 'diff'
- ) {
- item.instance.invalidateEditSessionLayout();
- this.markItemLayoutDirty(item);
- this.render();
+ if (itemSnapshot?.type === 'diff') {
+ if (
+ item != null &&
+ item.type === 'diff' &&
+ item.instance.completeEditSession()
+ ) {
+ this.markItemLayoutDirty(item);
+ this.render();
+ }
+ finishEditSessionForDiff(itemSnapshot.fileDiff);
}
const { lastChange } = record.state;
if (lastChange != null) {
diff --git a/packages/diffs/src/components/FileDiff.ts b/packages/diffs/src/components/FileDiff.ts
index 680aa729d..7c0539de7 100644
--- a/packages/diffs/src/components/FileDiff.ts
+++ b/packages/diffs/src/components/FileDiff.ts
@@ -74,7 +74,11 @@ import {
wrapThemeCSS,
wrapUnsafeCSS,
} from '../utils/cssWrappers';
-import { finishEditSessionForDiff } from '../utils/editSessionHunks';
+import {
+ captureExpansionAnchors,
+ finishEditSessionForDiff,
+ rebuildExpansionFromAnchors,
+} from '../utils/editSessionHunks';
import { getDiffFileInput } from '../utils/getDiffFileInput';
import { getDiffHunksRendererOptions } from '../utils/getDiffHunksRendererOptions';
import { getLineAnnotationName } from '../utils/getLineAnnotationName';
@@ -1291,21 +1295,42 @@ export class FileDiff<
};
}
- // Genuine session exit: restore recompute-shaped hunks (a context-only
- // region collapses away, boundaries re-derive) and repaint through the
- // session render path, which also invalidates virtualized layout — nothing
- // else invalidates layout at exit now that editing does not flip
- // expandUnchanged. Safe on a cleaned-up instance: the recompute is pure
- // metadata work and the deferred rerender is enabled-guarded.
+ // Genuine session exit for the live detach path.
private finishEditSession(): void {
this.hunksRenderer.endEditSession();
+ this.completeEditSession();
+ }
+
+ /**
+ * Run the genuine session-end recompute: restore recompute-shaped hunks (a
+ * context-only region collapses away, boundaries re-derive), preserve
+ * expansion state best-effort via old-side anchors, and repaint through
+ * the session render path — which also invalidates virtualized layout,
+ * since nothing else does at exit now that editing does not flip
+ * expandUnchanged. Marker-guarded and idempotent; CodeView also calls this
+ * when reaping a session whose detach closure was consumed by a recycle.
+ * Safe on a cleaned-up instance: the recompute is pure metadata work and
+ * the deferred rerender is enabled-guarded. Returns true when a recompute
+ * ran.
+ */
+ public completeEditSession(): boolean {
const fileDiff = this.fileDiffCache;
- if (
- fileDiff != null &&
- finishEditSessionForDiff(fileDiff, this.options.parseDiffOptions)
- ) {
- this.escalateEditSessionRender();
+ if (fileDiff == null || fileDiff.editSessionDirty !== true) {
+ return false;
}
+ const { collapsedContextThreshold = DEFAULT_COLLAPSED_CONTEXT_THRESHOLD } =
+ this.options;
+ const anchors = captureExpansionAnchors(
+ fileDiff,
+ this.hunksRenderer.getExpandedHunksMap(),
+ collapsedContextThreshold
+ );
+ finishEditSessionForDiff(fileDiff, this.options.parseDiffOptions);
+ this.hunksRenderer.setExpandedHunksMap(
+ rebuildExpansionFromAnchors(fileDiff, anchors)
+ );
+ this.escalateEditSessionRender();
+ return true;
}
// normally triggered by the host when the document line count changes
diff --git a/packages/diffs/src/components/VirtualizedFileDiff.ts b/packages/diffs/src/components/VirtualizedFileDiff.ts
index 15c69d6e0..d673e26d0 100644
--- a/packages/diffs/src/components/VirtualizedFileDiff.ts
+++ b/packages/diffs/src/components/VirtualizedFileDiff.ts
@@ -962,10 +962,17 @@ export class VirtualizedFileDiff<
}
// Session region changes need the same invalidation a document change
- // gets; rerender() defers the actual render through the virtualizer queue.
+ // gets. The virtualizer is told the layout changed (rendered rows and
+ // heights moved) and defers the actual render through its own queue; a
+ // released instance stops at the cache invalidation and the host relayout
+ // covers it.
protected override escalateEditSessionRender(): void {
this.invalidateEditSessionLayout();
- this.rerender();
+ if (!this.enabled || this.fileDiff == null) {
+ return;
+ }
+ this.forceRenderOverride = true;
+ this.virtualizer.instanceChanged(this, true);
}
protected override shouldSelfHealEditSession(): boolean {
diff --git a/packages/diffs/src/renderers/DiffHunksRenderer.ts b/packages/diffs/src/renderers/DiffHunksRenderer.ts
index 70f7ff3d4..ff9c0c498 100644
--- a/packages/diffs/src/renderers/DiffHunksRenderer.ts
+++ b/packages/diffs/src/renderers/DiffHunksRenderer.ts
@@ -365,6 +365,13 @@ export class DiffHunksRenderer {
return this.expandedHunks;
}
+ /** Replace the whole expansion map (session-exit expansion remapping). */
+ public setExpandedHunksMap(
+ expandedHunks: Map
+ ): void {
+ this.expandedHunks = expandedHunks;
+ }
+
public setLineAnnotations(
lineAnnotations: DiffLineAnnotation[]
): void {
diff --git a/packages/diffs/src/utils/editSessionHunks.ts b/packages/diffs/src/utils/editSessionHunks.ts
index 19d087aae..0804794d5 100644
--- a/packages/diffs/src/utils/editSessionHunks.ts
+++ b/packages/diffs/src/utils/editSessionHunks.ts
@@ -15,6 +15,10 @@ import {
recomputeHunkRenderLineCounts,
syncHunkNoEOFCRFromFullFile,
} from './updateDiffHunks';
+import {
+ getExpandedRegion,
+ getTrailingExpandedRegion,
+} from './virtualDiffLayout';
// While an editor is attached to a FileDiff, hunks are treated as a frozen
// "region skeleton": each hunk is one region spanning its full range, regions
@@ -307,6 +311,118 @@ export function remapExpandedHunksForRegionChange(
return remapped;
}
+/**
+ * An expanded gap-edge slice in old-side (deletion-line) coordinates as a
+ * `[start, end)` range. Old-side coordinates survive the exit recompute
+ * unchanged — edits only touch the new side — so these anchor best-effort
+ * expansion preservation across the recompute.
+ */
+export type ExpansionAnchorRange = [start: number, end: number];
+
+/** Snapshot the expanded gap-edge slices before the exit recompute. */
+export function captureExpansionAnchors(
+ diff: FileDiffMetadata,
+ expandedHunks: Map,
+ collapsedContextThreshold: number
+): ExpansionAnchorRange[] {
+ const anchors: ExpansionAnchorRange[] = [];
+ if (diff.isPartial) {
+ return anchors;
+ }
+ for (const [hunkIndex, hunk] of diff.hunks.entries()) {
+ const region = getExpandedRegion({
+ isPartial: diff.isPartial,
+ rangeSize: hunk.collapsedBefore,
+ expandedHunks,
+ hunkIndex,
+ collapsedContextThreshold,
+ });
+ // Gaps at or below the threshold render on their own; only explicit
+ // expansion state needs preserving.
+ if (region.rangeSize <= collapsedContextThreshold) {
+ continue;
+ }
+ const gapEnd = hunk.deletionLineIndex;
+ const gapStart = gapEnd - region.rangeSize;
+ if (region.fromStart > 0) {
+ anchors.push([gapStart, gapStart + region.fromStart]);
+ }
+ if (region.fromEnd > 0) {
+ anchors.push([gapEnd - region.fromEnd, gapEnd]);
+ }
+ }
+ const trailingRegion = getTrailingExpandedRegion({
+ fileDiff: diff,
+ hunkIndex: diff.hunks.length - 1,
+ expandedHunks,
+ collapsedContextThreshold,
+ errorPrefix: 'captureExpansionAnchors',
+ });
+ if (
+ trailingRegion != null &&
+ trailingRegion.fromStart > 0 &&
+ trailingRegion.rangeSize > collapsedContextThreshold
+ ) {
+ const lastHunk = diff.hunks[diff.hunks.length - 1];
+ const gapStart = lastHunk.deletionLineIndex + lastHunk.deletionCount;
+ anchors.push([gapStart, gapStart + trailingRegion.fromStart]);
+ }
+ return anchors;
+}
+
+/**
+ * Rebuild gap expansion state against the recomputed hunks: for each new
+ * gap, an anchor touching the gap's start edge restores `fromStart`, one
+ * touching its end edge restores `fromEnd`, and anchors for gaps that no
+ * longer exist drop.
+ */
+export function rebuildExpansionFromAnchors(
+ diff: FileDiffMetadata,
+ anchors: ExpansionAnchorRange[]
+): Map {
+ const rebuilt = new Map();
+ if (anchors.length === 0) {
+ return rebuilt;
+ }
+ const applyGap = (key: number, gapStart: number, gapEnd: number) => {
+ if (gapEnd <= gapStart) {
+ return;
+ }
+ let fromStart = 0;
+ let fromEnd = 0;
+ for (const [start, end] of anchors) {
+ if (end <= gapStart || start >= gapEnd) {
+ continue;
+ }
+ if (start <= gapStart) {
+ fromStart = Math.max(fromStart, Math.min(end, gapEnd) - gapStart);
+ }
+ if (end >= gapEnd) {
+ fromEnd = Math.max(fromEnd, gapEnd - Math.max(start, gapStart));
+ }
+ }
+ if (fromStart > 0 || fromEnd > 0) {
+ rebuilt.set(key, { fromStart, fromEnd });
+ }
+ };
+ for (const [hunkIndex, hunk] of diff.hunks.entries()) {
+ applyGap(
+ hunkIndex,
+ hunk.deletionLineIndex - Math.max(hunk.collapsedBefore, 0),
+ hunk.deletionLineIndex
+ );
+ }
+ const lastHunk = diff.hunks[diff.hunks.length - 1];
+ if (lastHunk != null && !diff.isPartial && diff.deletionLines.length > 0) {
+ applyGap(
+ diff.hunks.length,
+ lastHunk.deletionLineIndex + lastHunk.deletionCount,
+ diff.deletionLines.length
+ );
+ }
+ return rebuilt;
+}
+
/**
* Genuine session exit: when session passes reshaped the hunks, run the real
* full recompute so exit state matches a non-session edit pipeline, and clear
diff --git a/packages/diffs/test/editSessionHunks.test.ts b/packages/diffs/test/editSessionHunks.test.ts
index 632c9cc7a..d1074221a 100644
--- a/packages/diffs/test/editSessionHunks.test.ts
+++ b/packages/diffs/test/editSessionHunks.test.ts
@@ -4,9 +4,11 @@ import type { FileDiffMetadata, HunkExpansionRegion } from '../src/types';
import {
applySessionChangedLines,
applySessionEditWindow,
+ captureExpansionAnchors,
findChangedLineWindow,
finishEditSessionForDiff,
normalizeEditorLines,
+ rebuildExpansionFromAnchors,
remapExpandedHunksForRegionChange,
} from '../src/utils/editSessionHunks';
import { iterateOverDiff } from '../src/utils/iterateOverDiff';
@@ -338,6 +340,68 @@ describe('remapExpandedHunksForRegionChange', () => {
});
});
+describe('expansion anchors across the exit recompute', () => {
+ const THRESHOLD = 1;
+
+ test('a gap that still exists keeps its edge expansions', () => {
+ const diff = makeDiff();
+ const expandedHunks = new Map([
+ [1, { fromStart: 2, fromEnd: 3 }],
+ ]);
+ const anchors = captureExpansionAnchors(diff, expandedHunks, THRESHOLD);
+ expect(anchors.length).toBe(2);
+
+ // A session insert inside the first hunk shifts the addition side but
+ // leaves old-side coordinates alone.
+ diff.additionLines.splice(3, 0, 'inserted\n');
+ applySessionEditWindow(diff, { start: 3, prevEnd: 3, nextEnd: 4 });
+ finishEditSessionForDiff(diff);
+
+ const rebuilt = rebuildExpansionFromAnchors(diff, anchors);
+ expect(rebuilt.get(1)).toEqual({ fromStart: 2, fromEnd: 3 });
+ });
+
+ test('anchors interior to a merged gap drop; edge-anchored ones survive', () => {
+ const diff = makeDiff();
+ const expandedHunks = new Map([
+ [1, { fromStart: 2, fromEnd: 3 }],
+ ]);
+ const anchors = captureExpansionAnchors(diff, expandedHunks, THRESHOLD);
+
+ // Reverting the first hunk removes it at exit, merging its leading gap
+ // with the gap between the hunks.
+ diff.additionLines[2] = 'l3\n';
+ applySessionEditWindow(diff, { start: 2, prevEnd: 3, nextEnd: 3 });
+ finishEditSessionForDiff(diff);
+ expect(diff.hunks).toHaveLength(1);
+
+ const rebuilt = rebuildExpansionFromAnchors(diff, anchors);
+ // The fromEnd slice still touches the surviving gap's end edge; the
+ // fromStart slice is now interior to the bigger gap and drops.
+ expect(rebuilt.get(0)).toEqual({ fromStart: 0, fromEnd: 3 });
+ expect(rebuilt.size).toBe(1);
+ });
+
+ test('trailing pseudo-key expansion survives', () => {
+ const diff = makeDiff();
+ const expandedHunks = new Map([
+ [2, { fromStart: 4, fromEnd: 0 }],
+ ]);
+ const anchors = captureExpansionAnchors(diff, expandedHunks, THRESHOLD);
+ expect(anchors.length).toBe(1);
+
+ diff.additionLines[2] = 'changed differently\n';
+ applySessionEditWindow(diff, { start: 2, prevEnd: 3, nextEnd: 3 });
+ finishEditSessionForDiff(diff);
+
+ const rebuilt = rebuildExpansionFromAnchors(diff, anchors);
+ expect(rebuilt.get(diff.hunks.length)).toEqual({
+ fromStart: 4,
+ fromEnd: 0,
+ });
+ });
+});
+
describe('finishEditSessionForDiff', () => {
test('a dirty session recomputes to the plain edit pipeline result', () => {
const diff = makeDiff();
diff --git a/packages/diffs/test/editorCollapsedEdit.test.ts b/packages/diffs/test/editorCollapsedEdit.test.ts
index c88042203..a20176c4f 100644
--- a/packages/diffs/test/editorCollapsedEdit.test.ts
+++ b/packages/diffs/test/editorCollapsedEdit.test.ts
@@ -297,6 +297,36 @@ describe('diff editor: collapsed regions during edit', () => {
}
});
+ test('a mid-session expansion whose gap still exists survives session exit', async () => {
+ const fixture = await createCollapsedEditFixture();
+ const { container, editor, fileDiff } = fixture;
+ try {
+ // Reveal five lines at the start of the gap (lines 15-19), then make
+ // an edit so a genuine exit recompute runs.
+ fileDiff.handleExpandHunk(1, 'up', 5);
+ await wait(10);
+ expect(fileDiff.isLineRenderable(15)).toBe(true);
+ typeAt(editor, 49, 0, 'X');
+ await wait(10);
+
+ // Genuine session end: the recompute runs, and the expansion remaps
+ // onto the recomputed hunks via old-side anchors.
+ editor.cleanUp();
+ await wait(20);
+
+ expect(fileDiff.fileDiff?.editSessionDirty).toBeUndefined();
+ expect(fileDiff.isLineRenderable(15)).toBe(true);
+ expect(fileDiff.isLineRenderable(19)).toBe(true);
+ // The gap interior stays collapsed.
+ expect(fileDiff.isLineRenderable(30)).toBe(false);
+ const content = findAdditionContent(container);
+ expect(content?.querySelector('[data-line="15"]')).not.toBeNull();
+ expect(content?.querySelector('[data-line="30"]')).toBeNull();
+ } finally {
+ await fixture.cleanup();
+ }
+ });
+
test('a line-count edit below the gap re-renders without duplicates', async () => {
const fixture = await createCollapsedEditFixture();
const { container, editor } = fixture;
From 5568174f61f7f2a3fae31f63cf5fd69f7997f668 Mon Sep 17 00:00:00 2001
From: Amadeus Demarzi
Date: Fri, 10 Jul 2026 13:01:42 -0700
Subject: [PATCH 07/11] test(diffs): Add browser E2E coverage for editing with
collapsed regions
New edit-collapsed fixture: a 60-line diff whose unchanged gap
collapses, driven in both split and unified view (the unified content
column rebuilds via innerHTML on edits, a named regression risk).
Cases: the gap stays collapsed on edit entry, a reverted hunk persists
as context until session exit and then collapses away, arrow keys
fold-skip the gap, search navigation reveals a hidden match, a
separator expand click mid-edit keeps the viewport and accepts typing
into the revealed context, a programmatic replace into the gap
materializes a rendered hunk without scrolling, and undo/redo
round-trip with collapse live.
---
packages/diffs/src/components/Virtualizer.ts | 4 +-
packages/diffs/test/e2e/e2e-globals.d.ts | 12 ++
packages/diffs/test/e2e/edit-collapsed.pw.ts | 196 ++++++++++++++++++
.../test/e2e/fixtures/edit-collapsed.html | 143 +++++++++++++
packages/diffs/test/e2e/fixtures/index.html | 11 +
5 files changed, 364 insertions(+), 2 deletions(-)
create mode 100644 packages/diffs/test/e2e/edit-collapsed.pw.ts
create mode 100644 packages/diffs/test/e2e/fixtures/edit-collapsed.html
diff --git a/packages/diffs/src/components/Virtualizer.ts b/packages/diffs/src/components/Virtualizer.ts
index d5830f354..9f9d0890d 100644
--- a/packages/diffs/src/components/Virtualizer.ts
+++ b/packages/diffs/src/components/Virtualizer.ts
@@ -137,8 +137,8 @@ export class Virtualizer {
// host/React-driven render call, an async highlight completion). Those
// renders queue themselves here so their measured line deltas — wrapped
// lines, annotation heights — are re-captured; otherwise a layout reset
- // (e.g. an edit-mode expandUnchanged flip) leaves the instance stuck on
- // its baseline estimate and its placeholder renders the wrong height.
+ // (e.g. an edit-session exit recompute) leaves the instance stuck on its
+ // baseline estimate and its placeholder renders the wrong height.
requestHeightReconcile(instance: SubscribedInstance): void {
this.reconcileQueue.add(instance);
queueRender(this.computeRenderRangeAndEmit);
diff --git a/packages/diffs/test/e2e/e2e-globals.d.ts b/packages/diffs/test/e2e/e2e-globals.d.ts
index a46e5354b..1a2732f29 100644
--- a/packages/diffs/test/e2e/e2e-globals.d.ts
+++ b/packages/diffs/test/e2e/e2e-globals.d.ts
@@ -31,6 +31,11 @@ interface E2EEditorState {
};
}
+interface E2ETextEdit {
+ range: { start: E2ESelectionPoint; end: E2ESelectionPoint };
+ newText: string;
+}
+
// The subset of the real Editor surface the fixtures expose on `window`.
interface E2EEditor {
canUndo: boolean;
@@ -39,7 +44,9 @@ interface E2EEditor {
getFile: () => { contents: string } | undefined;
getState: () => E2EEditorState;
setSelections: (selections: E2ESelection[]) => void;
+ applyEdits: (edits: E2ETextEdit[], updateHistory?: boolean) => void;
focus: () => void;
+ cleanUp: () => void;
}
interface Window {
@@ -64,4 +71,9 @@ interface Window {
// Editor handle exposed by the editable fixtures.
__editor?: E2EEditor;
+
+ // edit-collapsed.html helpers: rendered new-file line numbers in the
+ // editable column, and the primary caret's zero-based line.
+ __renderedLines?: () => number[];
+ __caretLine?: () => number | undefined;
}
diff --git a/packages/diffs/test/e2e/edit-collapsed.pw.ts b/packages/diffs/test/e2e/edit-collapsed.pw.ts
new file mode 100644
index 000000000..04dfda62b
--- /dev/null
+++ b/packages/diffs/test/e2e/edit-collapsed.pw.ts
@@ -0,0 +1,196 @@
+import { expect, type Page, test } from '@playwright/test';
+
+// Edit mode over a diff whose unchanged regions collapse: a 60-line file with
+// changes at lines 10 and 50, so lines 15-45 sit in a collapsed gap between
+// two hunks. Both diff styles run every case — the unified view rebuilds its
+// content column via innerHTML on edits, a named regression risk.
+
+const CONTENT = '[data-code]:not([data-deletions]) [data-content]';
+
+async function openFixture(page: Page, diffStyle: 'split' | 'unified') {
+ await page.goto(
+ `/test/e2e/fixtures/edit-collapsed.html?diffStyle=${diffStyle}`
+ );
+ await page.waitForFunction(() => window.__editReady === true);
+}
+
+const renderedLines = (page: Page): Promise =>
+ page.evaluate(() => window.__renderedLines?.() ?? []);
+
+const caretLine = (page: Page): Promise =>
+ page.evaluate(() => window.__caretLine?.());
+
+const scrollTop = (page: Page): Promise =>
+ page.evaluate(() => document.scrollingElement?.scrollTop ?? 0);
+
+function row(page: Page, lineNumber: number) {
+ return page.locator(
+ `${CONTENT} [data-line="${lineNumber}"]:not([data-line-type="change-deletion"])`
+ );
+}
+
+for (const diffStyle of ['split', 'unified'] as const) {
+ test.describe(`collapsed regions during edit (${diffStyle})`, () => {
+ test('entering edit mode keeps the gap collapsed', async ({ page }) => {
+ await openFixture(page, diffStyle);
+
+ const lines = await renderedLines(page);
+ expect(lines.length).toBeLessThan(60);
+ expect(lines).not.toContain(30);
+ // Both hunks render.
+ expect(lines).toContain(10);
+ expect(lines).toContain(50);
+ });
+
+ test('a reverted hunk persists until exit, then collapses away', async ({
+ page,
+ }) => {
+ await openFixture(page, diffStyle);
+
+ // Rewrite line 10 back to its old-side text.
+ await row(page, 10).click();
+ await page.evaluate(() => {
+ window.__editor?.applyEdits(
+ [
+ {
+ range: {
+ start: { line: 9, character: 0 },
+ end: { line: 9, character: 'line 10 changed'.length },
+ },
+ newText: 'line 10',
+ },
+ ],
+ true
+ );
+ });
+ await expect(row(page, 10)).toHaveText('line 10');
+
+ // The reverted hunk persists as a context-only region mid-session.
+ let lines = await renderedLines(page);
+ expect(lines).toContain(10);
+ expect(lines).not.toContain(30);
+
+ // Genuine session end: the reverted region collapses away.
+ await page.evaluate(() => window.__editor?.cleanUp());
+ await expect
+ .poll(async () => (await renderedLines(page)).includes(10))
+ .toBe(false);
+ const after = await renderedLines(page);
+ expect(after).toContain(50);
+ });
+
+ test('arrow-down skips the collapsed gap like a code fold', async ({
+ page,
+ }) => {
+ await openFixture(page, diffStyle);
+
+ // Caret on line 14 (zero-based 13), the last rendered line of hunk 1.
+ await row(page, 14).click();
+ await expect.poll(() => caretLine(page)).toBe(13);
+
+ await page.keyboard.press('ArrowDown');
+ await expect.poll(() => caretLine(page)).toBe(45);
+
+ await page.keyboard.press('ArrowUp');
+ await expect.poll(() => caretLine(page)).toBe(13);
+ });
+
+ test('search navigation to a hidden match reveals it', async ({ page }) => {
+ await openFixture(page, diffStyle);
+
+ await row(page, 10).click();
+ await page.keyboard.press('ControlOrMeta+f');
+ const searchInput = page.locator(
+ '[data-search-panel] input[data-search]'
+ );
+ await expect(searchInput).toBeVisible();
+ await searchInput.fill('line 30');
+ await searchInput.press('Enter');
+
+ await expect(row(page, 30)).toBeVisible();
+ const lines = await renderedLines(page);
+ expect(lines).toContain(30);
+ // Only the gap containing the match expands (the reveal step is
+ // distance + expansionLineCount, which covers this whole gap); other
+ // collapsed regions stay collapsed.
+ expect(lines).not.toContain(57);
+ });
+
+ test('separator expansion mid-edit keeps the viewport and accepts typing', async ({
+ page,
+ }) => {
+ await openFixture(page, diffStyle);
+
+ await row(page, 10).click();
+ const scrollBefore = await scrollTop(page);
+
+ // Separator rows render in several columns; some copies hide their
+ // buttons via CSS, so click the first visible one for this gap.
+ const expandButton = page
+ .locator('[data-separator][data-expand-index="1"] [data-expand-button]')
+ .locator('visible=true')
+ .first();
+ await expandButton.click();
+ await expect(row(page, 30)).toBeVisible();
+ expect(await scrollTop(page)).toBe(scrollBefore);
+
+ // Type into the newly revealed context line; the edit lands and the
+ // caret row stays visible.
+ await row(page, 30).click();
+ await page.keyboard.press('Home');
+ await page.keyboard.type('typed ');
+ await expect(row(page, 30)).toHaveText('typed line 30');
+ await expect(row(page, 30)).toBeInViewport();
+ expect(await scrollTop(page)).toBe(scrollBefore);
+ });
+
+ test('a programmatic replace into the gap materializes a rendered hunk', async ({
+ page,
+ }) => {
+ await openFixture(page, diffStyle);
+
+ await row(page, 10).click();
+ const scrollBefore = await scrollTop(page);
+
+ // Mirrors search replaceAll: a buffer edit with no caret involvement
+ // into a line hidden inside the collapsed gap.
+ await page.evaluate(() => {
+ window.__editor?.applyEdits(
+ [
+ {
+ range: {
+ start: { line: 29, character: 0 },
+ end: { line: 29, character: 'line 30'.length },
+ },
+ newText: 'REPLACED',
+ },
+ ],
+ true
+ );
+ });
+
+ await expect(row(page, 30)).toHaveText('REPLACED');
+ expect(await scrollTop(page)).toBe(scrollBefore);
+ // The caret stays where it was (line 10's row is still on screen).
+ await expect(row(page, 10)).toBeInViewport();
+ });
+
+ test('undo and redo round-trip with collapse live', async ({ page }) => {
+ await openFixture(page, diffStyle);
+
+ await row(page, 50).click();
+ await page.keyboard.press('Home');
+ await page.keyboard.type('Z');
+ await expect(row(page, 50)).toContainText('Zline 50');
+
+ await page.keyboard.press('ControlOrMeta+z');
+ await expect(row(page, 50)).not.toContainText('Zline 50');
+
+ await page.keyboard.press('ControlOrMeta+Shift+z');
+ await expect(row(page, 50)).toContainText('Zline 50');
+
+ // The gap never opened along the way.
+ expect(await renderedLines(page)).not.toContain(30);
+ });
+ });
+}
diff --git a/packages/diffs/test/e2e/fixtures/edit-collapsed.html b/packages/diffs/test/e2e/fixtures/edit-collapsed.html
new file mode 100644
index 000000000..5368b1348
--- /dev/null
+++ b/packages/diffs/test/e2e/fixtures/edit-collapsed.html
@@ -0,0 +1,143 @@
+
+
+
+
+
+ diffs edit-collapsed fixture
+
+
+
+ ← All fixtures
+
+
+
+
+
diff --git a/packages/diffs/test/e2e/fixtures/index.html b/packages/diffs/test/e2e/fixtures/index.html
index 9e8eec2de..6a7723260 100644
--- a/packages/diffs/test/e2e/fixtures/index.html
+++ b/packages/diffs/test/e2e/fixtures/index.html
@@ -76,6 +76,17 @@ @pierre/diffs — E2E fixtures
window.__editorEvents.
+
+ edit-collapsed.html
+
+ A FileDiff + Editor over a 60-line file
+ whose unchanged gap collapses. Drives
+ edit-collapsed.pw.ts (fold-skip navigation, reveal on
+ search, separator expansion mid-edit, session exit).
+ ?diffStyle=unified switches views. Ready flag:
+ window.__editReady.
+
+
conflict.html
From 94262d5fa1633124a950e80b13eb01eb5f9a76d8 Mon Sep 17 00:00:00 2001
From: Amadeus Demarzi
Date: Fri, 10 Jul 2026 17:40:02 -0700
Subject: [PATCH 08/11] Add a new HTML editable example to /playground
This is useful for different types of diff editing scenarios
---
apps/docs/app/(diffs)/playground/constants.ts | 83 ++++++++++++++++++-
1 file changed, 82 insertions(+), 1 deletion(-)
diff --git a/apps/docs/app/(diffs)/playground/constants.ts b/apps/docs/app/(diffs)/playground/constants.ts
index 3113a4ed9..d2f1d14d7 100644
--- a/apps/docs/app/(diffs)/playground/constants.ts
+++ b/apps/docs/app/(diffs)/playground/constants.ts
@@ -258,6 +258,71 @@ const user = await getUser('123');
\`\`\`
`;
+// Nested markup with meaningful indentation, for exercising edit-mode diff
+// alignment: wrapping/unwrapping containers, pushing lines around with
+// Enter, and re-indenting all reshape change blocks whose lines differ only
+// (or mostly) in whitespace. The unchanged middle keeps a collapsible gap
+// between the changed regions.
+const OLD_MARKUP_CONTENT = `
+
+
+
Mathematician and writer.
+
+
+ 12 notes
+ 3 programs
+
+
+
+
+`;
+
+const NEW_MARKUP_CONTENT = `
+
+
+
Mathematician and writer.
+
+
+ 12 notes
+ 3 programs
+
+
+
+
+`;
+
// The base files are replicated into several uniquely-named variants so the
// Virtualizer and CodeView demos have enough content to scroll through. Each
// variant is a full (non-partial) diff parsed from complete old/new contents.
@@ -287,7 +352,18 @@ const README_BASE: BaseDiff = {
newContents: NEW_README_CONTENT,
};
-const BASE_DIFFS: BaseDiff[] = [USERS_BASE, STYLES_BASE, README_BASE];
+const MARKUP_BASE: BaseDiff = {
+ name: 'ui/profile-card.html',
+ oldContents: OLD_MARKUP_CONTENT,
+ newContents: NEW_MARKUP_CONTENT,
+};
+
+const BASE_DIFFS: BaseDiff[] = [
+ USERS_BASE,
+ MARKUP_BASE,
+ STYLES_BASE,
+ README_BASE,
+];
// Appends a variant index before the file extension (e.g. `users.ts` ->
// `users-2.ts`) so each replicated file has a distinct name and id.
@@ -329,6 +405,11 @@ export const CODE_VIEW_ITEMS: CodeViewItem[] =
type: 'diff',
fileDiff: variantDiff(USERS_BASE, index),
},
+ {
+ id: `diff:${variantName(MARKUP_BASE.name, index)}`,
+ type: 'diff',
+ fileDiff: variantDiff(MARKUP_BASE, index),
+ },
{
id: `file:${readmeName}`,
type: 'file',
From 7a7b828a35867f059a027c5d75dc30dca0875005 Mon Sep 17 00:00:00 2001
From: Amadeus Demarzi
Date: Fri, 10 Jul 2026 17:51:18 -0700
Subject: [PATCH 09/11] feat(diffs): semantic diffs, at home
Basically an attempt at doing better line matching on stuff
---
.../diffs/src/renderers/DiffHunksRenderer.ts | 6 +
packages/diffs/src/utils/editSessionHunks.ts | 12 +-
packages/diffs/src/utils/parsePatchFiles.ts | 5 +
.../diffs/src/utils/realignChangeContent.ts | 187 ++++++++++++++++++
packages/diffs/test/DiffHunksRender.test.ts | 44 +++++
.../test/DiffHunksRendererRecompute.test.ts | 59 ++++++
packages/diffs/test/e2e/edit-collapsed.pw.ts | 2 +-
.../diffs/test/realignChangeContent.test.ts | 160 +++++++++++++++
8 files changed, 471 insertions(+), 4 deletions(-)
create mode 100644 packages/diffs/src/utils/realignChangeContent.ts
create mode 100644 packages/diffs/test/realignChangeContent.test.ts
diff --git a/packages/diffs/src/renderers/DiffHunksRenderer.ts b/packages/diffs/src/renderers/DiffHunksRenderer.ts
index ff9c0c498..8f80d66cf 100644
--- a/packages/diffs/src/renderers/DiffHunksRenderer.ts
+++ b/packages/diffs/src/renderers/DiffHunksRenderer.ts
@@ -1354,6 +1354,12 @@ export class DiffHunksRenderer {
}
pendingSplitContext.side = missingSide;
pendingSplitContext.increment();
+ } else if (type === 'change') {
+ // A change row with both sides fills the column a pending
+ // one-sided buffer was holding open (an insert/delete block
+ // directly followed by a paired block, from similarity
+ // realignment); flush first so the buffer lands above this row.
+ pendingSplitContext.flush();
}
const annotationSpans = this.getAnnotations(
diff --git a/packages/diffs/src/utils/editSessionHunks.ts b/packages/diffs/src/utils/editSessionHunks.ts
index 0804794d5..4d382636e 100644
--- a/packages/diffs/src/utils/editSessionHunks.ts
+++ b/packages/diffs/src/utils/editSessionHunks.ts
@@ -552,12 +552,18 @@ function rediffRegion(
{ ...parseDiffOptions, context: 0 }
);
// Walk the zero-context hunks, re-deriving the unchanged stretches
- // between them from the addition side (context is paired, so both sides
- // advance by the same amount).
+ // between them (context is paired, so both sides advance by the same
+ // amount). The stretch length must come from a side the hunk has content
+ // on: a zero-count side's start follows the unified `N,0` convention
+ // (the line *before* the change), so its lineIndex is one short of the
+ // lines consumed ahead of the hunk.
let coveredAdditions = 0;
let coveredDeletions = 0;
for (const parsedHunk of reparsed.hunks) {
- const contextLines = parsedHunk.additionLineIndex - coveredAdditions;
+ const contextLines =
+ parsedHunk.additionCount > 0
+ ? parsedHunk.additionLineIndex - coveredAdditions
+ : parsedHunk.deletionLineIndex - coveredDeletions;
pushContext(
contextLines,
bounds.additionStart + coveredAdditions,
diff --git a/packages/diffs/src/utils/parsePatchFiles.ts b/packages/diffs/src/utils/parsePatchFiles.ts
index 20e0ad779..1f52141e1 100644
--- a/packages/diffs/src/utils/parsePatchFiles.ts
+++ b/packages/diffs/src/utils/parsePatchFiles.ts
@@ -17,6 +17,7 @@ import type {
} from '../types';
import { cleanLastNewline } from './cleanLastNewline';
import { detachString, releaseStringDetachBuffer } from './detachString';
+import { realignChangeContentBySimilarity } from './realignChangeContent';
interface ParsedHunkHeader {
additionCount: number;
@@ -577,6 +578,10 @@ function _processFile(
) {
currentFile.prevName = undefined;
}
+ // Pair change-block lines by similarity instead of the patch's positional
+ // ordering. Partial diffs work too: their hunk line indexes point into the
+ // patch-built line arrays, which hold every line a block references.
+ realignChangeContentBySimilarity(currentFile);
return currentFile;
}
diff --git a/packages/diffs/src/utils/realignChangeContent.ts b/packages/diffs/src/utils/realignChangeContent.ts
new file mode 100644
index 000000000..a2c51168c
--- /dev/null
+++ b/packages/diffs/src/utils/realignChangeContent.ts
@@ -0,0 +1,187 @@
+import type { ChangeContent, ContextContent, FileDiffMetadata } from '../types';
+
+// The diff library emits one change block per replaced run, ordering every
+// deleted line before every added line. Renderers pair a block's lines
+// positionally (deletion[i] across from addition[i] in split view), so a
+// block like { deletions: 1, additions: 2 } pairs the deleted line with
+// whichever addition happens to come first — even when a later addition is
+// the edited version of it (e.g. pressing Enter above a changed line pushes
+// a blank line in front of it). These helpers re-split such blocks so the
+// most similar lines pair up and the surplus renders as pure insert/delete
+// rows at the block's edges.
+
+// Skip realignment when a block would need more than this many line
+// comparisons; pathological blocks keep the library's positional pairing.
+const MAX_ALIGNMENT_COMPARISONS = 4096;
+
+// A shifted pairing must beat the positional one by this much per paired
+// line before the block is re-split. Near-ties (e.g. lines that merely share
+// an import-statement shape) keep the library's canonical order; the
+// realignment is meant for decisive wins like a blank or unrelated inserted
+// line displacing an edited one, where the gap approaches 1.
+const MIN_IMPROVEMENT_PER_PAIR = 0.5;
+
+/**
+ * Re-split count-mismatched change blocks in every hunk so paired lines are
+ * chosen by content similarity instead of position. Mutates `hunks` in
+ * place; rendered row counts are unchanged (a split block covers the same
+ * split/unified rows as the original).
+ */
+export function realignChangeContentBySimilarity(
+ diff: Pick
+): void {
+ for (const hunk of diff.hunks) {
+ for (let index = 0; index < hunk.hunkContent.length; index++) {
+ const content = hunk.hunkContent[index];
+ if (content.type !== 'change') {
+ continue;
+ }
+ const replacement = realignChangeBlock(diff, content);
+ if (replacement != null) {
+ hunk.hunkContent.splice(index, 1, ...replacement);
+ index += replacement.length - 1;
+ }
+ }
+ }
+}
+
+// Returns the split blocks for one change block, or null when the block is
+// balanced, too large to scan, or already best paired positionally.
+function realignChangeBlock(
+ diff: Pick,
+ content: ChangeContent
+): (ContextContent | ChangeContent)[] | null {
+ const { deletions, additions, deletionLineIndex, additionLineIndex } =
+ content;
+ const pairCount = Math.min(deletions, additions);
+ const surplus = Math.abs(additions - deletions);
+ if (
+ pairCount === 0 ||
+ surplus === 0 ||
+ pairCount * (surplus + 1) > MAX_ALIGNMENT_COMPARISONS
+ ) {
+ return null;
+ }
+
+ // Whitespace (indentation, formatter churn, trailing spaces, the line
+ // break itself) is noise for deciding which lines pair — the rendered diff
+ // still shows every whitespace change on the paired row. Strip it once per
+ // line here rather than per comparison.
+ const strippedDeletions: string[] = [];
+ for (let line = 0; line < deletions; line++) {
+ strippedDeletions.push(
+ stripWhitespace(diff.deletionLines[deletionLineIndex + line] ?? '')
+ );
+ }
+ const strippedAdditions: string[] = [];
+ for (let line = 0; line < additions; line++) {
+ strippedAdditions.push(
+ stripWhitespace(diff.additionLines[additionLineIndex + line] ?? '')
+ );
+ }
+
+ // Score every offset of the shorter side along the longer side and keep
+ // the best one only when it decisively beats the positional pairing.
+ const additionsAreLonger = additions > deletions;
+ let bestOffset = 0;
+ let bestScore = -1;
+ for (let offset = 0; offset <= surplus; offset++) {
+ let score = 0;
+ for (let pair = 0; pair < pairCount; pair++) {
+ score += lineSimilarity(
+ strippedDeletions[pair + (additionsAreLonger ? 0 : offset)],
+ strippedAdditions[pair + (additionsAreLonger ? offset : 0)]
+ );
+ }
+ if (offset === 0) {
+ bestScore = score + pairCount * MIN_IMPROVEMENT_PER_PAIR;
+ } else if (score > bestScore) {
+ bestScore = score;
+ bestOffset = offset;
+ }
+ }
+ if (bestOffset === 0) {
+ return null;
+ }
+
+ const blocks: ChangeContent[] = [];
+ const pushBlock = (
+ blockDeletions: number,
+ blockAdditions: number,
+ blockDeletionIndex: number,
+ blockAdditionIndex: number
+ ) => {
+ if (blockDeletions > 0 || blockAdditions > 0) {
+ blocks.push({
+ type: 'change',
+ deletions: blockDeletions,
+ additions: blockAdditions,
+ deletionLineIndex: blockDeletionIndex,
+ additionLineIndex: blockAdditionIndex,
+ });
+ }
+ };
+ if (additionsAreLonger) {
+ pushBlock(0, bestOffset, deletionLineIndex, additionLineIndex);
+ pushBlock(
+ pairCount,
+ pairCount,
+ deletionLineIndex,
+ additionLineIndex + bestOffset
+ );
+ pushBlock(
+ 0,
+ additions - pairCount - bestOffset,
+ deletionLineIndex + pairCount,
+ additionLineIndex + bestOffset + pairCount
+ );
+ } else {
+ pushBlock(bestOffset, 0, deletionLineIndex, additionLineIndex);
+ pushBlock(
+ pairCount,
+ pairCount,
+ deletionLineIndex + bestOffset,
+ additionLineIndex
+ );
+ pushBlock(
+ deletions - pairCount - bestOffset,
+ 0,
+ deletionLineIndex + bestOffset + pairCount,
+ additionLineIndex + pairCount
+ );
+ }
+ return blocks;
+}
+
+const WHITESPACE = /\s+/g;
+
+function stripWhitespace(line: string): string {
+ return line.replace(WHITESPACE, '');
+}
+
+// Cheap 0..1 similarity over whitespace-stripped lines: shared prefix plus
+// shared suffix over the longer length. Exact for lines that differ only in
+// whitespace, 0 for a blank against content — enough to steer pairing
+// without a real edit-distance pass.
+function lineSimilarity(a: string, b: string): number {
+ if (a === b) {
+ return 1;
+ }
+ const maxLength = Math.max(a.length, b.length);
+ const minLength = Math.min(a.length, b.length);
+ if (minLength === 0) {
+ return 0;
+ }
+ let prefix = 0;
+ while (prefix < minLength && a[prefix] === b[prefix]) {
+ prefix++;
+ }
+ let suffix = 0;
+ while (
+ suffix < minLength - prefix &&
+ a[a.length - 1 - suffix] === b[b.length - 1 - suffix]
+ ) {
+ suffix++;
+ }
+ return (prefix + suffix) / maxLength;
+}
diff --git a/packages/diffs/test/DiffHunksRender.test.ts b/packages/diffs/test/DiffHunksRender.test.ts
index 4808f6f98..41d91802f 100644
--- a/packages/diffs/test/DiffHunksRender.test.ts
+++ b/packages/diffs/test/DiffHunksRender.test.ts
@@ -216,6 +216,50 @@ describe('DiffHunksRenderer', () => {
}).toMatchSnapshot('rendered rows');
});
+ test('an insert block above a paired change keeps split columns aligned', async () => {
+ // Realignment splits "blank line inserted above an edited line" into an
+ // addition-only block directly followed by a paired block. The renderer
+ // must flush the pending deletion-side buffer before the paired block's
+ // deletion row, so the buffer renders above it and the columns stay
+ // index-parallel.
+ const instance = new DiffHunksRenderer(mockDiffs.diffRowBufferTest.options);
+ const context = 'alpha\nbravo\ncharlie\ndelta\n';
+ const diff = parseDiffFromFile(
+ {
+ name: 'a.ts',
+ contents: `${context}const value = compute();\n${context}`,
+ },
+ {
+ name: 'a.ts',
+ contents: `${context}\nconst value = computed();\n${context}`,
+ }
+ );
+ expect(verifyHunkLineValues(diff)).toEqual([]);
+ const result = await instance.asyncRender(diff);
+ assertDefined(
+ result.deletionsContentAST,
+ 'result.deletionsContentAST should be defined'
+ );
+ assertDefined(
+ result.additionsContentAST,
+ 'result.additionsContentAST should be defined'
+ );
+
+ const deletionRows = projectColumn(result.deletionsContentAST);
+ const additionRows = projectColumn(result.additionsContentAST);
+ // The deletion column renders the gap above the paired old line.
+ const bufferIndex = deletionRows.findIndex((row) => row.kind === 'buffer');
+ const deletionLineIndex = deletionRows.findIndex((row) =>
+ rowDigests([row])[0].includes('const value = compute();')
+ );
+ expect(bufferIndex).toBeGreaterThan(-1);
+ expect(deletionLineIndex).toBe(bufferIndex + 1);
+ // Both columns cover the same rendered rows.
+ const rowSpan = (rows: ReturnType) =>
+ rows.reduce((total, row) => total + (row.bufferSize ?? 1), 0);
+ expect(rowSpan(deletionRows)).toBe(rowSpan(additionRows));
+ });
+
test('additions and deletions should be empty when unified', async () => {
const instance = new DiffHunksRenderer({
...mockDiffs.diffRowBufferTest.options,
diff --git a/packages/diffs/test/DiffHunksRendererRecompute.test.ts b/packages/diffs/test/DiffHunksRendererRecompute.test.ts
index 39ac044b3..239bfcf8b 100644
--- a/packages/diffs/test/DiffHunksRendererRecompute.test.ts
+++ b/packages/diffs/test/DiffHunksRendererRecompute.test.ts
@@ -311,6 +311,65 @@ describe('DiffHunksRenderer edit-session hunk updates', () => {
expect(diff.additionLines.join('')).toBe(postEditLines.join(''));
});
+ test('a blank line pushed above an edited line keeps the pair aligned', async () => {
+ const { renderer, diff } = await createSessionRenderer();
+ // Edit line 3 into a near-match of its old side ("line 3" -> "line 3x"),
+ // then press Enter at its start: a blank line pushes it down one row.
+ renderer.updateRenderCache(makeDirtyLines([[2, 'line 3x']]), 'light');
+ const postEditLines = [
+ ...SESSION_NEW.slice(0, 2),
+ '\n',
+ 'line 3x\n',
+ ...SESSION_NEW.slice(3),
+ ];
+ renderer.applyDocumentChange(makeTextDocument(postEditLines));
+
+ // The edited line stays paired with its old side; the blank line renders
+ // as its own insert row above the pair instead of consuming the pairing.
+ const blocks = diff.hunks[0].hunkContent.filter(
+ (content) => content.type === 'change'
+ );
+ expect(
+ blocks.map(({ deletions, additions }) => [deletions, additions])
+ ).toEqual([
+ [0, 1],
+ [1, 1],
+ ]);
+ });
+
+ test('deleting a line keeps later context paired with its old side', async () => {
+ const { renderer, diff } = await createSessionRenderer();
+ // Delete context line 5 inside the first region. The region re-diff then
+ // contains a deletion-only parsed hunk after shared context; its
+ // zero-count addition side reports a `N,0`-convention line index, which
+ // previously shifted every following block by one row (the deleted row
+ // rendered against the wrong old line until session exit).
+ const postEditLines = [...SESSION_NEW.slice(0, 4), ...SESSION_NEW.slice(5)];
+ renderer.applyDocumentChange(makeTextDocument(postEditLines));
+
+ const rows: string[] = [];
+ iterateOverDiff({
+ diff,
+ diffStyle: 'split',
+ expandedHunks: true,
+ callback: ({ type, deletionLine, additionLine }) => {
+ rows.push(
+ `${type}:${deletionLine?.lineNumber ?? '-'}/${additionLine?.lineNumber ?? '-'}`
+ );
+ return rows.length >= 7;
+ },
+ });
+ expect(rows).toEqual([
+ 'context:1/1',
+ 'context:2/2',
+ 'change:3/3',
+ 'context:4/4',
+ 'change:5/-',
+ 'context:6/5',
+ 'context:7/6',
+ ]);
+ });
+
test('emptying the document behaves like the non-session shim', async () => {
const { renderer, diff } = await createSessionRenderer();
renderer.applyDocumentChange(makeTextDocument(['']));
diff --git a/packages/diffs/test/e2e/edit-collapsed.pw.ts b/packages/diffs/test/e2e/edit-collapsed.pw.ts
index 04dfda62b..23776fbcf 100644
--- a/packages/diffs/test/e2e/edit-collapsed.pw.ts
+++ b/packages/diffs/test/e2e/edit-collapsed.pw.ts
@@ -66,7 +66,7 @@ for (const diffStyle of ['split', 'unified'] as const) {
await expect(row(page, 10)).toHaveText('line 10');
// The reverted hunk persists as a context-only region mid-session.
- let lines = await renderedLines(page);
+ const lines = await renderedLines(page);
expect(lines).toContain(10);
expect(lines).not.toContain(30);
diff --git a/packages/diffs/test/realignChangeContent.test.ts b/packages/diffs/test/realignChangeContent.test.ts
new file mode 100644
index 000000000..c2bc51128
--- /dev/null
+++ b/packages/diffs/test/realignChangeContent.test.ts
@@ -0,0 +1,160 @@
+import { describe, expect, test } from 'bun:test';
+
+import type { FileDiffMetadata } from '../src/types';
+import { parseDiffFromFile } from '../src/utils/parseDiffFromFile';
+
+// parseDiffFromFile pairs change-block lines by similarity: the diff library
+// orders every deleted line before every added line in a block, and split
+// view pairs block lines positionally, so without realignment a blank line
+// inserted above an edited line renders across from the old text while the
+// edited line faces a gap.
+
+const CONTEXT = 'alpha\nbravo\ncharlie\ndelta\n';
+
+function parse(oldMiddle: string, newMiddle: string): FileDiffMetadata {
+ return parseDiffFromFile(
+ { name: 'a.ts', contents: CONTEXT + oldMiddle + CONTEXT },
+ { name: 'a.ts', contents: CONTEXT + newMiddle + CONTEXT }
+ );
+}
+
+function changeBlocks(diff: FileDiffMetadata) {
+ return diff.hunks.flatMap((hunk) =>
+ hunk.hunkContent.filter((content) => content.type === 'change')
+ );
+}
+
+describe('parseDiffFromFile change-block realignment', () => {
+ test('a blank line inserted above an edited line becomes its own insert block', () => {
+ const diff = parse(
+ ' const user = await db.users.findUnique({\n',
+ '\n const user = await db.users.findUniques({\n'
+ );
+ expect(changeBlocks(diff)).toEqual([
+ {
+ type: 'change',
+ deletions: 0,
+ additions: 1,
+ deletionLineIndex: 4,
+ additionLineIndex: 4,
+ },
+ {
+ type: 'change',
+ deletions: 1,
+ additions: 1,
+ deletionLineIndex: 4,
+ additionLineIndex: 5,
+ },
+ ]);
+ });
+
+ test('a blank line inserted below an edited line keeps the single block', () => {
+ // Positional pairing is already right here (edited line first), so the
+ // block stays canonical.
+ const diff = parse(
+ ' const user = await db.users.findUnique({\n',
+ ' const user = await db.users.findUniques({\n\n'
+ );
+ expect(changeBlocks(diff)).toEqual([
+ {
+ type: 'change',
+ deletions: 1,
+ additions: 2,
+ deletionLineIndex: 4,
+ additionLineIndex: 4,
+ },
+ ]);
+ });
+
+ test('a blank line removed above an edited line becomes its own delete block', () => {
+ const diff = parse(
+ '\n const user = await db.users.findUnique({\n',
+ ' const user = await db.users.findUniques({\n'
+ );
+ expect(changeBlocks(diff)).toEqual([
+ {
+ type: 'change',
+ deletions: 1,
+ additions: 0,
+ deletionLineIndex: 4,
+ additionLineIndex: 4,
+ },
+ {
+ type: 'change',
+ deletions: 1,
+ additions: 1,
+ deletionLineIndex: 5,
+ additionLineIndex: 4,
+ },
+ ]);
+ });
+
+ test('whitespace-only differences pair as the same line', () => {
+ // Unwrapping a container re-indents the kept line; similarity ignores
+ // whitespace, so the img lines pair and the removed wrapper renders as
+ // delete rows above and below.
+ const diff = parse('\n
![]()
\n
\n', '
\n');
+ expect(changeBlocks(diff)).toEqual([
+ {
+ type: 'change',
+ deletions: 1,
+ additions: 0,
+ deletionLineIndex: 4,
+ additionLineIndex: 4,
+ },
+ {
+ type: 'change',
+ deletions: 1,
+ additions: 1,
+ deletionLineIndex: 5,
+ additionLineIndex: 4,
+ },
+ {
+ type: 'change',
+ deletions: 1,
+ additions: 0,
+ deletionLineIndex: 6,
+ additionLineIndex: 5,
+ },
+ ]);
+ });
+
+ test('near-tie similarity keeps the canonical positional pairing', () => {
+ // Both additions share an import shape with the deletion; neither is a
+ // decisive winner, so the block must not be re-split.
+ const diff = parse(
+ "import Header from './Header';\n",
+ "import HeaderSimple from '../components/HeaderSimple';\nimport Hero from '../components/Hero';\n"
+ );
+ expect(changeBlocks(diff)).toEqual([
+ {
+ type: 'change',
+ deletions: 1,
+ additions: 2,
+ deletionLineIndex: 4,
+ additionLineIndex: 4,
+ },
+ ]);
+ });
+
+ test('several inserted lines above an edited pair split at the right offset', () => {
+ const diff = parse(
+ 'function work() {\n return compute(value);\n}\n',
+ '// note\n// more\nfunction work() {\n return compute(values);\n}\n'
+ );
+ // The three old lines pair against the last three new lines; the two
+ // comment lines become a leading insert block. (The identical lines end
+ // up as context via the library; only the changed pair stays in a block,
+ // so assert the paired block's indexes reflect the offset.)
+ const blocks = changeBlocks(diff);
+ for (const block of blocks) {
+ if (block.type !== 'change') {
+ continue;
+ }
+ if (block.deletions > 0 && block.additions > 0) {
+ expect(block.additionLineIndex - block.deletionLineIndex).toBe(2);
+ }
+ }
+ expect(diff.additionLines[4]).toBe('// note\n');
+ });
+});
From 7ff2050d97cf516a0ca4148c972f86ab7c2a66dd Mon Sep 17 00:00:00 2001
From: Amadeus Demarzi
Date: Fri, 10 Jul 2026 18:18:42 -0700
Subject: [PATCH 10/11] review(fix): Fix the arrow keys issue brought up by
codex
---
packages/diffs/src/editor/selection.ts | 24 +++++-
packages/diffs/test/e2e/edit-collapsed.pw.ts | 11 +++
.../diffs/test/editorFoldNavigation.test.ts | 74 +++++++++++++++++++
3 files changed, 105 insertions(+), 4 deletions(-)
diff --git a/packages/diffs/src/editor/selection.ts b/packages/diffs/src/editor/selection.ts
index e4bd8ddaa..37ebd6341 100644
--- a/packages/diffs/src/editor/selection.ts
+++ b/packages/diffs/src/editor/selection.ts
@@ -218,8 +218,16 @@ export function mapCursorMove(
false
);
} else if (line > 0) {
- line = line - 1;
- character = textDocument.getLineLength(line);
+ // Fold-skip: land at the end of the nearest renderable line above;
+ // stay put when everything above is collapsed.
+ const targetLine =
+ options.resolveRenderableLine == null
+ ? line - 1
+ : options.resolveRenderableLine(line - 1, 'up');
+ if (targetLine !== undefined) {
+ line = targetLine;
+ character = textDocument.getLineLength(line);
+ }
}
} else if (character < lineLength) {
character = stepCharacterByGrapheme(
@@ -229,8 +237,16 @@ export function mapCursorMove(
true
);
} else if (line < lineCount - 1) {
- line = line + 1;
- character = 0;
+ // Fold-skip: land at the start of the nearest renderable line below;
+ // stay put when everything below is collapsed.
+ const targetLine =
+ options.resolveRenderableLine == null
+ ? line + 1
+ : options.resolveRenderableLine(line + 1, 'down');
+ if (targetLine !== undefined) {
+ line = Math.min(targetLine, lineCount - 1);
+ character = 0;
+ }
}
}
const pos = { line, character };
diff --git a/packages/diffs/test/e2e/edit-collapsed.pw.ts b/packages/diffs/test/e2e/edit-collapsed.pw.ts
index 23776fbcf..882d8a3a2 100644
--- a/packages/diffs/test/e2e/edit-collapsed.pw.ts
+++ b/packages/diffs/test/e2e/edit-collapsed.pw.ts
@@ -93,6 +93,17 @@ for (const diffStyle of ['split', 'unified'] as const) {
await page.keyboard.press('ArrowUp');
await expect.poll(() => caretLine(page)).toBe(13);
+
+ // Horizontal motion at the boundary skips the gap too: ArrowRight at
+ // the line's end lands at the next renderable line's start, ArrowLeft
+ // at a line's start lands at the previous renderable line's end.
+ await page.keyboard.press('End');
+ await page.keyboard.press('ArrowRight');
+ await expect.poll(() => caretLine(page)).toBe(45);
+
+ await page.keyboard.press('Home');
+ await page.keyboard.press('ArrowLeft');
+ await expect.poll(() => caretLine(page)).toBe(13);
});
test('search navigation to a hidden match reveals it', async ({ page }) => {
diff --git a/packages/diffs/test/editorFoldNavigation.test.ts b/packages/diffs/test/editorFoldNavigation.test.ts
index 35f4d5bb2..97ec32c74 100644
--- a/packages/diffs/test/editorFoldNavigation.test.ts
+++ b/packages/diffs/test/editorFoldNavigation.test.ts
@@ -158,6 +158,80 @@ describe('diff editor: fold-skip navigation', () => {
}
});
+ test('arrow-right at the end of a hunk boundary line skips the gap', async () => {
+ const fixture = await createFoldFixture();
+ const { editor, content } = fixture;
+ try {
+ // End of line 14 (index 13), the last rendered line before the gap.
+ setCaret(editor, 13, 'line 14'.length);
+ await wait(0);
+ pressKey(content, 'ArrowRight');
+ const selection = editor.getState().selections?.at(-1);
+ // The caret lands at the start of the next renderable line (46).
+ expect(selection?.start.line).toBe(45);
+ expect(selection?.start.character).toBe(0);
+ } finally {
+ await fixture.cleanup();
+ }
+ });
+
+ test('arrow-left at the start of a hunk boundary line skips the gap', async () => {
+ const fixture = await createFoldFixture();
+ const { editor, content } = fixture;
+ try {
+ // Start of line 46 (index 45), the first rendered line after the gap.
+ setCaret(editor, 45, 0);
+ await wait(0);
+ pressKey(content, 'ArrowLeft');
+ const selection = editor.getState().selections?.at(-1);
+ // The caret lands at the end of the previous renderable line (14).
+ expect(selection?.start.line).toBe(13);
+ expect(selection?.start.character).toBe('line 14'.length);
+ } finally {
+ await fixture.cleanup();
+ }
+ });
+
+ test('shift+arrow-right extends the selection across the gap', async () => {
+ const fixture = await createFoldFixture();
+ const { editor, content } = fixture;
+ try {
+ setCaret(editor, 13, 'line 14'.length);
+ await wait(0);
+ pressKey(content, 'ArrowRight', { shiftKey: true });
+ const selection = editor.getState().selections?.at(-1);
+ expect(selection?.start.line).toBe(13);
+ expect(selection?.end.line).toBe(45);
+ expect(selection?.end.character).toBe(0);
+ } finally {
+ await fixture.cleanup();
+ }
+ });
+
+ test('horizontal motion stays put when only collapsed lines remain', async () => {
+ const fixture = await createFoldFixture();
+ const { editor, content } = fixture;
+ try {
+ // End of line 54 (index 53): only the collapsed trailing gap is below.
+ setCaret(editor, 53, 'line 54'.length);
+ await wait(0);
+ pressKey(content, 'ArrowRight');
+ let selection = editor.getState().selections?.at(-1);
+ expect(selection?.start.line).toBe(53);
+ expect(selection?.start.character).toBe('line 54'.length);
+
+ // Start of line 6 (index 5): only the collapsed leading gap is above.
+ setCaret(editor, 5, 0);
+ await wait(0);
+ pressKey(content, 'ArrowLeft');
+ selection = editor.getState().selections?.at(-1);
+ expect(selection?.start.line).toBe(5);
+ expect(selection?.start.character).toBe(0);
+ } finally {
+ await fixture.cleanup();
+ }
+ });
+
test('shift+arrow-down builds a selection spanning the gap', async () => {
const fixture = await createFoldFixture();
const { editor, content } = fixture;
From ec6c2b1b990a67ad3874880c3f2e20e76437a63c Mon Sep 17 00:00:00 2001
From: Amadeus Demarzi
Date: Fri, 10 Jul 2026 18:54:07 -0700
Subject: [PATCH 11/11] fix(bug): Fix a strange edge cases with diffs that
include intermixed blank lines
Unclear if this will create some knock on effects that make it harder to
manage in the future...
---
packages/diffs/src/utils/editSessionHunks.ts | 24 +++-
.../diffs/src/utils/realignChangeContent.ts | 104 ++++++++++++++++-
packages/diffs/test/editSessionHunks.test.ts | 42 +++++++
.../diffs/test/realignChangeContent.test.ts | 105 ++++++++++++++++++
4 files changed, 270 insertions(+), 5 deletions(-)
diff --git a/packages/diffs/src/utils/editSessionHunks.ts b/packages/diffs/src/utils/editSessionHunks.ts
index 4d382636e..ce483047e 100644
--- a/packages/diffs/src/utils/editSessionHunks.ts
+++ b/packages/diffs/src/utils/editSessionHunks.ts
@@ -8,6 +8,7 @@ import type {
HunkExpansionRegion,
} from '../types';
import { parseDiffFromFile } from './parseDiffFromFile';
+import { slideBlankBoundaryBlocksUp } from './realignChangeContent';
import {
offsetHunkContent,
recomputeDiffHunksForEdit,
@@ -573,10 +574,23 @@ function rediffRegion(
coveredDeletions += contextLines;
for (const content of parsedHunk.hunkContent) {
// Parsed content indexes are relative to the slice; offset them into
- // full-file coordinates.
- hunkContent.push(
- offsetHunkContent(content, bounds.additionStart, bounds.deletionStart)
+ // full-file coordinates. A zero-count side carries the `N,0`
+ // convention (one short of the lines consumed), so pin it to the
+ // running counter instead.
+ const offset = offsetHunkContent(
+ content,
+ bounds.additionStart,
+ bounds.deletionStart
);
+ if (offset.type === 'change') {
+ if (offset.additions === 0) {
+ offset.additionLineIndex = bounds.additionStart + coveredAdditions;
+ }
+ if (offset.deletions === 0) {
+ offset.deletionLineIndex = bounds.deletionStart + coveredDeletions;
+ }
+ }
+ hunkContent.push(offset);
}
additionChangedLines += parsedHunk.additionLines;
deletionChangedLines += parsedHunk.deletionLines;
@@ -609,6 +623,10 @@ function rediffRegion(
noEOFCRAdditions: false,
noEOFCRDeletions: false,
};
+ // The inner parse runs with zero context, so blank-run slides can only
+ // apply once the context blocks are reassembled here — keeping the session
+ // rendering identical to the exit parse, which slides in its own post-pass.
+ slideBlankBoundaryBlocksUp(hunk, diff);
recomputeHunkRenderLineCounts(hunk);
return hunk;
}
diff --git a/packages/diffs/src/utils/realignChangeContent.ts b/packages/diffs/src/utils/realignChangeContent.ts
index a2c51168c..fa3a6d6fc 100644
--- a/packages/diffs/src/utils/realignChangeContent.ts
+++ b/packages/diffs/src/utils/realignChangeContent.ts
@@ -1,4 +1,9 @@
-import type { ChangeContent, ContextContent, FileDiffMetadata } from '../types';
+import type {
+ ChangeContent,
+ ContextContent,
+ FileDiffMetadata,
+ Hunk,
+} from '../types';
// The diff library emits one change block per replaced run, ordering every
// deleted line before every added line. Renderers pair a block's lines
@@ -23,7 +28,8 @@ const MIN_IMPROVEMENT_PER_PAIR = 0.5;
/**
* Re-split count-mismatched change blocks in every hunk so paired lines are
- * chosen by content similarity instead of position. Mutates `hunks` in
+ * chosen by content similarity instead of position, then slide blank-line
+ * insert/delete blocks to the top of their blank run. Mutates `hunks` in
* place; rendered row counts are unchanged (a split block covers the same
* split/unified rows as the original).
*/
@@ -42,6 +48,100 @@ export function realignChangeContentBySimilarity(
index += replacement.length - 1;
}
}
+ slideBlankBoundaryBlocksUp(hunk, diff);
+ }
+}
+
+/**
+ * Slide pure insert/delete blocks made entirely of blank lines to the top of
+ * the blank run they sit in. Adding or removing a blank line next to
+ * existing blanks is ambiguous, and the diff library reports the change at
+ * the run's bottom — so pressing Enter at the end of a line marks a blank
+ * *below* the caret as inserted while the caret's own new line renders as
+ * context. Sliding up anchors the change to the content above it (the caret
+ * line after an Enter) instead. Non-blank blocks never slide, so code that
+ * merely ends like its neighbor (an added function before an identical `}`)
+ * keeps the library's canonical position. Runs on final assembled
+ * hunkContent: from the parse post-pass above and from the edit session's
+ * region re-diff, so mid-session and exit renderings agree.
+ */
+export function slideBlankBoundaryBlocksUp(
+ hunk: Hunk,
+ diff: Pick
+): void {
+ const { hunkContent } = hunk;
+ for (let index = 1; index < hunkContent.length; index++) {
+ const block = hunkContent[index];
+ const previous = hunkContent[index - 1];
+ if (
+ block.type !== 'change' ||
+ (block.additions > 0 && block.deletions > 0) ||
+ previous.type !== 'context'
+ ) {
+ continue;
+ }
+ const isInsert = block.additions > 0;
+ const lines = isInsert ? diff.additionLines : diff.deletionLines;
+ const blockStart = isInsert
+ ? block.additionLineIndex
+ : block.deletionLineIndex;
+ const blockLength = isInsert ? block.additions : block.deletions;
+
+ // Sliding through identical lines is only well-defined when the block is
+ // a uniform run; require every block line to equal the first one, and
+ // that line to be blank.
+ const unit = lines[blockStart] ?? '';
+ if (unit.trim() !== '') {
+ continue;
+ }
+ let uniform = true;
+ for (let offset = 1; offset < blockLength; offset++) {
+ if (lines[blockStart + offset] !== unit) {
+ uniform = false;
+ break;
+ }
+ }
+ if (!uniform) {
+ continue;
+ }
+
+ // Slide distance: how many trailing context lines match the block's line
+ // exactly (context lines are identical on both sides by definition).
+ let slide = 0;
+ while (
+ slide < previous.lines &&
+ diff.additionLines[
+ previous.additionLineIndex + previous.lines - 1 - slide
+ ] === unit
+ ) {
+ slide++;
+ }
+ if (slide === 0) {
+ continue;
+ }
+
+ block.additionLineIndex -= slide;
+ block.deletionLineIndex -= slide;
+ const blockAdditionEnd = block.additionLineIndex + block.additions;
+ const blockDeletionEnd = block.deletionLineIndex + block.deletions;
+ const next = hunkContent[index + 1];
+ if (next?.type === 'context') {
+ next.lines += slide;
+ next.additionLineIndex = blockAdditionEnd;
+ next.deletionLineIndex = blockDeletionEnd;
+ } else {
+ hunkContent.splice(index + 1, 0, {
+ type: 'context',
+ lines: slide,
+ additionLineIndex: blockAdditionEnd,
+ deletionLineIndex: blockDeletionEnd,
+ });
+ }
+ previous.lines -= slide;
+ if (previous.lines === 0) {
+ hunkContent.splice(index - 1, 1);
+ index--;
+ }
}
}
diff --git a/packages/diffs/test/editSessionHunks.test.ts b/packages/diffs/test/editSessionHunks.test.ts
index d1074221a..7e67c0253 100644
--- a/packages/diffs/test/editSessionHunks.test.ts
+++ b/packages/diffs/test/editSessionHunks.test.ts
@@ -242,6 +242,48 @@ describe('applySessionEditWindow', () => {
expect(hunk.hunkContent[0].type).toBe('context');
}
});
+
+ test('a blank pushed beside an existing blank stays anchored to the edit', () => {
+ // Enter at the end of a changed line that is followed by a blank line:
+ // the prefix scan slides the detected insert past the identical blank,
+ // but the blank-run slide re-anchors it to the edited line, matching the
+ // exit parse.
+ const oldContents = 'a\nb\ntarget\n\nafter\nz1\nz2\nz3\nz4\nz5\n';
+ const newContents = oldContents.replace('target\n', 'target!\n');
+ const diff = parseDiffFromFile(
+ { name: 't.ts', contents: oldContents },
+ { name: 't.ts', contents: newContents }
+ );
+ diff.additionLines.splice(3, 0, '\n');
+ // The scan reports the insert after the pre-existing blank at index 3.
+ const change = applySessionEditWindow(diff, {
+ start: 4,
+ prevEnd: 4,
+ nextEnd: 5,
+ });
+
+ expect(change).toBeUndefined();
+ // The line pairing matches what the exit recompute will produce: the
+ // change blocks are identical (hunk bounds legitimately differ — the
+ // frozen region keeps its envelope while exit re-derives context).
+ const exit = parseDiffFromFile(
+ { name: 't.ts', contents: oldContents },
+ { name: 't.ts', contents: diff.additionLines.join('') }
+ );
+ const changesOf = (hunkContent: (typeof diff.hunks)[0]['hunkContent']) =>
+ hunkContent.filter((block) => block.type === 'change');
+ expect(changesOf(diff.hunks[0].hunkContent)).toEqual(
+ changesOf(exit.hunks[0].hunkContent)
+ );
+ // Anchored directly after the edited line, not after the old blank.
+ expect(changesOf(diff.hunks[0].hunkContent)[1]).toEqual({
+ type: 'change',
+ deletions: 0,
+ additions: 1,
+ deletionLineIndex: 3,
+ additionLineIndex: 3,
+ });
+ });
});
describe('applySessionChangedLines', () => {
diff --git a/packages/diffs/test/realignChangeContent.test.ts b/packages/diffs/test/realignChangeContent.test.ts
index c2bc51128..c992370a5 100644
--- a/packages/diffs/test/realignChangeContent.test.ts
+++ b/packages/diffs/test/realignChangeContent.test.ts
@@ -158,3 +158,108 @@ describe('parseDiffFromFile change-block realignment', () => {
expect(diff.additionLines[4]).toBe('// note\n');
});
});
+
+describe('blank-run slide canonicalization', () => {
+ test('a blank inserted beside an existing blank slides up to the change above', () => {
+ // Enter at the end of an edited line inserts a blank before an existing
+ // one; the library reports the insert at the run's bottom, the slide
+ // anchors it to the edited line (where the caret is).
+ const diff = parse('const a = 1;\n\nrest\n', 'const a = 2;\n\n\nrest\n');
+ expect(changeBlocks(diff)).toEqual([
+ {
+ type: 'change',
+ deletions: 1,
+ additions: 1,
+ deletionLineIndex: 4,
+ additionLineIndex: 4,
+ },
+ {
+ type: 'change',
+ deletions: 0,
+ additions: 1,
+ deletionLineIndex: 5,
+ additionLineIndex: 5,
+ },
+ ]);
+ });
+
+ test('a blank removed beside an existing blank slides up', () => {
+ const diff = parse('const a = 1;\n\n\nrest\n', 'const a = 2;\n\nrest\n');
+ expect(changeBlocks(diff)).toEqual([
+ {
+ type: 'change',
+ deletions: 1,
+ additions: 1,
+ deletionLineIndex: 4,
+ additionLineIndex: 4,
+ },
+ {
+ type: 'change',
+ deletions: 1,
+ additions: 0,
+ deletionLineIndex: 5,
+ additionLineIndex: 5,
+ },
+ ]);
+ });
+
+ test('identical non-blank lines never slide', () => {
+ // Adding a function before an identical closing brace: the canonical
+ // position (insert after the existing `}`) must be preserved.
+ const diff = parse(
+ 'function a() {\n}\n',
+ 'function a() {\n}\nfunction b() {\n}\n'
+ );
+ expect(changeBlocks(diff)).toEqual([
+ {
+ type: 'change',
+ deletions: 0,
+ additions: 2,
+ deletionLineIndex: 6,
+ additionLineIndex: 6,
+ },
+ ]);
+ });
+
+ test('whitespace-only but unequal lines do not slide', () => {
+ // The context line holds a single space; the inserted line is empty.
+ // They are not identical, so sliding would change the diff's meaning.
+ const diff = parse('x = 1;\n \nrest\n', 'x = 2;\n \n\nrest\n');
+ expect(changeBlocks(diff)).toEqual([
+ {
+ type: 'change',
+ deletions: 1,
+ additions: 1,
+ deletionLineIndex: 4,
+ additionLineIndex: 4,
+ },
+ {
+ type: 'change',
+ deletions: 0,
+ additions: 1,
+ deletionLineIndex: 6,
+ additionLineIndex: 6,
+ },
+ ]);
+ });
+
+ test('slides to the top of a multi-blank run', () => {
+ const diff = parse('head;\n\n\n\ntail;\n', 'head!;\n\n\n\n\ntail;\n');
+ expect(changeBlocks(diff)).toEqual([
+ {
+ type: 'change',
+ deletions: 1,
+ additions: 1,
+ deletionLineIndex: 4,
+ additionLineIndex: 4,
+ },
+ {
+ type: 'change',
+ deletions: 0,
+ additions: 1,
+ deletionLineIndex: 5,
+ additionLineIndex: 5,
+ },
+ ]);
+ });
+});