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}