Skip to content

Side-by-side diff panes drift out of alignment past the first hunk #166

Description

@JDonaghy

Summary

In the side-by-side diff view (:Gdiffsplit, and the Source Control panel's "open diff" flow from Session 197), the two panes line up at the top but visibly drift once you scroll past the first hunk. After a hunk with +3/-1, the HEAD pane ends up showing lines that are three rows higher than the working-copy pane shows for the "same" logical row. The longer you scroll, the worse it gets.

Expected

Both panes stay in visual lockstep — when the working-copy side shows an added line, the HEAD side shows a blank filler row, and vice versa. Vim/vimdiff and VSCode's diff view both do this. This should be backend-independent (TUI, GTK, Win-GUI).

Actual

Padding rows render correctly at the top (they're visible in the first hunk), but once scroll_top advances past that hunk, the two panes' rendered rows stop corresponding. Scrolling either pane drags the partner to a buffer line that doesn't match the partner's aligned row.

Why this is confusing to diagnose

The infrastructure is already in place — it's a subtle bug in the sync, not a missing feature:

  • Engine.diff_aligned: HashMap<WindowId, Vec<AlignedDiffEntry>> is populated by build_aligned_diff() (src/core/engine/mod.rs:3974) when a diff split is opened.
  • render.rs consumes it (line ~6748): when diff_aligned exists for a window, the render loop walks the aligned sequence and emits padding rows where source_line is None. Padding does render in both panes, and in isolation each pane shows the correct aligned view relative to its own scroll_top.
  • src/core/engine/search.rs::sync_scroll_binds() (line ~277) has a specific branch for the aligned-diff case (line 318 onwards) that maps the active pane's scroll_top → aligned index → partner's corresponding source_line.

Root cause (hypothesis)

sync_scroll_binds() sets partner.scroll_top to a buffer line number, but what it actually wants to sync is an aligned-row index. The conversion back to buffer line at lines ~336–353:

// Map that aligned index to the partner's buffer line.
let partner_line = if target_idx < partner_aligned.len() {
    partner_aligned[target_idx..]
        .iter()
        .find_map(|e| e.source_line)
        // fallback to previous real line if target is padding
        ...
};
w.view.scroll_top = partner_line;

When partner_aligned[target_idx] is a padding entry (source_line = None), the code walks forward to the next entry with a real source_line. That means:

  • Active pane: visible rows start at aligned row N (which may be a real line on active's side).
  • Partner pane: scroll_top is set to some later real buffer line; the partner render re-enters the aligned sequence at whatever aligned row that buffer line lives at — which is further along the aligned sequence than N. Net effect: the partner skips past the padding block instead of showing the padding.

Once one pane skips padding the other pane keeps, the two are permanently out of sync, and every subsequent hunk compounds the drift.

Fix sketch

Either:

  1. Use aligned-row index for scroll, not buffer line. Treat scroll_top as an index into the aligned sequence for any window that has diff_aligned populated. The render loop already starts at the aligned index matching scroll_top; make the scroll unit match. This is a larger refactor because the view / cursor-movement code assumes scroll_top is a buffer line.

  2. Cheaper targeted fix: in sync_scroll_binds, when partner_aligned[target_idx] is a padding entry, back up target_idx in the partner sequence to the start of the padding run (first padding entry at the current gap), then set partner.scroll_top to the buffer line of the aligned entry immediately before that run. The partner render's own "find aligned entry for scroll_top" step will then re-enter the sequence at the start of the padding block and emit the padding rows. Needs a corresponding adjustment in the render loop's initial aligned_idx calculation so that when scroll_top's aligned entry is followed by padding, we back up to the start of the padding (symmetric with the fix above).

  3. Render-side fallback: change the aligned-index seek at render.rs:~6756 so that when multiple aligned entries match a given source_line (one real + preceding padding), it lands on the first (padding) entry. Combined with v0.3.0: TUI-only binary (vcd) #2, both sides consistently render padding at the right visual row.

Option #2 or #3 (or a combined minimal fix) should be enough; option #1 is the "clean" long-term refactor if this keeps causing surprises.

Acceptance

  • Open a file with multiple hunks (e.g. make several scattered edits to a tracked file).
  • Run :Gdiffsplit or use the SC panel's diff-open.
  • Scroll down past the first hunk on either side.
  • Both panes' rows correspond: added lines in one pane line up horizontally with blank filler in the other pane; deleted lines in one pane line up with blank filler in the other.
  • Drift of ≥1 row at any scroll offset is a failure.
  • Works in TUI and GTK; verify Win-GUI once they lands on the shared sync_scroll_binds path.

Reproduction

  1. cd into a git repo, edit a file so it has 2+ non-adjacent hunks with mixed additions/deletions.
  2. :Gdiffsplit (or click the file in the Source Control panel).
  3. Observe first hunk lines up.
  4. Scroll down (mouse wheel, j, or Ctrl-d) until past the first hunk.
  5. Hunk 2 is misaligned horizontally by (net + − net -) of hunk 1.

Related code

  • src/core/engine/search.rs:277sync_scroll_binds (the likely culprit)
  • src/core/engine/mod.rs:3974build_aligned_diff
  • src/render.rs:~6748 — per-window render loop that emits padding from diff_aligned
  • src/core/engine/buffers.rs:2599compute_diff populates diff_aligned
  • src/core/engine/buffers.rs:1099sc_apply_diff_split registers the scroll-bind pair

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workinguiUI/rendering

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions