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}