Skip to main content

lattice_magit/
magit_hunk_mode.rs

1//! MG.24a: `magit-hunk-mode` — the minor that owns diff *content*.
2//!
3//! Design fragment:
4//! `docs/dev/architecture/magit-hunk-mode.md`.
5//!
6//! Five majors render unified diff, and each declared its own chords
7//! for acting on it. The set had drifted: magit-status had `s`/`u`/`x`,
8//! magit-diff had `s`/`u` and no `x`, and magit-commit,
9//! magit-revision and magit-stash-show had none at all — eight
10//! declarations covering three actions, eleven of fifteen cells empty.
11//! Nobody noticed the missing `x` because there was no single place it
12//! should have been, which is the failure mode a copied set has: **a
13//! gap in it does not announce itself.**
14//!
15//! So the chords live here, on the mode that says what the buffer's
16//! *content* is, while the major keeps saying what the buffer *is*.
17//!
18//! **The machinery did not move.** `resolve_hunk`, `HunkOp`, the
19//! `DiffSource` gate and MG.18e's region rewrite stay in
20//! `magit_core_mode` where MG.18 put them; this mode contributes the
21//! bindings and the handlers that call them. Only the bindings were in
22//! the wrong place.
23//!
24//! **`<CR>` moved here once the seam existed.** The chord and the
25//! diff-path parsing belong to the mode; *which version of the file to
26//! open* belongs to the view, because it genuinely differs — the index
27//! blob for a staged diff, the live file for an unstaged one, the file
28//! at a sha for a revision, the stash's copy for a stash. That is
29//! `MagitView::diff_target`.
30//!
31//! Magit-status's `<CR>` is context-aware over rows that are not diffs
32//! at all (a file entry, a stash, a commit), and a minor's binding
33//! wins over a major's — so that behaviour is reached through
34//! `MagitView::visit_at_cursor` rather than being replaced by a
35//! diff-only handler.
36
37use std::sync::OnceLock;
38
39use lattice_core::FoldOverlayServiceHandle;
40use lattice_mode::{
41    ActivationPolicy, BufferStoreHandle, CapabilitySet, Keymap, KeymapEntry, LifecycleFuture, Mode,
42    ModeContext, ModeId, ModeKind, OptionOverrideSet, keymap_entry,
43};
44
45use crate::hunk_fold_source::MagitHunkFoldSource;
46
47use crate::magit_commit_mode::MagitCommitMode;
48use crate::magit_diff_mode::MagitDiffMode;
49use crate::magit_revision_mode::MagitRevisionMode;
50use crate::magit_stash_show_mode::MagitStashShowMode;
51use crate::magit_status_mode::MagitStatusMode;
52
53pub struct MagitHunkMode;
54
55impl MagitHunkMode {
56    pub fn mode_id() -> ModeId {
57        ModeId::new("magit-hunk-mode")
58    }
59}
60
61fn magit_hunk_keymap_entries() -> &'static [KeymapEntry] {
62    static ENTRIES: OnceLock<Vec<KeymapEntry>> = OnceLock::new();
63    ENTRIES.get_or_init(|| {
64        vec![
65            // Normal and Visual for each: MG.18e's region staging acts
66            // on the lines a selection covers, and it is reached by the
67            // same key. A Normal-only binding would leave the region
68            // path bound in some majors and not others — which is the
69            // drift this mode exists to end.
70            keymap_entry! { mode: Normal, chord: "s", doc: "Stage hunk or file at cursor", cmd: "action:magit-stage" },
71            keymap_entry! { mode: Visual, chord: "s", doc: "Stage the selected lines", cmd: "action:magit-stage" },
72            keymap_entry! { mode: Normal, chord: "u", doc: "Unstage hunk or file at cursor", cmd: "action:magit-unstage" },
73            keymap_entry! { mode: Visual, chord: "u", doc: "Unstage the selected lines", cmd: "action:magit-unstage" },
74            keymap_entry! { mode: Normal, chord: "x", doc: "Discard hunk or file at cursor", cmd: "action:magit-discard" },
75            keymap_entry! { mode: Visual, chord: "x", doc: "Discard the selected lines", cmd: "action:magit-discard" },
76            // MG.23g's committed-hunk pair. They were on
77            // `magit-core-mode`, which activates on all eleven magit
78            // majors — so they were consumed dead keys in the six with
79            // no diff content in them.
80            keymap_entry! { mode: Normal, chord: "a", doc: "Apply the hunk at cursor to the working tree", cmd: "action:magit-apply-hunk" },
81            // The Visual peers of `s` / `u` / `x`. `region_of` has always
82            // restricted a hunk to the selected rows for EVERY `HunkOp`,
83            // `Apply` and `Reverse` included — but a selection only exists in
84            // Visual, and without a Visual binding these two could never be
85            // pressed with one. The region path was implemented and
86            // unreachable.
87            //
88            // **Visual `a` costs the `a`-flavoured text objects in magit
89            // buffers**, and that is not a detail: a bound prefix kills its
90            // longer chords — the trie stops at `a` and `vaw` / `vap` / `vab`
91            // die silently rather than being shadowed. Accepted because
92            // `evil-collection-magit` binds `a` in visual state and this
93            // repo's convention is to follow its remaps, and because the
94            // `i`-flavoured objects (`viw`, `vip`) are untouched, so
95            // selecting a word to yank still works. If that trade ever looks
96            // wrong, the fix is to move apply/reverse off `a`, not to
97            // half-bind it.
98            keymap_entry! { mode: Visual, chord: "a", doc: "Apply the selected lines to the working tree", cmd: "action:magit-apply-hunk" },
99            keymap_entry! { mode: Normal, chord: "-", doc: "Reverse the hunk at cursor out of the working tree", cmd: "action:magit-reverse-hunk" },
100            // `-` is not a prefix of anything, so its Visual binding costs
101            // nothing.
102            keymap_entry! { mode: Visual, chord: "-", doc: "Reverse the selected lines out of the working tree", cmd: "action:magit-reverse-hunk" },
103            // Hunk navigation, for the same reason: `]c` in a branch
104            // list resolved an empty header set and returned `None`,
105            // and a Normal-mode chord a mode binds is consumed
106            // unconditionally.
107            keymap_entry! { mode: Normal, chord: "]c", doc: "Next hunk", cmd: "action:magit-next-hunk" },
108            keymap_entry! { mode: Normal, chord: "[c", doc: "Previous hunk", cmd: "action:magit-prev-hunk" },
109            keymap_entry! { mode: Normal, chord: "<CR>", doc: "Visit the file at cursor", cmd: "action:magit-visit-diff-target" },
110            // `]f` / `[f` moved here from `magit-core-mode`, where they
111            // were bound on all ten majors and meant something in one.
112            // In a branch / stash / remote / log list they jumped
113            // between *rows* while claiming to move between files (a
114            // job `j` and `]]` already do); in the diff-content views
115            // they matched indented CONTEXT lines, so they walked
116            // through arbitrary code; in the rebase todo, whose rows
117            // sit at column 0, they matched nothing at all.
118            //
119            // This mode's five majors are exactly the file-bearing
120            // ones. Which rows count as "a file" still differs between
121            // them — entries in magit-status, `diff --git` headers in a
122            // pure diff — and that is what `MagitView::file_lines`
123            // answers. Same shape MG.24a gave `]c` / `[c`.
124            keymap_entry! { mode: Normal, chord: "]f", doc: "Next file", cmd: "action:magit-next-file" },
125            keymap_entry! { mode: Normal, chord: "[f", doc: "Previous file", cmd: "action:magit-prev-file" },
126            // MG.19: vim-fugitive's key for exactly this, and it lands
127            // in the `d`-prefixed family `diff-mode` already owns
128            // (`do` / `dp` / `d2o`). `dv` is not an operator+motion —
129            // `v` forces characterwise on a `d` that never completes —
130            // so it is inert in a read-only magit buffer.
131            keymap_entry! { mode: Normal, chord: "dv", doc: "Open the file at cursor side-by-side against its baseline", cmd: "action:magit-diff-side-by-side" },
132        ]
133    })
134}
135
136/// MG.22: the one `<CR>` handler.
137///
138/// **The view is asked first, and that order is a correctness
139/// requirement rather than a preference.**
140///
141/// The obvious order — resolve the diff path, then ask the view which
142/// version — is wrong in magit-status, where an expanded inline diff
143/// is rendered *below* the file entry it belongs to:
144///
145/// ```text
146///   modified a.txt
147///     diff --git a/a.txt b/a.txt     ← a.txt's expansion
148///     @@ …
149///   modified b.txt                   ← cursor here
150/// ```
151///
152/// `path_at_cursor` scans **upward** for the nearest `diff --git`, so
153/// on `modified b.txt` it would find *a.txt's* header and `<CR>` would
154/// open the wrong file — silently, and only in the case where some
155/// earlier entry happens to be expanded.
156///
157/// Asking the view first removes that: magit-status classifies the row
158/// (file entry, stash, commit) and answers, and only rows it does not
159/// recognise — which is exactly the diff content — fall through to
160/// path resolution. Views whose buffer is entirely diff decline the
161/// first question and take the second.
162fn visit_diff_target(ctx: &lattice_mode::ActionContext<'_>) -> Option<lattice_grammar::Effect> {
163    let view = crate::buffer_state::view_for(ctx)?;
164    if let Some(effect) = view.visit_at_cursor(ctx.cursor) {
165        return Some(effect);
166    }
167    let store = ctx.services.get::<lattice_mode::BufferStoreHandle>()?;
168    let handle = store.handle_for(lattice_core::BufferId(ctx.buffer_id.0 as u32))?;
169    let snap = handle.snapshot();
170    let read = |i: usize| u32::try_from(i).ok().and_then(|l| snap.buffer.line(l));
171    let path = crate::hunk::path_at_cursor(read, ctx.cursor.line as usize)?;
172    let target = view.diff_target(&path, ctx.cursor)?;
173
174    // MG.50: land on the code under the cursor — the right line AND the
175    // right offset within it, so `<CR>` on a hunk row puts the caret on
176    // the same token it was on in the diff rather than at line start.
177    //
178    // `None` here is the ordinary case, not a failure — a file entry row
179    // is not inside a hunk, and emacs opens those at the top too. The
180    // target opens unpositioned.
181    match crate::hunk::source_position_at(read, ctx.cursor.line as usize, ctx.cursor.byte) {
182        Some(pos) => Some(at_position(target, pos)),
183        None => Some(target),
184    }
185}
186
187/// MG.50: re-express an "open this" effect as "open this AT `pos`".
188///
189/// Positioning has to be part of the SAME effect rather than a
190/// following `CursorMove`: the opens are peer-applied (the TUI/GPUI
191/// `do_edit` path) while a cursor effect runs host-side against
192/// whatever buffer is active at that moment, so the two cannot be
193/// ordered to land the caret on a buffer that does not exist yet. This
194/// is the reason `Effect::OpenBufferAt` exists at all — see its doc,
195/// which records the same bug for search `<CR>`.
196///
197/// Effects with nothing to position (an echo, a refusal) pass through.
198fn at_position(
199    effect: lattice_grammar::Effect,
200    pos: crate::hunk::SourcePos,
201) -> lattice_grammar::Effect {
202    use lattice_grammar::Effect;
203    let position = lattice_protocol::position::Position::new(pos.line, pos.byte);
204    match effect {
205        Effect::OpenBuffer { path, force } => Effect::OpenBufferAt {
206            path,
207            position,
208            force,
209            content: None,
210            activate_minor: None,
211        },
212        Effect::OpenSyntheticBuffer { name, mode_id, .. } => Effect::OpenSyntheticBufferAt {
213            name,
214            mode_id,
215            position,
216        },
217        other => other,
218    }
219}
220
221/// MG.19: `dv` — the file at cursor, side by side against its baseline.
222///
223/// **This composes what already exists rather than building a second
224/// diff.** `lattice-diff` owns two-pane sessions: scroll binding,
225/// filler rows, `]c` / `[c`, and `do` / `dp` are all consequences of a
226/// registered `PaneGroup`, not of anything magit does. So the whole
227/// slice is two effects in order:
228///
229/// 1. open the baseline — the file as it exists at the version this
230///    diff was taken against — in the CURRENT pane;
231/// 2. `Effect::Diffsplit` the working-tree file into a new vsplit,
232///    which registers the session between the two.
233///
234/// The baseline goes first because `Diffsplit` diffs the new pane
235/// against whatever pane is active. Getting that order backwards would
236/// put the editable side on the left and silently invert what `do` and
237/// `dp` mean.
238///
239/// The baseline is `*magit:file:<ref>:<path>*`
240/// ([`crate::magit_file_revision_mode`]) — a synthetic buffer, which
241/// works here only because synthetic magit buffers really are
242/// `BufferKind::Document`. `do_diffsplit` refuses a non-Document
243/// active pane, so "everything is a buffer" is load-bearing rather
244/// than decorative in this path.
245fn diff_side_by_side(ctx: &lattice_mode::ActionContext<'_>) -> Option<lattice_grammar::Effect> {
246    use lattice_grammar::Effect;
247
248    let view = crate::buffer_state::view_for(ctx)?;
249    let store = ctx.services.get::<lattice_mode::BufferStoreHandle>()?;
250    let handle = store.handle_for(lattice_core::BufferId(ctx.buffer_id.0 as u32))?;
251    let snap = handle.snapshot();
252    let path = crate::hunk::path_at_cursor(
253        |i| u32::try_from(i).ok().and_then(|l| snap.buffer.line(l)),
254        ctx.cursor.line as usize,
255    )?;
256
257    // Which version is "the other side" depends on what this buffer's
258    // diff was taken against — the same question `s` / `u` / `x` ask,
259    // answered by the same seam.
260    //
261    // Note this succeeds in a case where `s` / `u` / `x` deliberately
262    // refuse: `diff_source` yields `None` for the unscoped
263    // `*magit:diff*`, because a diff against HEAD mixes staged and
264    // unstaged changes and there is no single tree to apply a hunk to.
265    // *Showing* two versions has no such ambiguity — the question is
266    // "which version", not "which tree do I write to" — so `None`
267    // resolves to HEAD rather than declining.
268    let git_ref = baseline_ref(view.diff_source(ctx.cursor), || {
269        view.commit_at_cursor(ctx.cursor)
270    })?;
271
272    let workdir = view.workdir()?;
273    let absolute = workdir.join(&path);
274    // A file the commit deleted, or one not yet written, has no
275    // working-tree side to put in the right-hand pane. Saying so beats
276    // opening an empty split that reads as a broken diff.
277    if !absolute.exists() {
278        return Some(Effect::Echo {
279            level: lattice_grammar::EchoLevel::Warn,
280            text: format!(
281                "{} has no working-tree copy to diff against",
282                path.display()
283            ),
284        });
285    }
286
287    Some(side_by_side_effects(
288        &crate::repo_scope::label_of_buffer(&store, lattice_core::BufferId(ctx.buffer_id.0 as u32)),
289        &git_ref,
290        &path,
291        absolute,
292    ))
293}
294
295/// Which version is the left-hand side.
296///
297/// Pure, and separate from the handler, because this is the part with
298/// a decision in it — the handler around it is buffer plumbing.
299pub(crate) fn baseline_ref(
300    source: Option<crate::buffer_state::DiffSource>,
301    commit_at_cursor: impl FnOnce() -> Option<String>,
302) -> Option<String> {
303    use crate::buffer_state::DiffSource;
304    match source {
305        // Both index-relative: `--cached` is HEAD↔index and a plain
306        // diff is index↔worktree, so the index blob is the meaningful
307        // other side in each.
308        Some(DiffSource::Staged) | Some(DiffSource::Unstaged) => Some("staged".to_string()),
309        // A commit's or a stash's patch describes a specific version,
310        // so that is the baseline — not the index, which has nothing
311        // to do with it.
312        Some(DiffSource::Committed) => commit_at_cursor(),
313        // `None` is where this deliberately differs from `s` / `u` /
314        // `x`, which refuse here: the unscoped `*magit:diff*` is
315        // against HEAD and mixes staged with unstaged, so there is no
316        // single tree to apply a hunk TO. Showing two versions has no
317        // such ambiguity — the question is "which version", not "which
318        // tree do I write to" — so HEAD is the answer, not a refusal.
319        None => Some("HEAD".to_string()),
320    }
321}
322
323/// The two effects, in the order that matters.
324///
325/// `Diffsplit` diffs its new pane against whatever pane is ACTIVE, so
326/// the baseline must be opened first. Reversed, the editable
327/// working-tree copy would end up on the left and `do` / `dp` would
328/// silently mean the opposite of what the user intends.
329pub(crate) fn side_by_side_effects(
330    repo: &str,
331    git_ref: &str,
332    path: &std::path::Path,
333    absolute: std::path::PathBuf,
334) -> lattice_grammar::Effect {
335    use lattice_grammar::Effect;
336    Effect::Many(vec![
337        Effect::OpenSyntheticBuffer {
338            name: crate::magit_file_revision_mode::blob_buffer_name(repo, git_ref, path),
339            mode_id: crate::magit_file_revision_mode::MagitFileRevisionMode::mode_id().to_string(),
340            content: None,
341            cursor: None,
342            activate_minor: None,
343        },
344        Effect::Diffsplit {
345            path: absolute,
346            remote: None,
347        },
348    ])
349}
350
351/// MG.45: deregisters this buffer's diff-fold source.
352///
353/// Drop-based, the same lifecycle `MagitStatusGuard` and
354/// `DiffModeGuard` use — a source left registered on a buffer whose
355/// mode has gone would keep computing folds over text it no longer
356/// describes.
357#[derive(Default)]
358pub struct MagitHunkGuard {
359    fold_registration: Option<(FoldOverlayServiceHandle, lattice_core::ProviderId)>,
360}
361
362impl Drop for MagitHunkGuard {
363    fn drop(&mut self) {
364        if let Some((svc, id)) = self.fold_registration.take() {
365            svc.remove_source(id);
366        }
367    }
368}
369
370impl Mode for MagitHunkMode {
371    /// MG.45: carries the diff-fold registration. Every ACTION this
372    /// mode binds is still registered once at boot by the mode that
373    /// owns its body (`magit-core-mode` for the shared hunk machinery,
374    /// `magit-status-mode`'s `actions.rs` for discard's ask/execute
375    /// pair) — this mode contributes bindings, not handlers.
376    type Guard = MagitHunkGuard;
377
378    fn id(&self) -> ModeId {
379        Self::mode_id()
380    }
381
382    fn kind(&self) -> ModeKind {
383        ModeKind::Minor
384    }
385
386    /// The five majors that render unified diff — and only those.
387    ///
388    /// Deliberately NOT the list `magit-core-mode` carries. A branch
389    /// list, a log, a stash list, a rebase todo, a blame and a blob
390    /// have no hunks, so binding `s`/`u`/`x`/`]c` there would consume
391    /// the keys to do nothing. That is the state `]c` and `a`/`-` were
392    /// already in before this mode existed.
393    fn activation_policy(&self) -> ActivationPolicy {
394        ActivationPolicy::Majors(vec![
395            MagitStatusMode::mode_id(),
396            MagitDiffMode::mode_id(),
397            MagitCommitMode::mode_id(),
398            MagitRevisionMode::mode_id(),
399            MagitStashShowMode::mode_id(),
400        ])
401    }
402
403    /// MG.46: **the diff text folds by hunk, never by code structure.**
404    ///
405    /// This mode owns what is inside the diff, so it owns which folds
406    /// may exist there. `foldmethod=manual` leaves `ManualPrimary` —
407    /// which produces nothing — as the primary, so the only folds are
408    /// this mode's own file ▸ hunk overlays plus magit-status's entry
409    /// overlay.
410    ///
411    /// Without it, a user whose global `foldmethod` is `indent` or
412    /// `syntax` gets the primary provider run over the diff *as if it
413    /// were source*. It is not: a hunk is a fragment with `+`/`-`/` `
414    /// prefixes on every row, so the folds it derives are structurally
415    /// meaningless — and the last one, opened by an indent that the
416    /// fragment never closes, runs to the end of the buffer and
417    /// swallows the rest of the magit-status document.
418    ///
419    /// Scoped to the mode rather than the buffer kind: the override
420    /// reverts when the mode deactivates, and it reaches exactly the
421    /// five diff-rendering majors this mode activates on.
422    fn options(&self) -> OptionOverrideSet {
423        lattice_config::overrides! {
424            lattice_config::FoldMethodOption = lattice_core::FoldMethod::Manual,
425        }
426    }
427
428    fn required_capabilities(&self) -> CapabilitySet {
429        CapabilitySet::empty()
430    }
431
432    fn keymap(&self) -> Keymap {
433        Keymap::from_entries(magit_hunk_keymap_entries())
434    }
435
436    fn action_handlers(&self) -> Vec<lattice_mode::ActionHandlerContribution> {
437        vec![
438            lattice_mode::ActionHandlerContribution {
439                action_name: "action:magit-visit-diff-target",
440                handler: std::sync::Arc::new(visit_diff_target),
441            },
442            lattice_mode::ActionHandlerContribution {
443                action_name: "action:magit-diff-side-by-side",
444                handler: std::sync::Arc::new(diff_side_by_side),
445            },
446        ]
447    }
448
449    /// MG.45: register the file ▸ hunk fold source.
450    ///
451    /// Registered HERE rather than per major because this mode already
452    /// activates on exactly the buffers that render a diff — which is
453    /// what makes one source serve five majors instead of five
454    /// near-copies. magit-status keeps its own source for the ENTRY
455    /// level; the two compose by range containment.
456    fn on_activate(&self, ctx: ModeContext) -> LifecycleFuture<'_, Self::Guard> {
457        Box::pin(async move {
458            let buffer_id = lattice_core::BufferId(ctx.buffer_id().0 as u32);
459            let Some(store) = ctx.service::<BufferStoreHandle>() else {
460                return Ok(MagitHunkGuard::default());
461            };
462            let fold_registration = ctx
463                .service::<FoldOverlayServiceHandle>()
464                .map(|outer| (*outer).clone())
465                .map(|svc| {
466                    let source =
467                        std::sync::Arc::new(MagitHunkFoldSource::new(store.clone(), buffer_id));
468                    let id = svc.add_source(source, buffer_id);
469                    (svc, id)
470                });
471            Ok(MagitHunkGuard { fold_registration })
472        })
473    }
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479
480    /// MG.46: **diff text never folds by code structure.**
481    ///
482    /// The mode that owns what is inside a hunk owns which folds may
483    /// exist there. Pinned as an option override rather than left to
484    /// the user's global `foldmethod`: with `indent` or `syntax` set
485    /// globally, the primary provider runs over the diff as if it were
486    /// source, and the last fold — opened by an indent the fragment
487    /// never closes — swallows the rest of the magit-status buffer.
488    #[test]
489    fn diff_buffers_fold_only_by_hunk_not_by_code_structure() {
490        let opts = MagitHunkMode.options();
491        let ov = opts
492            .iter()
493            .find(|o| {
494                o.option_type_id == std::any::TypeId::of::<lattice_config::FoldMethodOption>()
495            })
496            .expect("magit-hunk-mode must pin `foldmethod`");
497        assert_eq!(
498            ov.downcast_value::<lattice_core::FoldMethod>().copied(),
499            Some(lattice_core::FoldMethod::Manual),
500            "`foldmethod` must be `manual` so only this mode's own \
501             file/hunk overlays produce folds",
502        );
503    }
504
505    /// The override must reach every major that renders a diff — the
506    /// same five this mode binds its hunk chords on. A major that
507    /// rendered diff text without it would fold that text as code.
508    #[test]
509    fn the_fold_override_covers_every_diff_rendering_major() {
510        let ActivationPolicy::Majors(majors) = MagitHunkMode.activation_policy() else {
511            panic!("magit-hunk-mode scopes itself to specific majors");
512        };
513        for expected in [
514            MagitStatusMode::mode_id(),
515            MagitDiffMode::mode_id(),
516            MagitCommitMode::mode_id(),
517            MagitRevisionMode::mode_id(),
518            MagitStashShowMode::mode_id(),
519        ] {
520            assert!(
521                majors.contains(&expected),
522                "{expected:?} renders diff text, so it must inherit the \
523                 `foldmethod=manual` override",
524            );
525        }
526    }
527
528    /// MG.19: the baseline is the version the diff was taken against,
529    /// per source.
530    #[test]
531    fn the_baseline_is_the_version_this_diff_describes() {
532        use crate::buffer_state::DiffSource;
533        let no_commit = || None;
534        assert_eq!(
535            baseline_ref(Some(DiffSource::Staged), no_commit).as_deref(),
536            Some("staged")
537        );
538        assert_eq!(
539            baseline_ref(Some(DiffSource::Unstaged), no_commit).as_deref(),
540            Some("staged"),
541            "index↔worktree: the index is still the other side"
542        );
543        assert_eq!(
544            baseline_ref(Some(DiffSource::Committed), || Some("a1b2c3d".into())).as_deref(),
545            Some("a1b2c3d"),
546            "a commit's patch describes that commit, not the index"
547        );
548    }
549
550    /// Where `s` / `u` / `x` refuse, `dv` answers — and that asymmetry
551    /// is deliberate rather than an oversight.
552    #[test]
553    fn an_unclassifiable_diff_still_has_a_baseline() {
554        assert_eq!(
555            baseline_ref(None, || None).as_deref(),
556            Some("HEAD"),
557            "the unscoped `*magit:diff*` is against HEAD; showing two \
558             versions needs no tree to write to"
559        );
560    }
561
562    /// A committed diff with no commit under the cursor (a `--graph`
563    /// connector, a stat header) declines rather than guessing.
564    #[test]
565    fn a_committed_diff_with_no_commit_at_cursor_declines() {
566        use crate::buffer_state::DiffSource;
567        assert!(baseline_ref(Some(DiffSource::Committed), || None).is_none());
568    }
569
570    /// The order is the correctness requirement: `Diffsplit` diffs
571    /// against the ACTIVE pane, so the baseline has to be opened
572    /// first. Reversed, `do` and `dp` would mean the opposite.
573    #[test]
574    fn the_baseline_pane_is_opened_before_the_split() {
575        use lattice_grammar::Effect;
576        let effect = side_by_side_effects(
577            "lattice",
578            "staged",
579            std::path::Path::new("src/main.rs"),
580            std::path::PathBuf::from("/repo/src/main.rs"),
581        );
582        let Effect::Many(effects) = effect else {
583            panic!("expected a two-effect sequence, got {effect:?}");
584        };
585        assert_eq!(effects.len(), 2);
586        match &effects[0] {
587            Effect::OpenSyntheticBuffer { name, mode_id, .. } => {
588                assert_eq!(name, "*magit:file:lattice:staged:src/main.rs*");
589                assert_eq!(mode_id, "magit-file-revision-mode");
590            }
591            other => panic!("the baseline must be opened first, got {other:?}"),
592        }
593        match &effects[1] {
594            Effect::Diffsplit { path, remote } => {
595                assert_eq!(path, std::path::Path::new("/repo/src/main.rs"));
596                assert!(remote.is_none(), "two-way, not a three-way merge");
597            }
598            other => panic!("the split must come second, got {other:?}"),
599        }
600    }
601
602    /// `dv` is bound, and in the `d`-prefixed family `diff-mode`
603    /// already owns — so it cannot collide with `do` / `dp`, which the
604    /// same buffer gets once the session is live.
605    ///
606    /// MG.49b: this survived the root-menu work. The first cut bound `d`
607    /// on `magit-core-mode`, which would have made `dv` unreachable (the
608    /// trie checks a node's own binding before its children); that cut
609    /// was reverted in favour of one `h` for the dispatch, so `d` is a
610    /// free prefix again.
611    #[test]
612    fn dv_is_bound_and_does_not_shadow_the_diff_mode_chords() {
613        let chords: Vec<&str> = magit_hunk_keymap_entries()
614            .iter()
615            .map(|e| e.chord)
616            .collect();
617        assert!(chords.contains(&"dv"), "{chords:?}");
618        for owned_by_diff_mode in ["do", "dp"] {
619            assert!(
620                !chords.contains(&owned_by_diff_mode),
621                "`{owned_by_diff_mode}` belongs to diff-mode; magit must not \
622                 rebind it: {chords:?}"
623            );
624        }
625    }
626
627    /// `]f` / `[f` live here, not on `magit-core-mode`.
628    ///
629    /// On core they were bound across all ten majors and meant
630    /// something in one. In the list views they jumped between rows
631    /// while claiming to move between files — a job `j` and `]]`
632    /// already do. In the diff views they matched indented CONTEXT
633    /// lines, so they walked through arbitrary code. In the rebase
634    /// todo, whose rows sit at column 0, they matched nothing.
635    #[test]
636    fn file_navigation_is_bound_here_and_not_on_magit_core() {
637        use lattice_mode::Mode;
638        let hunk: Vec<&str> = magit_hunk_keymap_entries()
639            .iter()
640            .map(|e| e.chord)
641            .collect();
642        for c in ["]f", "[f"] {
643            assert!(hunk.contains(&c), "`{c}` must be bound here: {hunk:?}");
644        }
645        let core: Vec<&str> = crate::MagitCoreMode
646            .keymap()
647            .entries
648            .iter()
649            .map(|e| e.chord)
650            .collect();
651        for c in ["]f", "[f"] {
652            assert!(
653                !core.contains(&c),
654                "`{c}` must NOT still be on magit-core-mode, where it is bound \
655                 on majors that have no files: {core:?}"
656            );
657        }
658    }
659
660    /// The five majors that show diff content, and no others.
661    ///
662    /// Both halves matter. Missing one leaves that buffer without the
663    /// staging chords — the state magit-commit, magit-revision and
664    /// magit-stash-show were in. Adding one that shows no diff puts the
665    /// keys back where they are consumed to do nothing, which is what
666    /// `]c` and `a`/`-` did on `magit-core-mode`.
667    #[test]
668    fn activates_on_exactly_the_diff_showing_majors() {
669        let ActivationPolicy::Majors(majors) = MagitHunkMode.activation_policy() else {
670            panic!("magit-hunk-mode activates by major");
671        };
672        let ids: Vec<String> = majors.iter().map(|m| m.as_str().to_string()).collect();
673        assert_eq!(
674            ids,
675            [
676                "magit-status-mode",
677                "magit-diff-mode",
678                "magit-commit-mode",
679                "magit-revision-mode",
680                "magit-stash-show-mode",
681            ]
682        );
683        for absent in [
684            "magit-log-mode",
685            "magit-branch-mode",
686            "magit-stash-mode",
687            "magit-rebase-mode",
688            "magit-blame-mode",
689            "magit-file-revision-mode",
690        ] {
691            assert!(
692                !ids.iter().any(|i| i == absent),
693                "`{absent}` renders no diff — binding hunk chords there \
694                 consumes them to do nothing"
695            );
696        }
697    }
698
699    /// Every chord bound in VISUAL must act through a handler that collapses
700    /// the selection when it finishes.
701    ///
702    /// The rule itself lives in `magit_core_mode::consuming_selection` and is
703    /// applied at registration, so nothing about a keymap entry can prove a
704    /// given handler was wrapped. What this pins instead is the list: a chord
705    /// bound in Visual is, by definition, one that acts on a selection, so its
706    /// action has to appear here — and adding a sixth content chord without
707    /// wrapping it fails this test naming the chord.
708    ///
709    /// A list rather than introspection because a closure cannot be asked what
710    /// it wraps. The cost is that the list is maintained by hand; the
711    /// alternative is no guard at all, and `magit-diff-mode`'s missing `x`
712    /// already showed what a gap in a hand-copied set looks like — invisible
713    /// until someone reaches for the key.
714    #[test]
715    fn every_content_chord_collapses_the_selection() {
716        /// Actions registered through `consuming_selection`. Keep in step with
717        /// the registration sites in `magit_core_mode.rs` (stage / unstage /
718        /// apply / reverse) and `actions.rs` (the three discard executes).
719        const WRAPPED: &[&str] = &[
720            "action:magit-stage",
721            "action:magit-unstage",
722            "action:magit-apply-hunk",
723            "action:magit-reverse-hunk",
724            // `x` itself is deliberately NOT wrapped: over a selection it
725            // returns `Effect::Confirm` and has not acted yet. Its three
726            // execute halves carry the collapse instead, so it lands when the
727            // discard does rather than when the question is asked.
728            "action:magit-discard",
729        ];
730
731        for entry in magit_hunk_keymap_entries() {
732            if !format!("{:?}", entry.modes).contains("Visual") {
733                continue;
734            }
735            let Some(cmd) = entry.command else { continue };
736            assert!(
737                WRAPPED.contains(&cmd),
738                "`{}` is bound in Visual but `{cmd}` is not in the \
739                 selection-collapsing set — a chord that acts on a selection \
740                 and leaves it live outlives the rows it referred to, over a \
741                 buffer its own refresh just rebuilt",
742                entry.chord
743            );
744        }
745    }
746
747    /// Every chord that acts on a hunk, in both the modes that can
748    /// reach it. A Normal-only binding would leave MG.18e's region
749    /// staging unreachable by its own documented gesture.
750    #[test]
751    fn the_staging_chords_are_bound_in_normal_and_visual() {
752        let entries = magit_hunk_keymap_entries();
753        for chord in ["s", "u", "x"] {
754            for mode in ["Normal", "Visual"] {
755                assert!(
756                    entries
757                        .iter()
758                        .any(|e| e.chord == chord && format!("{:?}", e.modes).contains(mode)),
759                    "`{chord}` must be bound in {mode}"
760                );
761            }
762        }
763    }
764
765    /// `<CR>` is here now that `diff_target` exists — and the handler
766    /// must ask the **view first**.
767    ///
768    /// A minor's binding wins over a major's, so without the fallback
769    /// magit-status's context-aware visit (file entry / stash /
770    /// commit rows) is silently replaced by a diff-only handler. Worse,
771    /// resolving the diff path first would scan upward past a
772    /// *previous* entry's expanded inline diff and open the wrong
773    /// file — see this module's header for the layout that makes that
774    /// happen.
775    #[test]
776    fn cr_is_bound_and_asks_the_view_before_the_diff_text() {
777        assert!(
778            magit_hunk_keymap_entries()
779                .iter()
780                .any(|e| e.chord == "<CR>"),
781            "`<CR>` belongs to the mode that owns diff content"
782        );
783        let src = include_str!("magit_hunk_mode.rs");
784        let view_first = src.find("view.visit_at_cursor(ctx.cursor)");
785        let path_after = src.find("path_at_cursor(");
786        assert!(
787            matches!((view_first, path_after), (Some(v), Some(p)) if v < p),
788            "the handler must consult `visit_at_cursor` BEFORE resolving \
789             a diff path — the other order opens the wrong file in \
790             magit-status whenever an earlier entry is expanded"
791        );
792    }
793}
794
795#[cfg(test)]
796mod at_position_tests {
797    use super::at_position;
798    use crate::hunk::SourcePos;
799    use lattice_grammar::Effect;
800
801    const POS: SourcePos = SourcePos { line: 41, byte: 12 };
802
803    /// MG.50: an open becomes an open-AT, carrying BOTH axes.
804    ///
805    /// Both effect shapes matter: a working-tree file (`OpenBuffer`) and
806    /// a blob (`OpenSyntheticBuffer`) are the two things `diff_target`
807    /// returns, and magit-status produces one of each depending on which
808    /// section the cursor sits under.
809    ///
810    /// The byte assertions are the point of this revision: the effect
811    /// used to be built with a hardcoded `Position::new(line, 0)`, so
812    /// every visit landed at the start of the line however far along the
813    /// row the cursor had been.
814    #[test]
815    fn both_open_shapes_carry_the_line_and_the_offset() {
816        match at_position(
817            Effect::OpenBuffer {
818                path: Some("/repo/src/main.rs".into()),
819                force: false,
820            },
821            POS,
822        ) {
823            Effect::OpenBufferAt { position, .. } => {
824                assert_eq!(position.line, POS.line);
825                assert_eq!(position.byte, POS.byte);
826            }
827            other => panic!("a working-tree open must position: {other:?}"),
828        }
829        match at_position(
830            Effect::OpenSyntheticBuffer {
831                name: "*magit:file:staged:src/main.rs*".into(),
832                mode_id: "magit-file-revision-mode".into(),
833                content: None,
834                cursor: None,
835                activate_minor: None,
836            },
837            POS,
838        ) {
839            Effect::OpenSyntheticBufferAt {
840                position, mode_id, ..
841            } => {
842                assert_eq!(position.line, POS.line);
843                assert_eq!(position.byte, POS.byte);
844                assert_eq!(mode_id, "magit-file-revision-mode");
845            }
846            other => panic!("a blob open must position: {other:?}"),
847        }
848    }
849
850    /// An effect with nothing to position passes through untouched —
851    /// a refusal must not be silently turned into an open.
852    #[test]
853    fn an_effect_with_nothing_to_position_is_unchanged() {
854        let echo = Effect::Echo {
855            level: lattice_grammar::EchoLevel::Warn,
856            text: "no working-tree copy".into(),
857        };
858        assert!(matches!(at_position(echo, POS), Effect::Echo { .. }));
859    }
860}
861
862/// **The selection audit.** Every chord this mode contributes that acts on
863/// CONTENT must be reachable in Visual, because a selection is the only way
864/// to name "these lines" and Visual is the only place a selection exists.
865///
866/// Written as a table over the whole keymap rather than a test per chord, so
867/// a chord added later is classified by its author or fails here — the
868/// failure mode this guards is a new content action that silently ignores a
869/// selection, which no per-chord test can notice because it does not exist
870/// yet.
871#[cfg(test)]
872mod selection_audit {
873    use super::*;
874    use lattice_keymap::BindingMode;
875
876    /// Chords that act on the content under the cursor, and so must offer a
877    /// Visual peer. The `region_of` / `*_rows` machinery already restricts
878    /// each of these to a selection; the binding is what makes it reachable.
879    const CONTENT_CHORDS: &[&str] = &["s", "u", "x", "a", "-"];
880
881    /// Chords that are deliberately single-target. Listed rather than merely
882    /// absent so the reason travels with them:
883    ///
884    /// - `<CR>` visits the file at cursor. A selection of five files would
885    ///   mean five buffers from one keypress; emacs magit opens one, and so
886    ///   does every other lattice view.
887    /// - `]c` / `[c` / `]f` / `[f` are motions. A motion that consumed a
888    ///   selection would be moving and selecting at once.
889    const SINGLE_TARGET_CHORDS: &[&str] = &["<CR>", "]c", "[c", "]f", "[f"];
890
891    fn chords_for(mode: BindingMode) -> Vec<String> {
892        magit_hunk_keymap_entries()
893            .iter()
894            .filter(|e| e.modes.contains(&mode))
895            .map(|e| e.chord.to_string())
896            .collect()
897    }
898
899    #[test]
900    fn every_content_chord_has_a_visual_peer() {
901        let visual = chords_for(BindingMode::Visual);
902        for chord in CONTENT_CHORDS {
903            assert!(
904                visual.iter().any(|c| c == chord),
905                "`{chord}` acts on content but has no Visual binding, so it \
906                 can never be pressed with a selection — its region path is \
907                 unreachable. Visual chords present: {visual:?}"
908            );
909        }
910    }
911
912    /// And each of those is ALSO bound in Normal, because acting on the
913    /// cursor's hunk without selecting first is the common case.
914    #[test]
915    fn every_content_chord_still_works_from_normal() {
916        let normal = chords_for(BindingMode::Normal);
917        for chord in CONTENT_CHORDS {
918            assert!(
919                normal.iter().any(|c| c == chord),
920                "`{chord}` lost its Normal binding: {normal:?}"
921            );
922        }
923    }
924
925    /// The deliberate singles must NOT gain a Visual binding by accident —
926    /// a `<CR>` that opened one buffer per selected row would be a surprise
927    /// delivered by a keypress the user has pressed a thousand times.
928    #[test]
929    fn single_target_chords_stay_out_of_visual() {
930        let visual = chords_for(BindingMode::Visual);
931        for chord in SINGLE_TARGET_CHORDS {
932            assert!(
933                !visual.iter().any(|c| c == chord),
934                "`{chord}` is documented as single-target but is now bound in \
935                 Visual. If that is intended, move it to CONTENT_CHORDS and \
936                 say why here."
937            );
938        }
939    }
940
941    /// Nothing is bound in Visual that is not accounted for above. This is
942    /// the half that catches a NEW chord: adding one to Visual without
943    /// classifying it fails here rather than shipping unclassified.
944    #[test]
945    fn every_visual_chord_is_classified() {
946        for chord in chords_for(BindingMode::Visual) {
947            assert!(
948                CONTENT_CHORDS.contains(&chord.as_str()),
949                "`{chord}` is bound in Visual but is not in CONTENT_CHORDS — \
950                 add it there (and make sure its handler reads \
951                 `ctx.selection`), or do not bind it in Visual."
952            );
953        }
954    }
955
956    /// `v` / `V` / `<C-v>` are never bound in a magit buffer — they are how
957    /// the user MAKES a selection, and a mode that claimed them would take
958    /// away the thing every chord above depends on.
959    #[test]
960    fn the_keys_that_start_a_selection_are_never_claimed() {
961        for chord in magit_hunk_keymap_entries().iter().map(|e| e.chord) {
962            assert!(
963                !matches!(chord, "v" | "V" | "<C-v>"),
964                "`{chord}` starts a Visual selection and must stay unbound"
965            );
966        }
967    }
968}