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}