Skip to main content

lattice_diff/
mode.rs

1//! `diff-mode` minor mode + the host-side bridge that toggles
2//! it on participating buffers as `DiffSession`s open and close.
3//!
4//! ## The invariant
5//!
6//! A buffer participates in any [`DiffSession`] ⟺ `diff-mode`
7//! is active on that buffer. The toggle lives inside the
8//! [`DiffSubsystem`] (`register_with_sources` /
9//! `drop_session`) so every present and future `:diff*`
10//! ex-command (`:diff <buf>`, `:diffthis`, `:diffsplit`,
11//! future `:Gdiff` D.7, future `:diff-accept` / `:diff-reject`
12//! D.6) and the doc-close auto-drop
13//! (`note_buffer_closed → drop_session`) inherit the toggle
14//! without each call site repeating the activation logic.
15//! See `docs/dev/architecture/diff-system.md` §3.4.7.
16//!
17//! ## Why a bridge instead of a direct call
18//!
19//! The subsystem holds an `Arc<DiffSubsystem>` and may be
20//! invoked from a tokio worker (the bus-subscription drainer
21//! that translates `DocumentClosed` events into
22//! `drop_session`). The mode-activation API
23//! (`ModeRegistry::activate_minor` / `deactivate_minor`)
24//! needs `&mut ActiveModes` per buffer plus references to
25//! mode-guards, config, event bus, and services — all of which
26//! live on `Editor` and aren't shareable across threads.
27//!
28//! The bridge resolves this by queueing mode changes from any
29//! thread into a `Mutex<Vec<DiffModeChange>>` and exposing a
30//! `drain_pending` call the editor invokes from the dispatch
31//! tail (`publish_render_state`). The dispatch tail applies
32//! each change using the existing `mode_registry` pattern.
33//!
34//! ## Why ref-counted per buffer
35//!
36//! v1's `:diff*` flows don't intentionally put the same
37//! buffer in two sessions, but `lookup_session_for` indexes
38//! only the primary side. A baseline-side buffer can therefore
39//! be staged into a second session without the existing
40//! rejection check catching it; D.6 three-way will plausibly
41//! see a `MergeBase` participant also diffed against working
42//! tree. Naive "session opens → mode on / session closes →
43//! mode off" would flip diff-mode off prematurely when the
44//! first of two sessions sharing a buffer closes.
45//!
46//! The bridge therefore keeps a per-buffer refcount keyed by
47//! the participating session keys. Activation appends the
48//! session key to the buffer's bucket; deactivation removes
49//! it; the bridge enqueues a `Deactivate` change only when
50//! the bucket empties.
51
52use lattice_core::{BufferId, FoldOverlayServiceHandle, ProviderId};
53use lattice_grammar::Effect;
54use lattice_mode::registry::ModeRegistry;
55use lattice_mode::{
56    ActionContext, ActionHandler, ActionHandlerContribution, DecorationCtx, ElementContent,
57    ElementId, GutterDecoration, GutterDiffKind, Keymap, KeymapEntry, LifecycleFuture, Mode,
58    ModeContext, ModeId, ModeKind, ModelineElement, ModelineRole, ModelineService, Scope, Zone,
59    keymap_entry,
60};
61use std::collections::HashMap;
62use std::sync::{Arc, Mutex, OnceLock};
63
64use crate::fold::{HunkFoldSource, UnchangedFoldSource};
65use crate::subsystem::DiffSubsystemHandle;
66
67// ──────────────────────────────────────────────────────────────
68// DiffMode — the minor mode itself
69// ──────────────────────────────────────────────────────────────
70
71/// Render-time snapshot for `DiffMode::gutter_decorations`. Carries
72/// the active buffer's diff sign map; injected by the renderer only for
73/// the active-document buffer (sign_map is active-doc only).
74pub struct DiffDecorationData {
75    pub sign_map: std::sync::Arc<crate::overlay::DiffSignMap>,
76}
77
78// ──────────────────────────────────────────────────────────────
79// Modeline element (ML.3): the `+N ~M` diff-stats segment
80// ──────────────────────────────────────────────────────────────
81
82/// Modeline element id for the diff add/change summary. Owned by the
83/// diff subsystem (`feedback_mode_owns_its_surface`).
84pub const DIFF_ELEMENT: &str = "diff";
85
86/// Register the `diff` modeline descriptor (ML.3). `Scope::Global`: the
87/// active document's session sign-map is a single shared value, so the
88/// summary renders on the **active** pane's modeline (the renderer gates
89/// Global elements to the active pane, modeline.md §7) — there is no
90/// distinct per-side count to show. Content is computed host-side on the
91/// actor (where the sign map already lives, `sync_diff_modeline_element`)
92/// rather than counted on the render thread (paramount #1). Left zone,
93/// after `core.path`.
94pub fn register_diff_modeline_element(svc: &ModelineService) {
95    svc.register(
96        ModelineElement::new(ElementId::new(DIFF_ELEMENT), Zone::Left, 20)
97            .with_scope(Scope::Global),
98    );
99}
100
101/// Format a diff sign-map into the `+N ~M` modeline content (ML.3). The
102/// formatter the diff subsystem owns — moved here from the retired
103/// `DiffMode::status_line_items`. Empty content (no adds/changes) ⇒
104/// hidden this frame (the caller's `apply` clears the slot).
105pub fn diff_content(sign_map: &crate::overlay::DiffSignMap) -> ElementContent {
106    use crate::overlay::DiffSignKind;
107    let mut added = 0u32;
108    let mut changed = 0u32;
109    for (_line, kind) in sign_map.entries() {
110        match kind {
111            DiffSignKind::Add => added += 1,
112            DiffSignKind::Change | DiffSignKind::Conflict | DiffSignKind::Remove => {
113                changed += 1;
114            }
115        }
116    }
117    if added == 0 && changed == 0 {
118        return ElementContent::default();
119    }
120    let mut parts = Vec::new();
121    if added > 0 {
122        parts.push(format!("+{added}"));
123    }
124    if changed > 0 {
125        parts.push(format!("~{changed}"));
126    }
127    ElementContent::text(
128        parts.join(" "),
129        ModelineRole::new(lattice_mode::modeline::ROLE_MODE_ITEM),
130    )
131}
132
133/// `diff-mode` minor. Empty marker in v1 (D.5.a): the bit other
134/// layers consult. D.5.b/c land the `do`/`dp` chord bindings
135/// against the `MinorMode(ModeId::new("diff-mode"))` keymap
136/// layer (K.1.b shape).
137pub struct DiffMode;
138
139impl DiffMode {
140    pub fn mode_id() -> ModeId {
141        ModeId::new("diff-mode")
142    }
143}
144
145/// DX.3-C7 (2026-06-24): deregisters the buffer's [`HunkFoldSource`] when
146/// `diff-mode` deactivates (the diff session closed, or the buffer's mode
147/// set changed). `Drop` fires from the mode guard store when
148/// `deactivate_minor` runs — the same Drop-based lifecycle multibuffer's
149/// `MultibufferModeGuard` uses for its excerpt/file-boundary fold sources.
150/// Empty when the fold service / diff subsystem weren't registered (some
151/// test harnesses) — Drop is then a no-op.
152pub struct DiffModeGuard {
153    fold_registrations: Vec<(FoldOverlayServiceHandle, ProviderId)>,
154}
155
156impl Drop for DiffModeGuard {
157    fn drop(&mut self) {
158        for (svc, id) in self.fold_registrations.drain(..) {
159            svc.remove_source(id);
160        }
161    }
162}
163
164impl Mode for DiffMode {
165    type Guard = DiffModeGuard;
166    fn id(&self) -> ModeId {
167        Self::mode_id()
168    }
169    fn kind(&self) -> ModeKind {
170        ModeKind::Minor
171    }
172    /// MO.x (2026-06-24): contribute the `do`/`dp` chords through the
173    /// standard `Mode::keymap()` seam (the multibuffer `]e`/`[e` pattern).
174    /// The host's K.2.4 translation pass resolves each entry's `cmd` name
175    /// against the `CommandRegistry` and pushes the layer under
176    /// `MinorMode(diff-mode)`, gated to diff-active buffers by K.1.c —
177    /// retiring the host's bespoke explicit `push_layer` (the emacs-keys
178    /// host-push that DX.5/DX.7 left as this follow-up). The binding choice
179    /// now lives wholly with the mode; the host pushes nothing diff-specific.
180    fn keymap(&self) -> Keymap {
181        Keymap::from_entries(diff_mode_keymap_entries())
182    }
183    /// CR.1 (2026-06-24): the `do`/`dp` chord handler bodies, now
184    /// mode-owned (they were `Editor::do_diff_get`/`do_diff_put` +
185    /// `Action::DiffGet`/`DiffPut` host-side). Bound globally
186    /// (buffer-agnostic) at boot by the host's `register_mode_action_handlers`
187    /// walk: `diff-mode` is active on many buffers at once and the
188    /// `ActionHandlerRegistry` is keyed by `CommandId` alone, so a
189    /// per-`on_activate` registration would let one buffer's deactivation
190    /// evict the handler for the others — the snippet `action:snippet-expand`
191    /// precedent. Each handler reads the active buffer + cursor from the
192    /// `ActionContext` and the diff session from the `DiffSubsystemHandle`
193    /// service, resolves the hunk via `DiffSubsystem::diff_*_effect`, and
194    /// hands the host an `Effect::ApplyEdit` (CR.0): the mode owns the
195    /// resolution, the host owns the apply. `do`/`dp` pass `None` as the
196    /// target (auto-resolve the peer in 2-way; "target required" in 3-way);
197    /// the `:diffget`/`:diffput` optional `<bufnr>` is a separate host call
198    /// into the same `diff_*_effect` resolver.
199    fn action_handlers(&self) -> Vec<ActionHandlerContribution> {
200        let get: ActionHandler = Arc::new(|ctx: &ActionContext<'_>| -> Option<Effect> {
201            let sub = ctx.services.get::<DiffSubsystemHandle>()?;
202            let active = lattice_core::BufferId(ctx.buffer_id.0 as u32);
203            sub.diff_get_effect(active, ctx.cursor.line, None)
204        });
205        let put: ActionHandler = Arc::new(|ctx: &ActionContext<'_>| -> Option<Effect> {
206            let sub = ctx.services.get::<DiffSubsystemHandle>()?;
207            let active = lattice_core::BufferId(ctx.buffer_id.0 as u32);
208            sub.diff_put_effect(active, ctx.cursor.line, None)
209        });
210        // CR.6 (2026-06-24): `]c`/`[c` hunk-nav, now mode-owned (was the
211        // host's `do_next_hunk`/`do_prev_hunk`). The resolvers return a
212        // generic `Effect::SelectionChange`; the host owns the cursor write.
213        let next_hunk: ActionHandler = Arc::new(|ctx: &ActionContext<'_>| -> Option<Effect> {
214            let sub = ctx.services.get::<DiffSubsystemHandle>()?;
215            let active = lattice_core::BufferId(ctx.buffer_id.0 as u32);
216            sub.diff_next_hunk_effect(active, ctx.cursor.line)
217        });
218        let prev_hunk: ActionHandler = Arc::new(|ctx: &ActionContext<'_>| -> Option<Effect> {
219            let sub = ctx.services.get::<DiffSubsystemHandle>()?;
220            let active = lattice_core::BufferId(ctx.buffer_id.0 as u32);
221            sub.diff_prev_hunk_effect(active, ctx.cursor.line)
222        });
223        vec![
224            ActionHandlerContribution {
225                action_name: "action:diff-get",
226                handler: get,
227            },
228            ActionHandlerContribution {
229                action_name: "action:diff-put",
230                handler: put,
231            },
232            ActionHandlerContribution {
233                action_name: "action:hunk-next",
234                handler: next_hunk,
235            },
236            ActionHandlerContribution {
237                action_name: "action:hunk-prev",
238                handler: prev_hunk,
239            },
240        ]
241    }
242    // ML.3: the `+N ~M` summary moved off `status_line_items` to a
243    // registered modeline element (`register_diff_modeline_element` /
244    // `diff_content`), pushed via the actor's `sync_diff_modeline_element`.
245    fn gutter_decorations(&self, ctx: &DecorationCtx<'_>) -> Vec<GutterDecoration> {
246        use crate::overlay::DiffSignKind;
247        let Some(data) = ctx.service::<DiffDecorationData>() else {
248            return Vec::new();
249        };
250        // SG.4b: a hunk mark is a sign in the `diff` column now — same
251        // registry, same theme resolution, same priority rule as any other.
252        let Some(ids) = ctx.service::<lattice_mode::BuiltinSignIds>() else {
253            return Vec::new();
254        };
255        data.sign_map
256            .entries()
257            .iter()
258            .map(|(line, kind)| {
259                let gdk = match kind {
260                    DiffSignKind::Add => GutterDiffKind::Add,
261                    DiffSignKind::Remove => GutterDiffKind::Remove,
262                    DiffSignKind::Change => GutterDiffKind::Change,
263                    DiffSignKind::Conflict => GutterDiffKind::Conflict,
264                };
265                GutterDecoration::Sign {
266                    line: *line,
267                    sign: ids.for_diff(gdk),
268                }
269            })
270            .collect()
271    }
272
273    /// DX.3-C7 (2026-06-24) / D-fix.5 (2026-06-26): register the buffer's
274    /// diff fold sources.
275    ///
276    /// Mirrors `MultibufferMode::on_activate`: pull the
277    /// `FoldOverlayService` + the data handle (`DiffSubsystemHandle`) +
278    /// the `ConfigRegistry` from the service registry, resolve the diff
279    /// session and the buffer's `ranges` slot, and register TWO sources
280    /// over them:
281    /// - [`HunkFoldSource`] — folds the hunks (open by default);
282    /// - [`UnchangedFoldSource`] (D-fix.5) — folds their complement, the
283    ///   unchanged gaps (closed by default; vimdiff `foldmethod=diff`).
284    ///
285    /// D-fix.5 resolves via `lookup_session_for` (not the primary-only
286    /// `lookup`) + `participant_slot`, so EVERY participant — including
287    /// the baseline-side *secondary* buffer — registers sources folding
288    /// its OWN side. Both panes fold in lockstep; under scroll-bind,
289    /// folding only one side would desync the side-by-side view.
290    ///
291    /// The returned [`DiffModeGuard`]'s `Drop` removes both sources when
292    /// the mode deactivates. Missing service / no session / no slot just
293    /// skips (empty guard) — folds inactive, never a panic.
294    fn on_activate(&self, ctx: ModeContext) -> LifecycleFuture<'_, DiffModeGuard> {
295        Box::pin(async move {
296            // lattice-host maps lattice_core::BufferId →
297            // lattice_protocol::ids::BufferId via `new(id.0 as u64)`;
298            // invert here to key into the diff subsystem.
299            let core_buffer_id = lattice_core::BufferId(ctx.buffer_id().0 as u32);
300
301            // Both handle types are `Arc<dyn Trait>` / `Arc<T>` aliases;
302            // `ctx.service::<T>()` returns `Option<Arc<T>>`, so clone
303            // through the outer Arc to obtain the inner handle.
304            let fold_service = ctx
305                .service::<FoldOverlayServiceHandle>()
306                .map(|outer| (*outer).clone());
307            let diff_subsystem = ctx
308                .service::<DiffSubsystemHandle>()
309                .map(|outer| (*outer).clone());
310            // D-fix.5: the `ui.diff.*` reader for the unchanged-fold
311            // source. `ConfigRegistry` is registered as `Arc<ConfigRegistry>`
312            // (the multibuffer precedent), so `ctx.service` yields an
313            // `Arc<Arc<…>>` — unwrap one layer. `None` in test harnesses
314            // without a config service ⇒ the source's defaults apply.
315            let config = ctx
316                .service::<Arc<lattice_config::ConfigRegistry>>()
317                .map(|outer| (*outer).clone());
318
319            let mut fold_registrations = Vec::new();
320
321            match (fold_service, diff_subsystem) {
322                (Some(svc), Some(sub)) => {
323                    // D-fix.5: resolve from EITHER side (`lookup_session_for`,
324                    // not the primary-only `lookup`) so the baseline pane —
325                    // a session *secondary* — also registers its fold
326                    // sources. Both panes then fold in lockstep; with
327                    // scroll-bind on, folding only one side would desync the
328                    // side-by-side view.
329                    //
330                    // `participant_slot` is `None` for a sources-less
331                    // session (`DiffSubsystem::register`, no descriptor —
332                    // some test + inline paths). Default to slot 1 (current
333                    // side) there: the pre-D-fix.5 `HunkFoldSource`
334                    // hard-coded `ranges[1]`, so this preserves that
335                    // behaviour for the descriptor-free case.
336                    match sub.lookup_session_for(core_buffer_id) {
337                        Some(session) => {
338                            let slot = sub.participant_slot(core_buffer_id).unwrap_or(1);
339                            // Hunk folds (open-by-default; `za` collapses a
340                            // change) on this buffer's OWN side.
341                            let hunk_src = Arc::new(HunkFoldSource::new(
342                                Arc::clone(&session),
343                                core_buffer_id,
344                                slot,
345                            ));
346                            let hunk_id = svc.add_source(hunk_src, core_buffer_id);
347                            fold_registrations.push((svc.clone(), hunk_id));
348                            // Unchanged folds (closed-by-default; the
349                            // complement) on the same side — vimdiff
350                            // `foldmethod=diff`. Reads `ui.diff.*` live.
351                            let unchanged_src = Arc::new(UnchangedFoldSource::new(
352                                session,
353                                core_buffer_id,
354                                slot,
355                                config,
356                            ));
357                            let unchanged_id = svc.add_source(unchanged_src, core_buffer_id);
358                            fold_registrations.push((svc, unchanged_id));
359                        }
360                        _ => {
361                            tracing::debug!(
362                                "DiffMode::on_activate: no diff session / slot for buffer {:?}; \
363                                 diff folds inactive",
364                                core_buffer_id
365                            );
366                        }
367                    }
368                }
369                _ => {
370                    tracing::debug!(
371                        "DiffMode::on_activate: fold service or diff subsystem not \
372                         registered; diff folds inactive (expected in some tests)"
373                    );
374                }
375            }
376
377            Ok(DiffModeGuard { fold_registrations })
378        })
379    }
380}
381
382// ──────────────────────────────────────────────────────────────
383// DiffConflictMode (DX.8) — smerge-style conflict-resolution surface
384// ──────────────────────────────────────────────────────────────
385
386/// `diff-conflict-mode` (smerge-style) minor — DX.8 shell (2026-06-24).
387///
388/// Separates conflict *resolution* from the 2-way `diff-mode` surface
389/// (design `diff-extraction.md` §4): it should activate **only** on
390/// buffers whose diff session carries conflict regions
391/// ([`crate::overlay::DiffSignKind::Conflict`]), and will contribute the
392/// conflict-resolution chords (keep-ours / keep-theirs / keep-both).
393///
394/// DX.8 landed this as a marker shell; CR.3 filled it in. The chords
395/// are REAL — `d2o` / `d3o` / `d2p` / `d3p` / `dB`, with handler
396/// bodies below calling the matching `DiffSubsystem::diff_*_effect`
397/// resolver. (This comment claimed otherwise long after they shipped,
398/// as did the user page, which is a worse bug than a missing feature:
399/// it tells the user not to try.)
400///
401/// Still outstanding: next-conflict / prev-conflict navigation and a
402/// dedicated conflict gutter. `Guard = ()` — the mode allocates no
403/// per-buffer resources.
404pub struct DiffConflictMode;
405
406impl DiffConflictMode {
407    pub fn mode_id() -> ModeId {
408        ModeId::new("diff-conflict-mode")
409    }
410}
411
412impl Mode for DiffConflictMode {
413    type Guard = ();
414    fn id(&self) -> ModeId {
415        Self::mode_id()
416    }
417    fn kind(&self) -> ModeKind {
418        ModeKind::Minor
419    }
420    /// CR.3 (2026-06-24): the conflict-resolution chords, contributed
421    /// through the standard `Mode::keymap()` seam (the `diff-mode`
422    /// `do`/`dp` pattern). Vim-fugitive 3-way family — `d2o`/`d3o`
423    /// (keep-ours / keep-theirs), `d2p`/`d3p` (put-ours / put-theirs),
424    /// `dB` (keep-both). The host's K.2.4 translate pass resolves each
425    /// `cmd` name and pushes the layer under
426    /// `MinorMode(diff-conflict-mode)`, gated by K.1.c to
427    /// conflict-mode-active buffers.
428    fn keymap(&self) -> Keymap {
429        Keymap::from_entries(diff_conflict_mode_keymap_entries())
430    }
431    /// CR.3: the conflict-chord handler bodies, mode-owned. Bound globally
432    /// at boot by `register_mode_action_handlers` (same rationale as
433    /// [`DiffMode::action_handlers`]). Each reads the active buffer +
434    /// cursor from the `ActionContext` and the `DiffSubsystemHandle`
435    /// service and calls the matching `DiffSubsystem::diff_*_effect`
436    /// resolver, which resolves "theirs" from the session and returns
437    /// `Effect::ApplyEdit` — or, for the degenerate keep-ours / put-ours
438    /// self-targets, an informative `Echo` (Dhruva 2026-06-24).
439    fn action_handlers(&self) -> Vec<ActionHandlerContribution> {
440        macro_rules! conflict_handler {
441            ($method:ident) => {{
442                let h: ActionHandler = Arc::new(|ctx: &ActionContext<'_>| -> Option<Effect> {
443                    let sub = ctx.services.get::<DiffSubsystemHandle>()?;
444                    let active = lattice_core::BufferId(ctx.buffer_id.0 as u32);
445                    sub.$method(active, ctx.cursor.line)
446                });
447                h
448            }};
449        }
450        vec![
451            ActionHandlerContribution {
452                action_name: "action:diff-keep-ours",
453                handler: conflict_handler!(diff_keep_ours_effect),
454            },
455            ActionHandlerContribution {
456                action_name: "action:diff-keep-theirs",
457                handler: conflict_handler!(diff_keep_theirs_effect),
458            },
459            ActionHandlerContribution {
460                action_name: "action:diff-put-ours",
461                handler: conflict_handler!(diff_put_ours_effect),
462            },
463            ActionHandlerContribution {
464                action_name: "action:diff-put-theirs",
465                handler: conflict_handler!(diff_put_theirs_effect),
466            },
467            ActionHandlerContribution {
468                action_name: "action:diff-keep-both",
469                handler: conflict_handler!(diff_keep_both_effect),
470            },
471        ]
472    }
473    fn on_activate(&self, _ctx: ModeContext) -> LifecycleFuture<'_, ()> {
474        Box::pin(async { Ok(()) })
475    }
476}
477
478/// DX.8 (2026-06-24): the `diff-conflict-mode` activation predicate.
479///
480/// A diff session warrants conflict resolution iff its published sign map
481/// carries at least one [`crate::overlay::DiffSignKind::Conflict`] region.
482/// A pure function over the sign map — trivially testable and safe to
483/// consult anywhere — so the bridge can gate `diff-conflict-mode`
484/// activation on it (follow-up) the same way `DiffModeBridge` gates
485/// `diff-mode` on session participation.
486pub fn sign_map_has_conflicts(sign_map: &crate::overlay::DiffSignMap) -> bool {
487    use crate::overlay::DiffSignKind;
488    sign_map
489        .entries()
490        .iter()
491        .any(|(_line, kind)| matches!(kind, DiffSignKind::Conflict))
492}
493
494/// Register the diff modes against `registry`. Called from
495/// [`crate::install::install`] (the Phase-B install list) alongside the
496/// other feature-crate `register_*_modes` helpers. Registers both
497/// `diff-mode` (the 2-way base surface) and the `diff-conflict-mode`
498/// shell (DX.8).
499pub fn register_diff_modes(registry: &mut ModeRegistry) {
500    registry
501        .register(DiffMode)
502        .expect("diff-mode must register without conflict");
503    registry
504        .register(DiffConflictMode)
505        .expect("diff-conflict-mode must register without conflict");
506}
507
508/// MO.x (2026-06-24): the `diff-mode` keymap entries, contributed through
509/// `DiffMode::keymap()`. Each entry carries a canonical `action:diff-*`
510/// **name**; the host's K.2.4 translate pass (`translate_mode_keymaps`)
511/// resolves it against the `CommandRegistry` and pushes the layer under
512/// `MinorMode(diff-mode)` — the multibuffer `multibuffer_keymap_entries`
513/// pattern. This replaces the former `diff_mode_layer_bindings` host-push
514/// builder (DX.5): the binding choice now lives wholly with the mode and the
515/// host pushes nothing diff-specific. An unregistered name is dropped by the
516/// translate pass with a `warn!` — never a boot panic (graceful degradation).
517///
518/// - `do` → `action:diff-get` (D.5.b): rewrite the current side's hunk to
519///   match the baseline.
520/// - `dp` → `action:diff-put` (D.5.c): push the current side's hunk into the
521///   peer buffer.
522///
523/// K.1.c's per-keystroke filter gates the chords so they only fire on
524/// buffers where `diff-mode` is in `ActiveModes.minors()`; buffers without
525/// diff-mode active fall through to the normal `d`-operator resolution.
526fn diff_mode_keymap_entries() -> &'static [KeymapEntry] {
527    static ENTRIES: OnceLock<Vec<KeymapEntry>> = OnceLock::new();
528    ENTRIES.get_or_init(|| {
529        vec![
530            keymap_entry! {
531                mode: Normal, chord: "do",
532                doc: "diff-get: rewrite the current side's hunk to match the baseline",
533                cmd: "action:diff-get"
534            },
535            keymap_entry! {
536                mode: Normal, chord: "dp",
537                doc: "diff-put: push the current side's hunk into the peer buffer",
538                cmd: "action:diff-put"
539            },
540            keymap_entry! {
541                mode: Normal, chord: "]c",
542                doc: "diff: jump the cursor to the next hunk start (wraps)",
543                cmd: "action:hunk-next"
544            },
545            keymap_entry! {
546                mode: Normal, chord: "[c",
547                doc: "diff: jump the cursor to the previous hunk start (wraps)",
548                cmd: "action:hunk-prev"
549            },
550        ]
551    })
552}
553
554/// CR.3 (2026-06-24): the `diff-conflict-mode` keymap entries — the
555/// vim-fugitive 3-way conflict-resolution family, contributed through
556/// `DiffConflictMode::keymap()` and pushed under
557/// `MinorMode(diff-conflict-mode)` by the host's K.2.4 translate pass.
558/// K.1.c gates them to buffers where `diff-conflict-mode` is active (i.e.
559/// the session carries a `Conflict` region), so on a clean / 2-way buffer
560/// `d2o` etc. fall through to the normal `d`-operator resolution.
561///
562/// - `d2o` → keep-ours, `d3o` → keep-theirs (diffget from local/remote);
563/// - `d2p` → put-ours, `d3p` → put-theirs (diffput to local/remote);
564/// - `dB` → keep-both (ours⌢theirs).
565///
566/// In the marker-free 3-way model the cursor sits on `local` (= ours), so
567/// `d2o`/`d2p` are degenerate self-targets that emit an informative echo
568/// (see the `diff_*_effect` resolvers).
569fn diff_conflict_mode_keymap_entries() -> &'static [KeymapEntry] {
570    static ENTRIES: OnceLock<Vec<KeymapEntry>> = OnceLock::new();
571    ENTRIES.get_or_init(|| {
572        vec![
573            keymap_entry! {
574                mode: Normal, chord: "d2o",
575                doc: "conflict keep-ours: keep the local side for the conflict under the cursor",
576                cmd: "action:diff-keep-ours"
577            },
578            keymap_entry! {
579                mode: Normal, chord: "d3o",
580                doc: "conflict keep-theirs: take the remote side into local",
581                cmd: "action:diff-keep-theirs"
582            },
583            keymap_entry! {
584                mode: Normal, chord: "d2p",
585                doc: "conflict put-ours: push local into the ours side",
586                cmd: "action:diff-put-ours"
587            },
588            keymap_entry! {
589                mode: Normal, chord: "d3p",
590                doc: "conflict put-theirs: push local into the remote side",
591                cmd: "action:diff-put-theirs"
592            },
593            keymap_entry! {
594                mode: Normal, chord: "dB",
595                doc: "conflict keep-both: splice ours then theirs into local",
596                cmd: "action:diff-keep-both"
597            },
598        ]
599    })
600}
601
602// ──────────────────────────────────────────────────────────────
603// DiffModeBridge — refcount + cross-thread queue
604// ──────────────────────────────────────────────────────────────
605
606/// Direction of a pending [`DiffModeChange`]. `Activate` adds
607/// `diff-mode` to the buffer's `ActiveModes.minors()`;
608/// `Deactivate` removes it.
609#[derive(Debug, Clone, Copy, PartialEq, Eq)]
610pub enum DiffModeAction {
611    Activate,
612    Deactivate,
613}
614
615/// CR.3 (2026-06-24): which diff minor mode a [`DiffModeChange`] toggles.
616/// `Base` = `diff-mode` (every session participant); `Conflict` =
617/// `diff-conflict-mode` (only sessions whose sign map carries a
618/// `Conflict` region). The host's `apply_pending_diff_mode_changes` maps
619/// the kind to the concrete `ModeId`.
620#[derive(Debug, Clone, Copy, PartialEq, Eq)]
621pub enum DiffModeKind {
622    Base,
623    Conflict,
624}
625
626/// One queued toggle. Buffered until the dispatch tail drains
627/// the bridge and applies them through the mode registry.
628#[derive(Debug, Clone, Copy, PartialEq, Eq)]
629pub struct DiffModeChange {
630    pub buffer: BufferId,
631    pub action: DiffModeAction,
632    /// CR.3: which mode (`diff-mode` vs `diff-conflict-mode`) to toggle.
633    pub kind: DiffModeKind,
634}
635
636#[derive(Debug, Default)]
637struct BridgeState {
638    /// session_key → the participants that session activated
639    /// for. Stored so `note_session_closed(session_key)` can
640    /// look up which buffers to decrement without re-reading
641    /// the descriptor (which has been removed by the time
642    /// `drop_session` calls the bridge).
643    sessions: HashMap<BufferId, Vec<BufferId>>,
644    /// participant buffer → set of session keys currently
645    /// holding it active. Refcount via vec length.
646    refcounts: HashMap<BufferId, Vec<BufferId>>,
647    /// CR.3: sessions currently flagged conflict-bearing → the
648    /// participants their conflict state activated `diff-conflict-mode`
649    /// for. Mirrors `sessions` but for the conflict overlay mode, so
650    /// `note_session_closed` can decrement them without re-reading the
651    /// (already-removed) sign map.
652    conflict_sessions: HashMap<BufferId, Vec<BufferId>>,
653    /// CR.3: per-buffer `diff-conflict-mode` refcount, keyed by the
654    /// session keys currently holding it conflict-active. A buffer may
655    /// hold `diff-mode` (always, while in a session) without holding
656    /// `diff-conflict-mode` (only while a conflict region exists).
657    conflict_refcounts: HashMap<BufferId, Vec<BufferId>>,
658    /// Pending toggle queue drained from the dispatch tail.
659    pending: Vec<DiffModeChange>,
660}
661
662/// Host-side bridge between [`DiffSubsystem`] session lifecycle
663/// and the per-buffer `ActiveModes` set. See module docs for
664/// the why.
665#[derive(Debug, Default)]
666pub struct DiffModeBridge {
667    state: Mutex<BridgeState>,
668}
669
670impl DiffModeBridge {
671    pub fn new() -> Self {
672        Self::default()
673    }
674
675    /// Record that `session_key` opened with `participants` as
676    /// the user-visible diff sides. For each participant whose
677    /// refcount transitions 0 → 1, queue an `Activate` change.
678    /// Idempotent on re-open of the same session_key
679    /// (subsystem `register_with_sources` is itself idempotent
680    /// on session identity).
681    pub fn note_session_opened(&self, session_key: BufferId, participants: &[BufferId]) {
682        let mut state = self.state.lock().expect("DiffModeBridge mutex poisoned");
683        // Re-open of a known session_key: scrub the previous
684        // participants first so the refcount stays correct
685        // (descriptor may have changed sides — D.4.d.3 plans
686        // for `:diff-mode <inline|split|three-way>`).
687        if let Some(old) = state.sessions.remove(&session_key) {
688            for b in old {
689                Self::dec_refcount(&mut state, session_key, b);
690            }
691        }
692        let owned: Vec<BufferId> = participants.to_vec();
693        state.sessions.insert(session_key, owned.clone());
694        for b in owned {
695            Self::inc_refcount(&mut state, session_key, b);
696        }
697    }
698
699    /// Record that `session_key` closed. For each participant
700    /// whose refcount drops to 0, queue a `Deactivate` change.
701    /// No-op on unknown session keys (close-before-open or
702    /// double-close from auto-drop racing the user's
703    /// `:diffoff`).
704    pub fn note_session_closed(&self, session_key: BufferId) {
705        let mut state = self.state.lock().expect("DiffModeBridge mutex poisoned");
706        if let Some(participants) = state.sessions.remove(&session_key) {
707            for b in participants {
708                Self::dec_refcount(&mut state, session_key, b);
709            }
710        }
711        // CR.3: also clear any `diff-conflict-mode` activation this
712        // session held, so the conflict overlay deactivates on close
713        // (e.g. `:diffoff` / doc-close auto-drop) even if the recompute
714        // never published a no-conflict sign map first.
715        if let Some(participants) = state.conflict_sessions.remove(&session_key) {
716            for b in participants {
717                Self::dec_conflict(&mut state, session_key, b);
718            }
719        }
720    }
721
722    /// D.8.d (2026-05-31): record that an existing session
723    /// gained a new participant `buf`. Increments the refcount +
724    /// appends `buf` to the session's participant list so
725    /// subsequent [`Self::note_session_closed`] decrements
726    /// every participant including this one.
727    ///
728    /// Idempotent: re-adding a buffer that's already a
729    /// participant of `session_key` is a no-op (the refcount
730    /// bucket dedupes by session-key, mirroring
731    /// [`Self::note_session_opened`]).
732    ///
733    /// No-op on unknown `session_key` — the caller has either
734    /// already torn the session down or the bridge was never
735    /// notified of the open. Defensive against races between
736    /// `:diffthis` (which goes through the membership API) and
737    /// `drop_session` (which goes through `note_session_closed`).
738    pub fn note_session_extended(&self, session_key: BufferId, buf: BufferId) {
739        let mut state = self.state.lock().expect("DiffModeBridge mutex poisoned");
740        let Some(participants) = state.sessions.get_mut(&session_key) else {
741            return;
742        };
743        if participants.contains(&buf) {
744            return;
745        }
746        participants.push(buf);
747        Self::inc_refcount(&mut state, session_key, buf);
748    }
749
750    /// D.8.d (2026-05-31): record that a participant `buf` was
751    /// removed from `session_key`. Decrements the refcount; if
752    /// `buf` no longer participates in any session, queues a
753    /// `Deactivate` change so the dispatch tail flips
754    /// `ActiveModes.diff-mode` off on that buffer. Removes `buf`
755    /// from the session's participant list so a subsequent
756    /// `note_session_closed` doesn't double-decrement it.
757    ///
758    /// No-op on unknown `session_key` or unknown participant.
759    pub fn note_session_shrunk(&self, session_key: BufferId, buf: BufferId) {
760        let mut state = self.state.lock().expect("DiffModeBridge mutex poisoned");
761        let Some(participants) = state.sessions.get_mut(&session_key) else {
762            return;
763        };
764        if let Some(pos) = participants.iter().position(|b| *b == buf) {
765            participants.swap_remove(pos);
766            Self::dec_refcount(&mut state, session_key, buf);
767        }
768    }
769
770    /// Drain every pending change. Callers (the dispatch tail)
771    /// apply them via the mode registry, which mutates the
772    /// per-buffer `ActiveModes`.
773    pub fn drain_pending(&self) -> Vec<DiffModeChange> {
774        let mut state = self.state.lock().expect("DiffModeBridge mutex poisoned");
775        std::mem::take(&mut state.pending)
776    }
777
778    /// Test-only / introspection: current refcount for
779    /// `buffer`. Returns 0 if no session activated diff-mode on
780    /// it.
781    pub fn refcount(&self, buffer: BufferId) -> usize {
782        self.state
783            .lock()
784            .expect("DiffModeBridge mutex poisoned")
785            .refcounts
786            .get(&buffer)
787            .map(Vec::len)
788            .unwrap_or(0)
789    }
790
791    fn inc_refcount(state: &mut BridgeState, session_key: BufferId, buffer: BufferId) {
792        let bucket = state.refcounts.entry(buffer).or_default();
793        // Re-add of the same session_key is a no-op for the
794        // refcount (idempotent re-open guard).
795        if bucket.contains(&session_key) {
796            return;
797        }
798        let was_empty = bucket.is_empty();
799        bucket.push(session_key);
800        if was_empty {
801            state.pending.push(DiffModeChange {
802                buffer,
803                action: DiffModeAction::Activate,
804                kind: DiffModeKind::Base,
805            });
806        }
807    }
808
809    fn dec_refcount(state: &mut BridgeState, session_key: BufferId, buffer: BufferId) {
810        let Some(bucket) = state.refcounts.get_mut(&buffer) else {
811            return;
812        };
813        if let Some(pos) = bucket.iter().position(|k| *k == session_key) {
814            bucket.swap_remove(pos);
815        }
816        if bucket.is_empty() {
817            state.refcounts.remove(&buffer);
818            state.pending.push(DiffModeChange {
819                buffer,
820                action: DiffModeAction::Deactivate,
821                kind: DiffModeKind::Base,
822            });
823        }
824    }
825
826    /// CR.3 (2026-06-24): reconcile `diff-conflict-mode` activation for
827    /// `session_key` against its latest sign map. `has_conflicts` comes
828    /// from [`sign_map_has_conflicts`]; the host calls this each cycle off
829    /// the active session's published sign map. Idempotent on an unchanged
830    /// state (no transition → nothing queued), so per-cycle calls are
831    /// cheap. On the `false → true` transition it queues a `Conflict`
832    /// Activate for each participant (refcounted, so a buffer shared by
833    /// two conflict sessions stays active until the last clears); on
834    /// `true → false` it queues a Deactivate for each whose conflict
835    /// refcount hits zero.
836    pub fn note_conflict_state(
837        &self,
838        session_key: BufferId,
839        participants: &[BufferId],
840        has_conflicts: bool,
841    ) {
842        let mut state = self.state.lock().expect("DiffModeBridge mutex poisoned");
843        let currently = state.conflict_sessions.contains_key(&session_key);
844        if has_conflicts && !currently {
845            let owned: Vec<BufferId> = participants.to_vec();
846            state.conflict_sessions.insert(session_key, owned.clone());
847            for b in owned {
848                Self::inc_conflict(&mut state, session_key, b);
849            }
850        } else if !has_conflicts
851            && currently
852            && let Some(parts) = state.conflict_sessions.remove(&session_key)
853        {
854            for b in parts {
855                Self::dec_conflict(&mut state, session_key, b);
856            }
857        }
858    }
859
860    fn inc_conflict(state: &mut BridgeState, session_key: BufferId, buffer: BufferId) {
861        let bucket = state.conflict_refcounts.entry(buffer).or_default();
862        if bucket.contains(&session_key) {
863            return;
864        }
865        let was_empty = bucket.is_empty();
866        bucket.push(session_key);
867        if was_empty {
868            state.pending.push(DiffModeChange {
869                buffer,
870                action: DiffModeAction::Activate,
871                kind: DiffModeKind::Conflict,
872            });
873        }
874    }
875
876    fn dec_conflict(state: &mut BridgeState, session_key: BufferId, buffer: BufferId) {
877        let Some(bucket) = state.conflict_refcounts.get_mut(&buffer) else {
878            return;
879        };
880        if let Some(pos) = bucket.iter().position(|k| *k == session_key) {
881            bucket.swap_remove(pos);
882        }
883        if bucket.is_empty() {
884            state.conflict_refcounts.remove(&buffer);
885            state.pending.push(DiffModeChange {
886                buffer,
887                action: DiffModeAction::Deactivate,
888                kind: DiffModeKind::Conflict,
889            });
890        }
891    }
892}
893
894// ──────────────────────────────────────────────────────────────
895// Bridge tests — pure refcount semantics. End-to-end tests that
896// drive the dispatch tail + assert ActiveModes live next to the
897// `do_diff_open` / `register_two_pane_diff` call sites.
898// ──────────────────────────────────────────────────────────────
899
900#[cfg(test)]
901mod tests {
902    use super::*;
903
904    fn bid(n: u32) -> BufferId {
905        BufferId(n)
906    }
907
908    /// DX.8: the `diff-conflict-mode` activation predicate fires iff the
909    /// session's sign map carries a `Conflict` region — not for a clean,
910    /// add-only, or change/remove-only map. This is the gate a future
911    /// bridge consults to toggle the mode.
912    #[test]
913    fn conflict_predicate_detects_conflict_regions() {
914        use crate::overlay::{DiffSignKind, DiffSignMap};
915
916        let with_conflict =
917            DiffSignMap::from_entries(vec![(1, DiffSignKind::Add), (3, DiffSignKind::Conflict)]);
918        assert!(sign_map_has_conflicts(&with_conflict));
919
920        // Add / Change / Remove are 2-way diff signs, NOT conflicts.
921        let no_conflict = DiffSignMap::from_entries(vec![
922            (1, DiffSignKind::Add),
923            (2, DiffSignKind::Change),
924            (5, DiffSignKind::Remove),
925        ]);
926        assert!(!sign_map_has_conflicts(&no_conflict));
927
928        // A clean buffer has no conflict.
929        assert!(!sign_map_has_conflicts(&DiffSignMap::default()));
930    }
931
932    /// DX.8: `register_diff_modes` registers BOTH `diff-mode` and the new
933    /// `diff-conflict-mode` shell (the mode decomposition, design §4)
934    /// without a registration conflict — and both carry the `-mode` suffix
935    /// the registry enforces.
936    #[test]
937    fn register_diff_modes_registers_base_and_conflict_modes() {
938        let mut registry = ModeRegistry::new();
939        register_diff_modes(&mut registry);
940        assert!(
941            registry.is_registered(DiffMode::mode_id()),
942            "diff-mode registered"
943        );
944        assert!(
945            registry.is_registered(DiffConflictMode::mode_id()),
946            "diff-conflict-mode registered"
947        );
948        assert!(DiffConflictMode::mode_id().as_str().ends_with("-mode"));
949    }
950
951    /// MO.x (C10): the `diff-mode` keymap entries map `do`/`dp` to the
952    /// canonical `action:diff-*` names, in order (catches a name swap or a
953    /// dropped chord). The end-to-end resolution on the
954    /// `MinorMode(diff-mode)` layer via the host's K.2.4 translate pass is
955    /// pinned by `diff_get_put_chords_bound_on_diff_mode_layer` (DX.1) — a
956    /// stronger guard than a builder unit test, so the DX.5 trie-builder
957    /// tests retire with `diff_mode_layer_bindings`.
958    #[test]
959    fn keymap_entries_map_do_and_dp_to_diff_actions() {
960        let pairs: Vec<(&str, Option<&str>)> = diff_mode_keymap_entries()
961            .iter()
962            .map(|e| (e.chord, e.command))
963            .collect();
964        assert_eq!(
965            pairs,
966            vec![
967                ("do", Some("action:diff-get")),
968                ("dp", Some("action:diff-put")),
969                // CR.6: hunk-nav is mode-owned now (was host do_next/prev_hunk).
970                ("]c", Some("action:hunk-next")),
971                ("[c", Some("action:hunk-prev")),
972            ],
973        );
974    }
975
976    /// CR.1: `DiffMode::action_handlers()` contributes exactly the
977    /// `do`/`dp` handler bodies, keyed to the `action:diff-get` /
978    /// `action:diff-put` command names — the SAME names the keymap binds,
979    /// so the host's `register_mode_action_handlers` boot walk resolves
980    /// each to the chord's `CommandId`. Catches a name drift or a
981    /// dropped/duplicated handler.
982    #[test]
983    fn action_handlers_contribute_diff_get_and_put() {
984        let names: Vec<&str> = DiffMode
985            .action_handlers()
986            .iter()
987            .map(|c| c.action_name)
988            .collect();
989        assert_eq!(
990            names,
991            vec![
992                "action:diff-get",
993                "action:diff-put",
994                "action:hunk-next",
995                "action:hunk-prev",
996            ]
997        );
998    }
999
1000    /// CR.3: `DiffConflictMode` keymap maps the 5 vim-fugitive chords to
1001    /// the conflict action names, in order (catches a name swap / dropped
1002    /// chord). End-to-end layer resolution is pinned host-side by the
1003    /// DX.1-style `diff_conflict_chords_bound_on_conflict_mode_layer`.
1004    #[test]
1005    fn conflict_keymap_entries_map_chords_to_actions() {
1006        let pairs: Vec<(&str, Option<&str>)> = diff_conflict_mode_keymap_entries()
1007            .iter()
1008            .map(|e| (e.chord, e.command))
1009            .collect();
1010        assert_eq!(
1011            pairs,
1012            vec![
1013                ("d2o", Some("action:diff-keep-ours")),
1014                ("d3o", Some("action:diff-keep-theirs")),
1015                ("d2p", Some("action:diff-put-ours")),
1016                ("d3p", Some("action:diff-put-theirs")),
1017                ("dB", Some("action:diff-keep-both")),
1018            ],
1019        );
1020    }
1021
1022    /// CR.3: `DiffConflictMode` contributes the 5 conflict handler bodies,
1023    /// keyed to the matching action names.
1024    #[test]
1025    fn conflict_action_handlers_contribute_five() {
1026        let names: Vec<&str> = DiffConflictMode
1027            .action_handlers()
1028            .iter()
1029            .map(|c| c.action_name)
1030            .collect();
1031        assert_eq!(
1032            names,
1033            vec![
1034                "action:diff-keep-ours",
1035                "action:diff-keep-theirs",
1036                "action:diff-put-ours",
1037                "action:diff-put-theirs",
1038                "action:diff-keep-both",
1039            ],
1040        );
1041    }
1042
1043    /// CR.3: `note_conflict_state` queues a Conflict Activate per
1044    /// participant on the false→true transition, a Deactivate on
1045    /// true→false, and nothing on repeated same-state calls (so the
1046    /// host's per-cycle reconcile is cheap + idempotent).
1047    #[test]
1048    fn conflict_state_activates_and_deactivates_per_transition() {
1049        let bridge = DiffModeBridge::new();
1050        bridge.note_conflict_state(bid(2), &[bid(1), bid(2)], true);
1051        let changes = bridge.drain_pending();
1052        assert_eq!(changes.len(), 2);
1053        assert!(
1054            changes
1055                .iter()
1056                .all(|c| c.kind == DiffModeKind::Conflict && c.action == DiffModeAction::Activate)
1057        );
1058        // Idempotent: still conflict-bearing → nothing queued.
1059        bridge.note_conflict_state(bid(2), &[bid(1), bid(2)], true);
1060        assert!(bridge.drain_pending().is_empty());
1061        // Cleared → one Deactivate per participant.
1062        bridge.note_conflict_state(bid(2), &[bid(1), bid(2)], false);
1063        let changes = bridge.drain_pending();
1064        assert_eq!(changes.len(), 2);
1065        assert!(
1066            changes
1067                .iter()
1068                .all(|c| c.kind == DiffModeKind::Conflict
1069                    && c.action == DiffModeAction::Deactivate)
1070        );
1071    }
1072
1073    /// CR.3: closing a session also clears its conflict-mode activation,
1074    /// even if a no-conflict sign map was never published first
1075    /// (`:diffoff` / doc-close auto-drop).
1076    #[test]
1077    fn closing_session_clears_conflict_activation() {
1078        let bridge = DiffModeBridge::new();
1079        bridge.note_conflict_state(bid(2), &[bid(1), bid(2)], true);
1080        let _ = bridge.drain_pending();
1081        bridge.note_session_closed(bid(2));
1082        let changes = bridge.drain_pending();
1083        assert_eq!(changes.len(), 2);
1084        assert!(
1085            changes
1086                .iter()
1087                .all(|c| c.kind == DiffModeKind::Conflict
1088                    && c.action == DiffModeAction::Deactivate)
1089        );
1090    }
1091
1092    /// ML.3b: the formatter counts adds vs changes (Change/Conflict/Remove
1093    /// fold into `~`), and yields empty (hidden) content for a clean map.
1094    #[test]
1095    fn diff_content_counts_adds_and_changes() {
1096        use crate::overlay::{DiffSignKind, DiffSignMap};
1097        let map = DiffSignMap::from_entries(vec![
1098            (1, DiffSignKind::Add),
1099            (2, DiffSignKind::Add),
1100            (3, DiffSignKind::Change),
1101            (4, DiffSignKind::Conflict),
1102            (5, DiffSignKind::Remove),
1103        ]);
1104        // 2 adds, 3 changed (Change + Conflict + Remove).
1105        assert_eq!(diff_content(&map).plain(), "+2 ~3");
1106
1107        // Clean map → hidden.
1108        assert!(diff_content(&DiffSignMap::default()).is_empty());
1109
1110        // Adds only → no `~` segment.
1111        let adds = DiffSignMap::from_entries(vec![(1, DiffSignKind::Add)]);
1112        assert_eq!(diff_content(&adds).plain(), "+1");
1113    }
1114
1115    /// ML.3b: the descriptor is Global (active-pane only), Left zone,
1116    /// after `core.path`.
1117    #[test]
1118    fn register_diff_element_is_global_left() {
1119        let svc = ModelineService::new();
1120        register_diff_modeline_element(&svc);
1121        let snap = svc.snapshot();
1122        let el = snap
1123            .registry
1124            .get(&ElementId::new(DIFF_ELEMENT))
1125            .expect("diff descriptor");
1126        assert_eq!(el.zone, Zone::Left);
1127        assert_eq!(el.priority, 20);
1128        assert_eq!(el.scope, Scope::Global);
1129    }
1130
1131    /// DX.1 (BC.6 gate): `DiffMode::gutter_decorations` projects the active
1132    /// buffer's `DiffSignMap` into one `GutterDecoration::Diff` per signed line,
1133    /// mapping each `DiffSignKind` to its `GutterDiffKind`. This is the
1134    /// sign-gutter contract the extraction must preserve; it lives in `mode.rs`
1135    /// (mode-owned) so it moves with the mode into `lattice-diff` at DX.6.
1136    #[test]
1137    fn gutter_decorations_emit_diff_signs_from_sign_map() {
1138        use crate::overlay::{DiffSignKind, DiffSignMap};
1139        use lattice_mode::ServiceRegistry;
1140
1141        let sign_map = std::sync::Arc::new(DiffSignMap::from_entries(vec![
1142            (0, DiffSignKind::Add),
1143            (2, DiffSignKind::Change),
1144            (5, DiffSignKind::Remove),
1145            (7, DiffSignKind::Conflict),
1146        ]));
1147        let mut services = ServiceRegistry::new();
1148        services.register(DiffDecorationData { sign_map });
1149        // SG.4b: a hunk mark is a SIGN now, so the producer needs the interned
1150        // built-in ids the renderer injects. Registering a real set here (not
1151        // `Default`) is what makes the assertions below mean anything — the
1152        // default ids resolve to nothing on purpose.
1153        let mut registry = lattice_mode::SignRegistry::new();
1154        let ids = lattice_mode::register_builtin_signs(
1155            &mut registry,
1156            lattice_mode::DiagnosticGlyphs::default(),
1157        );
1158        services.register(ids);
1159
1160        let ctx = DecorationCtx::new(bid(1), &services);
1161        let decos = DiffMode.gutter_decorations(&ctx);
1162
1163        let expected = [
1164            (0u32, GutterDiffKind::Add),
1165            (2, GutterDiffKind::Change),
1166            (5, GutterDiffKind::Remove),
1167            (7, GutterDiffKind::Conflict),
1168        ];
1169        assert_eq!(
1170            decos.len(),
1171            expected.len(),
1172            "one decoration per signed line"
1173        );
1174        for (deco, (eline, ekind)) in decos.iter().zip(expected) {
1175            match deco {
1176                GutterDecoration::Sign { line, sign } => {
1177                    assert_eq!(*line, eline);
1178                    assert_eq!(
1179                        *sign,
1180                        ids.for_diff(ekind),
1181                        "the hunk kind selects the built-in sign for it"
1182                    );
1183                    // And that sign lands in the diff column, so a diagnostic
1184                    // on the same line cannot hide it.
1185                    assert_eq!(
1186                        registry.get(*sign).unwrap().column,
1187                        lattice_mode::SIGN_COLUMN_DIFF
1188                    );
1189                }
1190            }
1191        }
1192    }
1193
1194    /// SG.4b: without the interned built-in ids (a stripped harness), the
1195    /// producer contributes nothing rather than placements resolving to
1196    /// whatever sits at id 0 — which would be some plugin's sign.
1197    #[test]
1198    fn gutter_decorations_empty_without_builtin_sign_ids() {
1199        use crate::overlay::{DiffSignKind, DiffSignMap};
1200        use lattice_mode::ServiceRegistry;
1201        let sign_map = std::sync::Arc::new(DiffSignMap::from_entries(vec![(0, DiffSignKind::Add)]));
1202        let mut services = ServiceRegistry::new();
1203        services.register(DiffDecorationData { sign_map });
1204        let ctx = DecorationCtx::new(bid(1), &services);
1205        assert!(DiffMode.gutter_decorations(&ctx).is_empty());
1206    }
1207
1208    /// DX.1: without a `DiffDecorationData` service (the renderer injects it
1209    /// only for the active diff buffer), the gutter contributes nothing — a
1210    /// non-diff buffer has no diff signs.
1211    #[test]
1212    fn gutter_decorations_empty_without_decoration_service() {
1213        use lattice_mode::ServiceRegistry;
1214        let services = ServiceRegistry::new();
1215        let ctx = DecorationCtx::new(bid(1), &services);
1216        assert!(DiffMode.gutter_decorations(&ctx).is_empty());
1217    }
1218
1219    #[test]
1220    fn open_session_queues_activates_for_each_participant() {
1221        let bridge = DiffModeBridge::new();
1222        bridge.note_session_opened(bid(2), &[bid(1), bid(2)]);
1223        let changes = bridge.drain_pending();
1224        assert_eq!(changes.len(), 2);
1225        assert!(
1226            changes
1227                .iter()
1228                .any(|c| c.buffer == bid(1) && c.action == DiffModeAction::Activate)
1229        );
1230        assert!(
1231            changes
1232                .iter()
1233                .any(|c| c.buffer == bid(2) && c.action == DiffModeAction::Activate)
1234        );
1235    }
1236
1237    #[test]
1238    fn close_session_queues_deactivates() {
1239        let bridge = DiffModeBridge::new();
1240        bridge.note_session_opened(bid(2), &[bid(1), bid(2)]);
1241        let _ = bridge.drain_pending();
1242        bridge.note_session_closed(bid(2));
1243        let changes = bridge.drain_pending();
1244        assert_eq!(changes.len(), 2);
1245        assert!(
1246            changes
1247                .iter()
1248                .all(|c| c.action == DiffModeAction::Deactivate)
1249        );
1250        assert!(changes.iter().any(|c| c.buffer == bid(1)));
1251        assert!(changes.iter().any(|c| c.buffer == bid(2)));
1252    }
1253
1254    #[test]
1255    fn close_unknown_session_is_noop() {
1256        let bridge = DiffModeBridge::new();
1257        bridge.note_session_closed(bid(99));
1258        assert!(bridge.drain_pending().is_empty());
1259    }
1260
1261    #[test]
1262    fn empty_participants_queues_nothing() {
1263        let bridge = DiffModeBridge::new();
1264        bridge.note_session_opened(bid(2), &[]);
1265        assert!(bridge.drain_pending().is_empty());
1266        bridge.note_session_closed(bid(2));
1267        assert!(bridge.drain_pending().is_empty());
1268    }
1269
1270    #[test]
1271    fn two_independent_sessions_do_not_interfere() {
1272        // Session A: file1 ↔ file2. Session B: file3 ↔ file4.
1273        // Each open queues two activates; each close queues
1274        // two deactivates. The buffers never refcount-overlap,
1275        // so the changes are independent.
1276        let bridge = DiffModeBridge::new();
1277        bridge.note_session_opened(bid(2), &[bid(1), bid(2)]);
1278        bridge.note_session_opened(bid(4), &[bid(3), bid(4)]);
1279        let changes = bridge.drain_pending();
1280        assert_eq!(changes.len(), 4);
1281
1282        bridge.note_session_closed(bid(2));
1283        let after_close_a = bridge.drain_pending();
1284        assert_eq!(after_close_a.len(), 2);
1285        assert!(
1286            after_close_a
1287                .iter()
1288                .all(|c| c.action == DiffModeAction::Deactivate)
1289        );
1290        assert!(after_close_a.iter().any(|c| c.buffer == bid(1)));
1291        assert!(after_close_a.iter().any(|c| c.buffer == bid(2)));
1292
1293        // Session B is untouched.
1294        assert_eq!(bridge.refcount(bid(3)), 1);
1295        assert_eq!(bridge.refcount(bid(4)), 1);
1296        assert_eq!(bridge.refcount(bid(1)), 0);
1297        assert_eq!(bridge.refcount(bid(2)), 0);
1298    }
1299
1300    #[test]
1301    fn shared_buffer_in_two_sessions_holds_mode_until_last_closes() {
1302        // Pathological but legal: file1 participates in both
1303        // session A (file1 ↔ file2) and session B (file1 ↔
1304        // file3). Closing A must NOT deactivate diff-mode on
1305        // file1 because B still needs it.
1306        let bridge = DiffModeBridge::new();
1307        bridge.note_session_opened(bid(2), &[bid(1), bid(2)]);
1308        bridge.note_session_opened(bid(3), &[bid(1), bid(3)]);
1309        let _ = bridge.drain_pending();
1310
1311        assert_eq!(bridge.refcount(bid(1)), 2);
1312        assert_eq!(bridge.refcount(bid(2)), 1);
1313        assert_eq!(bridge.refcount(bid(3)), 1);
1314
1315        // Close session A: file2 deactivates; file1 must NOT
1316        // (still held by session B).
1317        bridge.note_session_closed(bid(2));
1318        let changes = bridge.drain_pending();
1319        assert_eq!(
1320            changes.len(),
1321            1,
1322            "only file2 should deactivate, got {changes:?}"
1323        );
1324        assert_eq!(changes[0].buffer, bid(2));
1325        assert_eq!(changes[0].action, DiffModeAction::Deactivate);
1326        assert_eq!(bridge.refcount(bid(1)), 1, "file1 still held by B");
1327
1328        // Close session B: file1 + file3 deactivate.
1329        bridge.note_session_closed(bid(3));
1330        let changes = bridge.drain_pending();
1331        assert_eq!(changes.len(), 2);
1332        assert!(
1333            changes
1334                .iter()
1335                .all(|c| c.action == DiffModeAction::Deactivate)
1336        );
1337        assert!(changes.iter().any(|c| c.buffer == bid(1)));
1338        assert!(changes.iter().any(|c| c.buffer == bid(3)));
1339    }
1340
1341    #[test]
1342    fn reopen_same_session_key_is_idempotent_on_refcount() {
1343        // `register_with_sources` is idempotent on session
1344        // identity; the bridge mirrors that — re-opening the
1345        // same session_key with the same participants must not
1346        // double-count.
1347        let bridge = DiffModeBridge::new();
1348        bridge.note_session_opened(bid(2), &[bid(1), bid(2)]);
1349        bridge.note_session_opened(bid(2), &[bid(1), bid(2)]);
1350        assert_eq!(bridge.refcount(bid(1)), 1);
1351        assert_eq!(bridge.refcount(bid(2)), 1);
1352    }
1353
1354    #[test]
1355    fn reopen_with_different_participants_releases_old() {
1356        // A future `:diff-mode split` may swap a session's
1357        // participants. The bridge must scrub old refcounts
1358        // and account the new set.
1359        let bridge = DiffModeBridge::new();
1360        bridge.note_session_opened(bid(2), &[bid(1), bid(2)]);
1361        let _ = bridge.drain_pending();
1362
1363        // Swap baseline from file1 to file9.
1364        bridge.note_session_opened(bid(2), &[bid(9), bid(2)]);
1365        assert_eq!(bridge.refcount(bid(1)), 0, "old baseline released");
1366        assert_eq!(bridge.refcount(bid(9)), 1, "new baseline held");
1367        assert_eq!(bridge.refcount(bid(2)), 1, "primary still held");
1368
1369        let changes = bridge.drain_pending();
1370        // Expect: file1 → Deactivate; file9 → Activate. file2's
1371        // refcount went 1 → 0 → 1 in one call, so its change
1372        // sequence is Deactivate + Activate.
1373        assert!(
1374            changes
1375                .iter()
1376                .any(|c| c.buffer == bid(1) && c.action == DiffModeAction::Deactivate)
1377        );
1378        assert!(
1379            changes
1380                .iter()
1381                .any(|c| c.buffer == bid(9) && c.action == DiffModeAction::Activate)
1382        );
1383    }
1384
1385    #[test]
1386    fn drain_pending_is_idempotent() {
1387        let bridge = DiffModeBridge::new();
1388        bridge.note_session_opened(bid(2), &[bid(1)]);
1389        assert_eq!(bridge.drain_pending().len(), 1);
1390        assert!(bridge.drain_pending().is_empty(), "second drain empty");
1391    }
1392}