999 lines · 60.4 KB · Markdown
RawDownload

Diff-Aware Buffer Redesign

Problem Statement

Split diff (side-by-side diff view) in Zed is currently built as a coordination problem. Two entirely separate editors, each with their own multibuffer, display pipeline, scroll state, and selections, are kept in sync by an elaborate synchronization layer:

  • Two multibuffers: The RHS editor has an editable MultiBuffer with excerpts from the working buffer. The LHS editor has a read-only MultiBuffer with excerpts from the base text buffer (owned by BufferDiff). The same BufferDiff entity is registered on the LHS in "inverted" mode.

  • Companion struct (display_map.rs ~L232): Maintains bidirectional hash maps mapping excerpt IDs, buffer IDs, and row conversion functions between the two sides.

  • Spacer blocks (block_map.rs, spacer_blocks() ~L1207): The block map inserts invisible spacer blocks on the shorter side of each hunk to keep rows aligned. This requires threading points through the full display pipeline (InlayMap → FoldMap → TabMap → WrapMap) on both sides to compute wrap counts, then computing deltas, then inserting spacers at the right positions.

  • Balancing blocks: Custom blocks (diagnostics, etc.) on one side trigger mirrored "balancing" blocks on the other side.

  • SharedScrollAnchor (scroll.rs ~L79): A shared scroll anchor entity is shared between both editors, with display-map-aware resolution.

  • Cursor syncing (split.rs ~L732): sync_cursor_to_other_side translates cursor positions between the two editors.

  • Action interception: Actions on the LHS (stage, restore, open file) are intercepted and translated to RHS coordinates before execution.

  • Excerpt mirroring (split.rs ~L1871): LhsEditor::sync_path_excerpts mirrors excerpt additions, removals, and expansions from the RHS to the LHS.

  • DiffTransform tree (multi_buffer.rs ~L638): A secondary SumTree<DiffTransform> in MultiBufferSnapshot interleaves BufferContent and DeletedHunk items to inject base text into the multibuffer's coordinate space. This tree must be walked in lockstep with the excerpts tree by MultiBufferCursor.

  • O(n) coordinate translation: BufferDiffSnapshot::patch_for_buffer_range and patch_for_base_text_range (buffer_diff.rs ~L412-510) build Patch<Point> (a flat Vec<Edit<T>>) for coordinate translation, requiring linear traversal.

This architecture has six fundamental problems:

  1. Every new editor feature must be taught about the companion system. Diagnostics, inlay hints, folds, breakpoints — anything that adds blocks or affects layout must consider how it interacts with the two-editor coordination.

  2. Spacer block alignment is fragile. It reverse-engineers wrap counts through the entire display pipeline of a companion editor. Changes to any display pipeline stage can break alignment.

  3. The architecture is asymmetric. The RHS is a real editor operating on a real buffer. The LHS is a synthetic read-only mirror that exists only to show the "other side." This asymmetry creates special cases throughout the codebase.

  4. Coordinate translation is O(n) through Patch<Point>, rather than O(log n) through a SumTree.

  5. The DiffTransform tree and excerpt tree must be walked in lockstep, creating complex cursor logic (MultiBufferCursor ~200 lines) that is a frequent source of bugs.

  6. sync_diff_transforms (multi_buffer.rs ~L3127) is approximately 300 lines of the most complex code in the multibuffer, rebuilding the DiffTransform tree in response to buffer edits and diff recomputations.


Overview

This document describes the redesign that eliminates these problems.

The fundamental shift: Diff awareness moves from being bolted onto the multibuffer via a separate DiffTransform tree and Companion synchronization system, to being a native property of the content — baked into the summaries that flow through the SumTree, threaded as metadata through every display pipeline layer, and rendered by a single diff-aware editor.

Three architectural pillars:

  1. The MultiBuffer is composed on Entity<BufferDiff>. BufferDiff wraps a buffer and optionally holds a diff base. A buffer with no diff is the degenerate case. Different views of the same buffer can have independent diffs.

  2. A DiffMap pipeline stage produces a unified diff text stream. It sits between the MultiBuffer and InlayMap, introducing Removed text from the base and tagging every chunk with diff_status. Downstream layers carry this metadata through, and some make behavioral decisions based on it (editability, fold boundaries, etc.).

  3. One diff-aware editor renders everything. Split view is a rendering mode of the EditorElement, not a structural concept. There are no two synchronized editors — one editor, one multibuffer, one display pipeline, one cursor. The renderer draws two panels from the same display snapshot, routing content to left/right based on diff_status.


