Skip to main content

lattice_magit/
magit_status_mode.rs

1//! MG.2-3: magit-status major mode.
2//!
3//! Creates the `*magit:status*` buffer, spawns a refresh task on
4//! `spawn_blocking`, registers per-buffer action handlers.
5
6use std::sync::OnceLock;
7
8use lattice_config;
9use lattice_core::FoldOverlayServiceHandle;
10use lattice_mode::{
11    BufferStoreHandle, CapabilitySet, Keymap, KeymapEntry, LifecycleFuture, Mode, ModeContext,
12    ModeId, ModeKind, OptionOverrideSet, keymap_entry,
13};
14
15use crate::actions;
16use crate::fold_source::MagitStatusFoldSource;
17use crate::refresh;
18
19pub struct MagitStatusMode;
20
21impl MagitStatusMode {
22    pub fn mode_id() -> ModeId {
23        ModeId::new("magit-status-mode")
24    }
25}
26
27fn magit_status_keymap_entries() -> &'static [KeymapEntry] {
28    static ENTRIES: OnceLock<Vec<KeymapEntry>> = OnceLock::new();
29    ENTRIES.get_or_init(|| {
30        vec![
31            // MG.18e: the same three actions on the selected lines.
32            // Emacs magit's most-used partial-stage path, and vim's own
33            // Visual-mode operator convention — the region is the
34            // operand. The Normal binding is unaffected: the handler
35            // sees `ctx.selection` only when one is live.
36            //
37            // In an editable buffer Visual `s` would be substitute and
38            // `x` delete; a magit buffer is read-only, so both slots are
39            // free and the muscle memory transfers from Normal mode.
40            keymap_entry! { mode: Normal, chord: "cc", doc: "Open commit buffer", cmd: "action:magit-commit" },
41            keymap_entry! { mode: Normal, chord: "ca", doc: "Amend previous commit", cmd: "action:magit-commit-amend" },
42            keymap_entry! { mode: Normal, chord: "=", doc: "Toggle inline diff at cursor", cmd: "action:magit-toggle-diff" },
43            // MG.49c: `dd`, not bare `d`.
44            //
45            // Bare `d` here was a PLAIN action, so the trie terminated at
46            // it — and a node's own binding is checked before its
47            // children, which made `magit-hunk-mode`'s `dv` unreachable
48            // in this buffer and only this buffer. (In magit-diff and its
49            // peers no major binds `d`, so the node keeps vim's `d`
50            // OPERATOR binding, which short-circuits into operator-pending
51            // instead of terminating — which is why `dv` worked there.)
52            //
53            // `dd` restores that shape: `d` stays the operator, and both
54            // `dd` and `dv` resolve as two-chord sequences under it.
55            // Vim's own `dd` is delete-line, inert in a read-only buffer.
56            keymap_entry! { mode: Normal, chord: "dd", doc: "Open file diff in a dedicated buffer", cmd: "action:magit-diff-file" },
57            keymap_entry! { mode: Normal, chord: "p", doc: "Stage hunk interactively", cmd: "action:magit-stage-patch" },
58            // MG.49b: these are back. The first cut of MG.49 removed them
59            // because `magit-core-mode` had taken `c` / `d` / `p` for root
60            // menus and the trie checks a node's own binding before its
61            // children — a bound `c` makes `cc` unreachable, not shadowed.
62            //
63            // That cut is reverted: one chord (`h`) opens the dispatch
64            // instead, so these prefixes are free again and the status
65            // buffer keeps the chords it always had.
66        ]
67    })
68}
69
70#[derive(Default)]
71pub struct MagitStatusGuard {
72    /// MG.13: unpublishes this buffer's state on deactivation.
73    states: Option<(actions::StatusStatesHandle, lattice_core::BufferId)>,
74    _state: Option<std::sync::Arc<std::sync::Mutex<actions::StatusBufferState>>>,
75    /// Fold-audit fix: deregisters the buffer's `MagitStatusFoldSource`
76    /// on deactivation — same Drop-based lifecycle
77    /// `DiffModeGuard`/`MultibufferModeGuard` use.
78    fold_registration: Option<(FoldOverlayServiceHandle, lattice_core::ProviderId)>,
79    /// MG.13: unpublishes this buffer's `MagitView` on deactivation so
80    /// `gr` cannot resolve against a dead status buffer.
81    views: Option<(
82        crate::buffer_state::MagitViewsHandle,
83        lattice_core::BufferId,
84    )>,
85    /// MG.14: the headerline provider registration. Its own `Drop`
86    /// unregisters the sticky row when the mode deactivates.
87    _headerline: Option<crate::headerline::HeaderlineRegistration>,
88}
89
90impl Drop for MagitStatusGuard {
91    fn drop(&mut self) {
92        if let Some((states, buffer)) = self.states.take() {
93            states.remove(buffer);
94        }
95        if let Some((views, buffer)) = self.views.take() {
96            views.remove(buffer);
97        }
98        if let Some((svc, id)) = self.fold_registration.take() {
99            svc.remove_source(id);
100        }
101    }
102}
103
104impl Mode for MagitStatusMode {
105    type Guard = MagitStatusGuard;
106
107    fn id(&self) -> ModeId {
108        Self::mode_id()
109    }
110    fn kind(&self) -> ModeKind {
111        ModeKind::Major
112    }
113    fn target_buffer_kind(&self) -> Option<lattice_core::BufferKind> {
114        None
115    }
116
117    fn options(&self) -> OptionOverrideSet {
118        lattice_config::overrides! {
119            lattice_config::ReadOnly = true,
120            lattice_config::NoFile = true,
121            lattice_config::Number = false,
122            lattice_config::SignColumnOption = lattice_config::SignColumn::Yes,
123            lattice_config::CursorLine = false,
124        }
125    }
126
127    /// MG.RO: `read-only-mode` is where the gate actually is.
128    ///
129    /// `ReadOnly = true` above stops TYPING and nothing else. It is read by
130    /// `read_only_edit_rejected`, which guards the insert-mode char path;
131    /// operators never reach it, because a `Document`'s grammar dispatch
132    /// applies its own edits and hands the host an already-applied
133    /// `Effect::Edits`. `x` deleted a character out of `*magit:status*` while
134    /// the buffer reported itself read-only — worse than not gating at all,
135    /// because it looks protected.
136    ///
137    /// `read-only-mode` carries the option AND the `invocation_runner`
138    /// (`Editor::run_read_only_motion`) that refuses mutating operators while
139    /// letting motions, `:` and `/` through.
140    ///
141    /// Declared per MAJOR rather than once on `magit-core-mode`: an implied
142    /// mode is followed from the mode being ACTIVATED, and the majors are what
143    /// the host activates. Putting it on the shared minor looked right and was
144    /// verified not to fire.
145    fn implies(&self) -> &[lattice_mode::ModeId] {
146        static IMPLIED: std::sync::OnceLock<Vec<lattice_mode::ModeId>> = std::sync::OnceLock::new();
147        IMPLIED.get_or_init(|| vec![lattice_mode::modes::ReadOnlyMode::mode_id()])
148    }
149
150    fn required_capabilities(&self) -> CapabilitySet {
151        CapabilitySet::empty()
152    }
153    fn keymap(&self) -> Keymap {
154        Keymap::from_entries(magit_status_keymap_entries())
155    }
156
157    /// MG.13: registered once at boot; see `actions::status_action_handlers`.
158    fn action_handlers(&self) -> Vec<lattice_mode::ActionHandlerContribution> {
159        actions::status_action_handlers()
160    }
161
162    fn on_activate(&self, ctx: ModeContext) -> LifecycleFuture<'_, Self::Guard> {
163        Box::pin(async move {
164            let buffer_id = lattice_core::BufferId(ctx.buffer_id().0 as u32);
165            let Some(store) = ctx.service::<BufferStoreHandle>() else {
166                return Ok(MagitStatusGuard::default());
167            };
168            let Some(handle) = store.handle_for(buffer_id) else {
169                return Ok(MagitStatusGuard::default());
170            };
171            let Ok(runtime) = tokio::runtime::Handle::try_current() else {
172                return Ok(MagitStatusGuard::default());
173            };
174
175            // MR.2/MR.3: the repository this buffer acts on, as the
176            // trigger resolved it — `on_activate` cannot see what the
177            // trigger saw, which is the whole reason the record exists.
178            // Through the shared helper every view uses, so the read and
179            // the document-index cannot come apart per view.
180            let workdir = match crate::repo_scope::view_workdir(&ctx, buffer_id, &handle) {
181                Some(w) => w,
182                None => {
183                    let _ = handle
184                        .apply_edit_batch(vec![lattice_protocol::edit::Edit::replace(
185                            lattice_protocol::position::Range::new(
186                                lattice_protocol::position::Position::ZERO,
187                                lattice_protocol::position::Position::new(0, 0),
188                            ),
189                            "Not a git repository.\n".to_string(),
190                        )])
191                        .await;
192                    return Ok(MagitStatusGuard::default());
193                }
194            };
195
196            tracing::debug!(
197                target: "lattice_magit",
198                "magit-status-mode activated on buffer {buffer_id:?}, workdir={workdir:?}",
199            );
200
201            let pending = ctx.service::<lattice_mode::PendingSyntheticHighlights>();
202
203            // MG.14: the header this buffer never had — branch,
204            // ahead/behind, repo, dirty counts. Installed in the same
205            // synchronous prefix as the state publish; it renders
206            // nothing until the first refresh below lands.
207            let (hl, hl_registration) =
208                match crate::headerline::install(&ctx, buffer_id, Self::mode_id().as_str()) {
209                    Some((h, reg)) => (Some(h), Some(reg)),
210                    None => (None, None),
211                };
212
213            // MG.13: build and publish BEFORE the initial refresh's
214            // `.await`. Every field here is synchronous, so this lands
215            // during `spawn_cascade`'s first synchronous poll — before
216            // `activate_major` returns — which is what makes the
217            // boot-registered handlers reachable on the very next
218            // keystroke. Publishing after the refresh below would leave
219            // `x` / `=` / `<CR>` dead for the width of a `git status`,
220            // which is the longest window of any magit view.
221            let shared_state =
222                std::sync::Arc::new(std::sync::Mutex::new(actions::StatusBufferState {
223                    buffer_id,
224                    store: store.clone(),
225                    workdir: workdir.clone(),
226                    runtime: runtime.clone(),
227                    pending_highlights: pending.clone(),
228                    expanded: std::collections::HashMap::new(),
229                    headerline: hl.clone(),
230                    config: ctx
231                        .service::<std::sync::Arc<lattice_config::ConfigRegistry>>()
232                        .map(|outer| (*outer).clone()),
233                    pending_cursor: None,
234                    // MG.18d: the wake-baked bus a post-mutation cursor
235                    // goes back on. `None` in a harness without the
236                    // service — the refresh still works, it just does
237                    // not move the cursor.
238                    cursor_bus: ctx
239                        .service::<crate::cursor_restore::CursorBusHandle>()
240                        .map(|outer| (*outer).clone()),
241                    // DS.3: the grammar registry for diff syntax. Same
242                    // service the file-revision view already reads.
243                    lang_registry: ctx
244                        .service::<std::sync::Arc<lattice_syntax::LangRegistry>>()
245                        .map(|outer| (*outer).clone()),
246                }));
247            if let Some(states) = ctx.service::<actions::StatusStatesHandle>() {
248                states.publish_shared(buffer_id, shared_state.clone());
249            }
250
251            // Initial refresh: blocking I/O on spawn_blocking, then async
252            // edit apply + highlights on the current task.
253            {
254                let wd = workdir.clone();
255                // Nothing is expanded on a fresh buffer, so the
256                // open-entry set is empty and the rebuilt bookkeeping
257                // it returns is too.
258                let context = actions::context_lines(
259                    &ctx.service::<std::sync::Arc<lattice_config::ConfigRegistry>>()
260                        .map(|outer| (*outer).clone()),
261                );
262                // DS-fix (2026-08-12): the initial build highlights
263                // through the same gate the `=` toggle and the refresh
264                // use, so a status buffer looks the same however it got
265                // rendered.
266                let lang_registry = crate::hunk_syntax::syntax_registry(
267                    ctx.service::<std::sync::Arc<lattice_syntax::LangRegistry>>()
268                        .map(|outer| (*outer).clone()),
269                    ctx.service::<std::sync::Arc<lattice_config::ConfigRegistry>>()
270                        .map(|outer| (*outer).clone())
271                        .as_ref(),
272                );
273                let (text, spans, header, _, refine) = tokio::task::spawn_blocking(move || {
274                    refresh::build_and_format(
275                        &wd,
276                        &std::collections::HashSet::new(),
277                        context,
278                        lang_registry.as_ref(),
279                    )
280                })
281                .await
282                .expect("spawn_blocking");
283                crate::headerline::publish(&hl, header);
284                refresh::apply_and_highlight_refined(
285                    handle.clone(),
286                    text,
287                    spans,
288                    refine,
289                    pending.clone(),
290                    buffer_id,
291                )
292                .await;
293            }
294
295            // Fold-audit fix: register the nested file>hunk fold
296            // source for this buffer's inline expansions.
297            let fold_registration = ctx
298                .service::<FoldOverlayServiceHandle>()
299                .map(|outer| (*outer).clone())
300                .map(|svc| {
301                    let source = std::sync::Arc::new(MagitStatusFoldSource::new(
302                        shared_state.clone(),
303                        buffer_id,
304                    ));
305                    let id = svc.add_source(source, buffer_id);
306                    (svc, id)
307                });
308
309            // MG.13: publish this buffer's `MagitView` so `gr` — now
310            // registered once at boot by `magit-core-mode` — reaches
311            // the status buffer's own refresh body. See
312            // `buffer_state::MagitView`.
313            let views = ctx
314                .service::<crate::buffer_state::MagitViewsHandle>()
315                .map(|v| (*v).clone());
316            if let Some(ref v) = views {
317                v.publish(
318                    buffer_id,
319                    std::sync::Arc::new(actions::StatusView(shared_state.clone())),
320                );
321            }
322
323            Ok(MagitStatusGuard {
324                states: ctx
325                    .service::<actions::StatusStatesHandle>()
326                    .map(|st| ((*st).clone(), buffer_id)),
327                _state: Some(shared_state),
328                fold_registration,
329                views: views.map(|v| (v, buffer_id)),
330                _headerline: hl_registration,
331            })
332        })
333    }
334}