# Diff review
> Part of the edamame contributor deep-dives. Index and project-wide conventions: [`AGENTS.md`](../../AGENTS.md). Sibling docs live in [`docs/dev/`](.).
A clean-buffer external write opens **diff review** (`Mode::Diff`): hunks stacked old-above-new with a decision divider each. Unchanged regions are painted as *rendered* Markdown; only changed regions drop to raw source. `src/diff/` owns the model, `ui::diff_view` the painting.
- **The rendered/raw split is a display partition, and it must never reshape `hunks`.** `layout::block_spans` partitions the new side by source-map block, marks blocks a hunk lands in as `touched`, and `build_visual_lines_rendered` emits a maximal run of touched blocks as one *raw region* and every other block as its pre-rendered rows. Snapping hunk ranges out to block boundaries instead collapses the per-row table hunks `engine::split_table_hunk` produces back into one whole-table hunk — the row-by-row review is the feature. A display-only partition leaves `hunks`, `decisions`, `HunkId` stability, `reconcile_with_disk` and `resolved_rope` untouched.
- **Every source-map block gets a span, zero-row blocks included.** A block rendering nothing (a standalone HTML comment, a `<!-- tui-columns: … -->` hint, a blank run collapsed by `preserve_blank_lines = false`) still carries source lines; without a span, a hunk confined to it marks nothing touched, falls inside no raw region, and its rows are never emitted — the user reviews a diff that doesn't contain the change while `all_resolved()` still lets them resolve it. Spans run from a block's own first line to the *next* block's first line (pulldown-cmark ranges absorb trailing blanks that already have virtual blocks), and the last to `len_lines()` including the phantom line, so the set is a total partition. A `debug_assert!` pins that plus "every hunk emits exactly one decision divider".
- **A new side that parses to *no blocks* falls back to the raw walk.** Reachable when the file was truncated to empty on disk, which the watcher hands straight through. With an empty partition the whole-document delete has no span, so the review would be blank with `all_resolved()` false and `Esc` refusing to finish a change the user was never shown.
- **`DiffState::parsed_new` is not stamped against `new_buffer`, so the one call that replaces the buffer drops the parse.** `build_visual_lines_rendered` reads source lines from the parse while taking the line count from the buffer, and nothing pairs the two. `DiffState` holds no theme and no width, so `reconcile_with_disk` calls `set_rendered_parse(None)` and lets `App::reconcile_diff_with_disk` reinstall a fresh one via `refresh_diff_parse`. A caller that forgets loses the rendered presentation until `refresh_parsed`'s tail call comes round; it never gets a review partitioned against stale line ranges. Don't replace the drop with a rebuild at the call site — the point is that the unsafe state is unrepresentable.
- **The initial build is deferred by one frame, on purpose.** At `enter_diff_mode` `viewport_width` still holds the *editor* width, which reserves a gutter `Mode::Diff` does not (`compute_doc_dims`), so a parse built there lays tables out against a width the diff view never paints at. It sets `diff_parse_dirty`, and `prepare_viewport` calls `flush_diff_parse_if_dirty` after posting the real width — so `parsed_new: None` is queried at least once per entry and is a live path, not a constructor artifact.
- **Every later rebuild rides `refresh_parsed`'s tail, not a hand-maintained list of sites.** Theme switches, width changes, big-H1 / striping toggles, media settings and arriving decodes all go through `refresh_parsed`, and `OpenSettings` / `SwitchTheme` / `CreateCustomTheme` are on the `diff_safe_action` allowlist, so several are reachable *during* a review, where a stale `parsed_new` disagrees with the row cache about block heights. `refresh_diff_parse` never calls back into `refresh_parsed` and guards on `self.diff.is_some()`.
- **The diff parse gets its own `RenderCache`** — one cache shared by two documents collides on every identical block, and per-build eviction by document membership would evict the other's entries wholesale.
- **`refresh_parsed`'s image-GC live set is the *union* of both parses.** A URL on the diff's new side but not in the editor's buffer would otherwise be evicted and immediately re-requested by the diff-side dispatch, a decode/evict loop lasting the whole review. `refresh_diff_parse` runs no GC of its own, for the mirror-image reason.
- **In diff mode `scroll` counts *diff* visual rows, so the image paths need diff-specific geometry — through one memo.** `image_dispatch::infos_in_diff_viewport_window` and `image_view::build_diff_snapshots` both read the rendered-row map via `DiffState::with_layout_index`, memoised on the layout cache and dropped with it; rebuilding per call was O(rendered rows) plus a `HashMap` allocation at frame cadence. Dispatch early-outs on an image-free new side. An image inside a *changed* region has no `ContextRendered` row, yields no snapshot and is never dispatched — it shows as `` source, which also keeps the media prompts honest mid-review, since a clean-region URL is by definition one the editor's document already carries. Without the dispatch, an unchanged image not yet decoded when the review opened reserves `image_max_height` blank rows for the whole review and never decodes.
- **`ROW_CACHE_CAP` is 2 because exactly two widths are queried per frame** — the scrollbar-decide width and the post-scrollbar display width. Every new per-frame `with_layout` / `with_layout_index` caller must use one of those two (the image paths use `last_doc_width`); a third turns the LRU into a full prefix-sum rebuild every frame.
- **A `ContextRendered` row carries no marker and no wash — it *is* the document.** `line_marker` returns `""`: a `- ` / `+ ` / two-space prefix would overflow table grids and code-block padding, both laid out at the full viewport width, and the markers' alignment reference is the raw context *inside* a changed region. The row cache measures it by handing the finished `Line` to `visual_rows_for_line` — the identical call `PreviewView`'s painter makes — so wrap and scroll math agree by construction; that arm sits *ahead* of the `line_text` call, which has nothing to say about a row with no source text.