Architecture Decision: Separate BufferDiff Entity

The BufferDiff entity is the fundamental unit that the MultiBuffer is composed on. It wraps an Entity<language::Buffer> and optionally holds a diff base (and secondary diff base for staging).

Rationale

Different views of the same live buffer legitimately need different diffs simultaneously:

  • The regular editor shows a git diff (HEAD → working copy) for gutter indicators.
  • The agent diff pane shows an action-log diff (pre-agent state → working copy) for the same buffer.
  • During agent review, the action-log diff replaces the git diff on the regular editor's multibuffer (agent_diff.rs ~L1489 adds the action log's diff to the regular editor's multibuffer).

If diff state lived on language::Buffer itself, these scenarios would require mutating the buffer's diff base — clobbering the git state for every other view. The separate entity makes the diff-to-buffer association a property of the view (multibuffer), not the data (buffer).

Many current BufferDiff constructions are artifacts of the current architecture. The git UI views (commit_view, file_diff_view, multi_diff_view), the edit prediction rating modal, and similar callers all create purpose-built buffers that aren't shared with the editor. For these, setting a diff base directly on the buffer would work fine. But the action log and agent diff cases are genuine multi-diff scenarios that require independent diff entities wrapping the same live buffer.

What Stays the Same

  • language::Buffer is unchanged. It does not gain diff fields.
  • text::Buffer and text::BufferSnapshot are unchanged.
  • The rope, CRDT, fragment tree, and all text operations are unmodified.

What Changes

  • BufferDiff is restructured: base text becomes a Rope (not Entity<language::Buffer>), and the hunk tree becomes SumTree<DiffRegion>. This eliminates one entity per diff compared to today.
  • MultiBuffer is composed on Entity<BufferDiff>. Its buffers map becomes buffer_diffs. Excerpt snapshots carry diff region information.
  • The DiffTransform tree, Companion system, LhsEditor, spacer blocks, and excerpt mirroring are all eliminated.
  • A new DiffMap display pipeline stage handles the unified diff text stream.
  • The editor and EditorElement become natively diff-aware. Split view is a rendering mode, not a structural concept.

Data Model

DiffRegion and the Region Tree

The relationship between buffer text and base text is described by a SumTree<DiffRegion>. Each region is classified:

  • Unchanged — text present in both buffer and base (identical content). buffer_len > 0, base_len > 0, and they are equal.
  • Added — text present in buffer only. buffer_len > 0, base_len == 0.
  • Removed — text present in base only. buffer_len == 0, base_len > 0.

The tree uses length-based boundaries, not anchors. Base text is a Rope with no CRDT, so anchor-based boundaries don't work for the base side.

Key invariants:

  • The cumulative buffer_len across all regions equals the buffer's text length.
  • The cumulative base_len across all regions equals the base text length.
  • Adjacent regions never have the same kind (they would be merged).
// In the `text` crate:

pub enum DiffRegionKind {
    Unchanged,
    Added,
    Removed,
}

pub struct DiffRegion {
    pub kind: DiffRegionKind,
    pub buffer_len: usize,
    pub base_len: usize,
}

Three-Dimensional Summary

DiffRegionSummary carries three TextSummary dimensions plus DiffStats:

pub struct DiffRegionSummary {
    /// Cumulative buffer text extent (Unchanged + Added regions).
    pub buffer: TextSummary,
    /// Cumulative base text extent (Unchanged + Removed regions).
    pub base: TextSummary,
    /// Cumulative unified diff extent (all regions — the total vertical space).
    pub diff: TextSummary,
    /// Accumulated diff statistics.
    pub stats: DiffStats,
}

pub struct DiffStats {
    pub lines_added: u32,
    pub lines_removed: u32,
    pub bytes_added: usize,
    pub bytes_removed: usize,
}

For each region kind:

Kind buffer base diff stats
Unchanged buffer text summary base text summary (= buffer) buffer text summary all zeros
Added buffer text summary zero buffer text summary lines/bytes from buffer
Removed zero base text summary base text summary lines/bytes from base

The diff dimension for each region is whichever side has text: for Unchanged and Added it's the buffer summary, for Removed it's the base summary. The total diff extent equals buffer.lines + stats.lines_removed (equivalently base.lines + stats.lines_added). This is the unified diff height — the total vertical space needed to display the complete diff.

All three dimensions are seekable in O(log n) through the SumTree. The diff dimension is what the display pipeline operates in for vertical layout.

DiffStats in the Summary Algebra

DiffStats fields accumulate through SumTree addition (each field is simply added). This means:

  • Total diff stats for a multibuffer: snapshot.excerpts.summary().diff_stats — O(1).
  • Diff stats for a range: SumTree range query — O(log n).
  • Diff stats per buffer in a multibuffer: walk relevant excerpts — O(log n) per excerpt.

This replaces the current O(n)-in-hunks computation used by CommitView::calculate_changed_lines and various diff stats displays.

rope::TextSummary Is Unchanged

The three-dimensional summary (DiffRegionSummary) lives at the buffer-diff level, not the rope level. Adding diff fields to rope::TextSummary would bloat every chunk summary in every rope by ~50% for a feature most ropes don't use. The rope stays lean.

BufferDiff Entity Structure

pub struct BufferDiff {
    pub buffer_id: BufferId,
    buffer: Entity<language::Buffer>,
    diff_base: Option<DiffBaseState>,
    secondary_diff_base: Option<DiffBaseState>,
    diff_version: usize,
    _buffer_subscription: gpui::Subscription,
}

struct DiffBaseState {
    text: Rope,
    regions: SumTree<DiffRegion>,
    word_diffs: Vec<WordDiffHunk>,
}

Compared to today:

  • SumTree<InternalDiffHunk> is replaced by SumTree<DiffRegion>.
  • Entity<language::Buffer> for base text is replaced by Rope. This eliminates one entity per diff and removes the need for CRDT machinery on base text.
  • The BufferDiff subscribes to the buffer for edit-time region updates (via _buffer_subscription).
  • The secondary diff (for staging status) is inlined as a DiffBaseState rather than being a separate Entity<BufferDiff>.

Snapshot Structure

pub struct BufferDiffSnapshot {
    pub buffer: language::BufferSnapshot,
    diff_base: Option<DiffBaseSnapshot>,
    secondary_diff_base: Option<DiffBaseSnapshot>,
    diff_version: usize,
}

struct DiffBaseSnapshot {
    text: Rope,
    regions: SumTree<DiffRegion>,
    word_diffs: Vec<WordDiffHunk>,
}

The snapshot carries the buffer snapshot alongside the diff information. When there's no diff, diff_base is None — the degenerate case. The MultiBuffer works with this snapshot type for all excerpt operations.

Degenerate Case (No Diff)

When a BufferDiff has no base text (diff_base: None):

  • The region tree is empty.
  • diff_text_summary_for_range returns buffer: TextSummary, base: zero, diff: buffer, stats: zero.
  • The excerpt's diff_base_summary is zero and diff_stats is zero.
  • The diff dimension equals the text dimension.
  • The DiffMap pipeline stage is a passthrough (zero cost).

This means every buffer can flow through the same diff-aware pipeline with no overhead when there's no diff.


MultiBuffer Composition on BufferDiff

Structural Changes

MultiBuffer's fundamental unit changes from Entity<Buffer> to Entity<BufferDiff>:

pub struct MultiBuffer {
    snapshot: RefCell<MultiBufferSnapshot>,
    buffer_diffs: BTreeMap<BufferId, BufferDiffState>,
    // ... other fields unchanged ...
}

struct BufferDiffState {
    diff: Entity<BufferDiff>,
    last_version: RefCell<clock::Global>,
    last_diff_version: Cell<usize>,
    last_non_text_state_update_count: Cell<usize>,
    excerpts: Vec<Locator>,
    _subscriptions: [gpui::Subscription; 2], // buffer events + diff events
}

The diffs: TreeMap<BufferId, DiffState> map and the DiffTransform SumTree are eliminated. The add_diff() / add_inverted_diff() methods are eliminated.

Excerpt Changes

The Excerpt carries the BufferDiffSnapshot and three summary dimensions:

struct Excerpt {
    id: ExcerptId,
    locator: Locator,
    buffer_id: BufferId,
    buffer_diff: BufferDiffSnapshot,
    range: Range<text::Anchor>,
    max_buffer_row: BufferRow,
    text_summary: TextSummary,
    diff_base_summary: TextSummary,
    diff_summary: TextSummary,      // unified diff extent
    diff_stats: DiffStats,
    has_trailing_newline: bool,
}

ExcerptSummary carries all four accumulable fields:

pub struct ExcerptSummary {
    excerpt_id: ExcerptId,
    excerpt_locator: Option<Locator>,
    widest_line_number: u32,
    text: MBTextSummary,
    diff_base: MBTextSummary,
    diff: MBTextSummary,       // unified diff extent
    diff_stats: DiffStats,
}

The diff field serves as the primary vertical dimension for the display pipeline. In the degenerate case (no diff), diff == text.

API Surface

impl MultiBuffer {
    /// Primary constructor — takes a BufferDiff.
    pub fn singleton(buffer_diff: Entity<BufferDiff>, cx: &mut Context<Self>) -> Self;

    /// Convenience — wraps a buffer in a no-diff BufferDiff.
    pub fn singleton_buffer(buffer: Entity<Buffer>, cx: &mut Context<Self>) -> Self;

    pub fn push_excerpts(
        &mut self,
        buffer_diff: Entity<BufferDiff>,
        ranges: impl IntoIterator<Item = ExcerptRange<Point>>,
        cx: &mut Context<Self>,
    ) -> Vec<ExcerptId>;

    /// Convenience — wraps buffer in a no-diff BufferDiff.
    pub fn push_buffer_excerpts(
        &mut self,
        buffer: Entity<Buffer>,
        ranges: impl IntoIterator<Item = ExcerptRange<Point>>,
        cx: &mut Context<Self>,
    ) -> Vec<ExcerptId>;
}

Callers that work with diffs (editor, agent diff, git UI) use the primary Entity<BufferDiff> API. Callers that don't care about diffs (search results, scratch buffers, completion menus) use the convenience methods.

MultiBufferCursor Simplification

The current MultiBufferCursor walks two SumTrees in lockstep: the excerpt tree and the DiffTransform tree. This is ~200 lines of complex cursor logic.

In the new model, the cursor walks a single excerpt tree. The diff information is a native property of each excerpt's summary. The DiffTransform cursor, lockstep logic, DiffTransformSummary, and all associated dimension types are eliminated.

struct MultiBufferCursor<'a, MBD, BD> {
    excerpts: Cursor<'a, 'static, Excerpt, ExcerptDimension<MBD>>,
    cached_region: OnceCell<Option<MultiBufferRegion<'a, MBD, BD>>>,
}

Sync Path

When the buffer is edited or the diff changes, the sync path rebuilds the affected excerpt with a fresh BufferDiffSnapshot. The three summary dimensions (text, diff_base, diff) and diff_stats are recomputed from the snapshot's region tree. The sync_diff_transforms function (~300 lines) and recompute_diff_transforms_for_edit (~200 lines) are eliminated entirely.


The Unified Diff Coordinate Space

The three-dimensional summary on DiffRegionSummary provides a unified diff coordinate space that is the key to the entire design.

Why Three Dimensions

  • buffer — the working copy text. Offsets, points, and anchors refer to this space. This is the coordinate space of the CRDT buffer.
  • base — the diff base text. Used for reading Removed text, coordinate translation, and staging operations.
  • diff — the unified diff extent. This is the total vertical space needed to display the complete diff. Both sides' text contributes: Unchanged lines appear once, Added lines appear once, Removed lines appear once. The total is unchanged_lines + added_lines + removed_lines.

Vertical Space Accounting

The diff dimension answers the question: "how tall is this diff?" For a concrete example:

Unchanged: "hello\nworld\n"      → 2 buffer lines, 2 base lines, 2 diff lines
Removed:   "old line\n"          → 0 buffer lines, 1 base line,  1 diff line
Added:     "new line 1\nnew 2\n" → 2 buffer lines, 0 base lines, 2 diff lines
Unchanged: "goodbye\n"           → 1 buffer line,  1 base line,  1 diff line

Total: buffer = 5 lines, base = 4 lines, diff = 6 lines.

In the unified diff stream (what the display pipeline processes):

  • Row 0: "hello\n" — diff_status: Unchanged
  • Row 1: "world\n" — diff_status: Unchanged
  • Row 2: "old line\n" — diff_status: Removed (from base text)
  • Row 3: "new line 1\n" — diff_status: Added (from buffer text)
  • Row 4: "new 2\n" — diff_status: Added (from buffer text)
  • Row 5: "goodbye\n" — diff_status: Unchanged

In split view rendering, each panel sees 6 rows:

  • Left panel: rows 0-1 text, row 2 text (Removed, red bg), rows 3-4 gap (Added), row 5 text.
  • Right panel: rows 0-1 text, row 2 gap (Removed), rows 3-4 text (Added, green bg), row 5 text.

The alignment is free — both panels have 6 rows because both are rendering from the same unified diff stream. Scroll position is one value. No synchronization logic.

Coordinate Translation

All coordinate translations are O(log n) via the region tree:

  • to_diff_base_offset(buffer_offset) -> base_offset — seek by buffer dimension, read base dimension.
  • from_diff_base_offset(base_offset) -> buffer_offset — seek by base dimension, read buffer dimension.
  • to_diff_offset(buffer_offset) -> diff_offset — seek by buffer dimension, read diff dimension.
  • from_diff_offset(diff_offset) -> buffer_offset — seek by diff dimension, read buffer dimension.
  • Analogous Point variants for all of the above.

How the Dimensions Flow Through the Architecture

DiffRegion SumTree
  └── DiffRegionSummary { buffer, base, diff, stats }

BufferDiffSnapshot
  └── Contains the region tree, provides O(log n) queries in all three dimensions

Excerpt
  └── text_summary (buffer), diff_base_summary (base), diff_summary (diff), diff_stats

ExcerptSummary
  └── text (buffer), diff_base (base), diff (diff), diff_stats

MultiBufferSnapshot
  └── excerpts: SumTree<Excerpt> — seekable by all four accumulated dimensions

DiffMap (new pipeline stage)
  └── Operates in the `diff` dimension
  └── Produces unified text stream with diff_status on every chunk

InlayMap → FoldMap → TabMap → WrapMap → BlockMap
  └── All carry diff_status through their Chunk types
  └── Some make behavioral decisions (editability, fold boundaries)

EditorElement
  └── In split mode: draws two panels from the same display snapshot
  └── Routes content to left/right based on diff_status

Display Pipeline: The DiffMap

Position in the Pipeline

MultiBuffer(BufferDiff)
  → DiffMap          ← NEW: produces unified diff text stream
    → InlayMap
      → FoldMap
        → TabMap
          → WrapMap
            → BlockMap
              → display rows

What the DiffMap Does

The DiffMap takes the MultiBufferSnapshot (which carries diff region information in its excerpts) and produces a unified text stream in the diff coordinate space. Each chunk in this stream carries a diff_status annotation.

For a buffer with no diff (degenerate case), the DiffMap is a zero-cost passthrough: diff coordinates equal buffer coordinates, and all chunks have diff_status: None.

For a buffer with a diff, the DiffMap:

  1. Iterates through excerpt diff regions in order.
  2. For Unchanged regions: yields the buffer text with diff_status: Unchanged.
  3. For Added regions: yields the buffer text with diff_status: Added.
  4. For Removed regions (when expanded): yields the base text (read from the diff base Rope) with diff_status: Removed.
  5. For Removed regions (when collapsed in inline mode): yields nothing (the region is elided from the stream). A gutter indicator marks the hunk boundary.

DiffMap Transforms

Like other pipeline stages, the DiffMap maintains a SumTree<DiffMapTransform> with input/output summary pairs:

enum DiffMapTransform {
    /// Pass-through: input excerpt text maps directly to output.
    /// Covers Unchanged and Added regions (their text is in the buffer).
    Isomorphic(TextSummary),

    /// Inserted content from the diff base that doesn't exist in the
    /// MultiBuffer's buffer coordinate space.
    DiffBaseInsertion {
        output: TextSummary,
        buffer_id: BufferId,
        base_byte_range: Range<usize>,
    },

    /// Elided content: a collapsed Removed region that produces no output.
    /// In split view, this variant is never used (all hunks are expanded).
    Elided,
}

Expand/Collapse State

The DiffMap holds the expand/collapse state for inline diff hunks:

  • Inline view, collapsed hunk (default): The Removed region produces an Elided transform. No base text appears in the stream. The gutter shows a diff indicator.
  • Inline view, expanded hunk: The Removed region produces a DiffBaseInsertion transform. The base text appears in the stream with diff_status: Removed.
  • Split view: All hunks are always expanded. Every Removed region produces a DiffBaseInsertion.

The editor tracks which hunks are expanded via a set of hunk identifiers (buffer anchors that survive edits). The DiffMap consults this set when building its transforms.

The sync Method

The DiffMap's sync follows the same pattern as InlayMap::sync (see inlay_map.rs ~L555):

  1. Takes a new MultiBufferSnapshot and a set of Edit<MultiBufferOffset> edits.
  2. Rebuilds the affected portion of its transform tree.
  3. Produces transformed Edit<DiffOffset> edits for the layer above (InlayMap).

When the diff base changes (new diff computation completes), the DiffMap receives this as an excerpt rebuild from the MultiBuffer. The affected regions get new transforms. In the worst case (full diff recomputation changes many regions), the DiffMap rebuild touches all transforms for that excerpt — but this is bounded by the number of diff regions, not the text size.


Diff Awareness Through the Pipeline

Lines 1–500 of 999.