Skip to main content

lattice_magit/
magit_log_mode.rs

1//! MG.6: magit-log major mode.
2//!
3//! Runs `git log --oneline --graph --decorate -50` on open,
4//! populates buffer content. <CR> shows commit detail.
5//!
6//! `*magit:log:<path>*` scopes the log to one file's history
7//! (`git log -- <path>`) — the target of `C-c f l` in the file
8//! dispatch transient, mirroring `magit-blame`/`magit-diff`'s
9//! path-in-buffer-name pattern. Bare `*magit:log*` stays repo-wide.
10
11use std::sync::{Arc, Mutex, OnceLock};
12
13use lattice_config;
14use lattice_grammar::Effect;
15use lattice_mode::{
16    ActionContext, ActionHandlerContribution, BufferStoreHandle, CapabilitySet, Keymap,
17    KeymapEntry, LifecycleFuture, Mode, ModeContext, ModeId, ModeKind, OptionOverrideSet,
18    keymap_entry,
19};
20use lattice_protocol::position::Position;
21
22use crate::buffer_state::{BufferStateGuard, BufferStates, MagitView, MagitViewsHandle};
23use crate::headerline::{self, MagitHeaderlineHandle};
24
25pub struct MagitLogMode;
26
27impl MagitLogMode {
28    pub fn mode_id() -> ModeId {
29        ModeId::new("magit-log-mode")
30    }
31}
32
33fn magit_log_keymap_entries() -> &'static [KeymapEntry] {
34    static ENTRIES: OnceLock<Vec<KeymapEntry>> = OnceLock::new();
35    ENTRIES.get_or_init(|| {
36        vec![
37            keymap_entry! { mode: Normal, chord: "<CR>", doc: "Show commit detail at cursor", cmd: "action:magit-log-show-commit" },
38        ]
39    })
40}
41
42pub struct LogState {
43    buffer_id: lattice_core::BufferId,
44    store: Arc<BufferStoreHandle>,
45    workdir: std::path::PathBuf,
46    /// `Some` when this buffer is scoped to one file's history
47    /// (opened as `*magit:log:<path>*`); `None` for the repo-wide
48    /// `*magit:log*`.
49    path: Option<std::path::PathBuf>,
50    pending_highlights: Option<lattice_mode::PendingSyntheticHighlightsHandle>,
51    /// MG.14: the buffer's headerline. Re-set on every refresh from
52    /// the same `run_log` output that produced the text.
53    headerline: Option<MagitHeaderlineHandle>,
54    /// MG.23k: extra git arguments the `D` menu set, replayed on every
55    /// refresh so `gr` does not silently revert to the default log.
56    extra_args: Vec<String>,
57}
58
59/// MG.13: service alias for this mode's per-buffer state
60/// (`feedback_servicesregistry_arc_typeid`).
61pub type LogStatesHandle = Arc<BufferStates<LogState>>;
62
63fn state(ctx: &ActionContext<'_>) -> Option<Arc<Mutex<LogState>>> {
64    crate::buffer_state::state_for::<LogState>(ctx)
65}
66
67impl Mode for MagitLogMode {
68    type Guard = BufferStateGuard<LogState>;
69
70    fn id(&self) -> ModeId {
71        Self::mode_id()
72    }
73    fn kind(&self) -> ModeKind {
74        ModeKind::Major
75    }
76    fn target_buffer_kind(&self) -> Option<lattice_core::BufferKind> {
77        None
78    }
79
80    fn options(&self) -> OptionOverrideSet {
81        lattice_config::overrides! {
82            lattice_config::ReadOnly = true,
83            lattice_config::NoFile = true,
84            lattice_config::Number = false,
85        }
86    }
87
88    /// MG.RO: `read-only-mode` is where the gate actually is.
89    ///
90    /// `ReadOnly = true` above stops TYPING and nothing else. It is read by
91    /// `read_only_edit_rejected`, which guards the insert-mode char path;
92    /// operators never reach it, because a `Document`'s grammar dispatch
93    /// applies its own edits and hands the host an already-applied
94    /// `Effect::Edits`. `x` deleted a character out of `*magit:status*` while
95    /// the buffer reported itself read-only — worse than not gating at all,
96    /// because it looks protected.
97    ///
98    /// `read-only-mode` carries the option AND the `invocation_runner`
99    /// (`Editor::run_read_only_motion`) that refuses mutating operators while
100    /// letting motions, `:` and `/` through.
101    ///
102    /// Declared per MAJOR rather than once on `magit-core-mode`: an implied
103    /// mode is followed from the mode being ACTIVATED, and the majors are what
104    /// the host activates. Putting it on the shared minor looked right and was
105    /// verified not to fire.
106    fn implies(&self) -> &[lattice_mode::ModeId] {
107        static IMPLIED: std::sync::OnceLock<Vec<lattice_mode::ModeId>> = std::sync::OnceLock::new();
108        IMPLIED.get_or_init(|| vec![lattice_mode::modes::ReadOnlyMode::mode_id()])
109    }
110
111    fn required_capabilities(&self) -> CapabilitySet {
112        CapabilitySet::empty()
113    }
114    fn keymap(&self) -> Keymap {
115        Keymap::from_entries(magit_log_keymap_entries())
116    }
117
118    /// MG.13: boot-registered — see `buffer_state`'s module docs. `gr`
119    /// is NOT here: it is a shared action owned by `magit-core-mode`
120    /// and reached through this mode's `MagitView` below.
121    fn action_handlers(&self) -> Vec<ActionHandlerContribution> {
122        vec![
123            // <CR> — show commit detail for the SHA at cursor.
124            ActionHandlerContribution {
125                action_name: "action:magit-log-show-commit",
126                handler: Arc::new(|ctx: &ActionContext<'_>| {
127                    let s = state(ctx)?;
128                    let g = s.lock().ok()?;
129                    let handle = g.store.handle_for(g.buffer_id)?;
130                    let snap = handle.snapshot();
131                    let line = snap.buffer.line(ctx.cursor.line)?;
132                    let sha = extract_sha(&line)?;
133                    Some(crate::magit_global_mode::open_repo_view_from_action_with(
134                        ctx,
135                        crate::magit_revision_mode::SHOW_VIEW,
136                        "magit-revision-mode",
137                        Some(sha),
138                    ))
139                }),
140            },
141        ]
142    }
143
144    fn on_activate(&self, ctx: ModeContext) -> LifecycleFuture<'_, Self::Guard> {
145        Box::pin(async move {
146            let buffer_id = lattice_core::BufferId(ctx.buffer_id().0 as u32);
147            let orphan = || BufferStateGuard::new(Arc::new(BufferStates::default()), buffer_id);
148            let Some(store) = ctx.service::<BufferStoreHandle>() else {
149                return Ok(orphan());
150            };
151            let Some(handle) = store.handle_for(buffer_id) else {
152                return Ok(orphan());
153            };
154            // MR.3: the repository the trigger resolved for THIS
155            // buffer, not the one the editor was started in.
156            let workdir =
157                crate::repo_scope::view_workdir(&ctx, buffer_id, &handle).unwrap_or_default();
158
159            // MR.3b: `*magit:log:<repo>:<path>*` scopes the history to
160            // one file; `*magit:log:<repo>*` stays repo-wide. The path
161            // is this view's `rest` — read through the shared parser, so
162            // a repository whose label looks like a path cannot be
163            // mistaken for one.
164            let buffer_name = store.name_for(buffer_id).unwrap_or_default();
165            let path: Option<std::path::PathBuf> = crate::workdir::parse_magit_name(&buffer_name)
166                .and_then(|n| n.rest)
167                .map(std::path::PathBuf::from);
168
169            // MG.43h: arguments the `l` dispatch row collected before
170            // this buffer existed — see `ViewArgsRequests`.
171            let requested_args = ctx
172                .service::<crate::magit_diff_mode::ViewArgsRequestsHandle>()
173                .and_then(|r| r.take(&buffer_name))
174                .unwrap_or_default();
175
176            let pending_highlights = ctx.service::<lattice_mode::PendingSyntheticHighlights>();
177
178            // MG.14: install the headerline in the same synchronous
179            // prefix as the state publish. It renders nothing until
180            // the log lands below; installing here means a reopened
181            // buffer never inherits the previous activation's row.
182            let (hl, hl_registration) =
183                match headerline::install(&ctx, buffer_id, Self::mode_id().as_str()) {
184                    Some((h, reg)) => (Some(h), Some(reg)),
185                    None => (None, None),
186                };
187
188            // MG.13: publish BEFORE the first `.await` — see the note
189            // in `magit_branch_mode::on_activate`.
190            let Some(states) = ctx.service::<LogStatesHandle>() else {
191                return Ok(orphan());
192            };
193            let state = states.publish(
194                buffer_id,
195                LogState {
196                    buffer_id,
197                    path: path.clone(),
198                    store: store.clone(),
199                    workdir: workdir.clone(),
200                    pending_highlights: pending_highlights.clone(),
201                    headerline: hl.clone(),
202                    extra_args: requested_args.clone(),
203                },
204            );
205            let mut guard = BufferStateGuard::new((*states).clone(), buffer_id)
206                .with_headerline(hl_registration);
207            if let Some(views) = ctx.service::<MagitViewsHandle>() {
208                views.publish(buffer_id, Arc::new(LogView(state.clone())));
209                guard = guard.with_views((*views).clone());
210            }
211
212            // Populate log: blocking I/O on spawn_blocking, then apply
213            // edit on the current task (no Runtime::new()).
214            let wd = workdir.clone();
215            let path_for_task = path.clone();
216            let text =
217                tokio::task::spawn_blocking(move || run_log(&wd, path_for_task.as_deref(), &[]))
218                    .await
219                    .unwrap();
220            headerline::publish(&hl, log_header_fields(&text, path.as_deref()));
221            let spans = crate::highlight::log_styled_spans(&text);
222            crate::buffer_io::replace_buffer_text(&handle, text).await;
223            if let Some(ref ph) = pending_highlights {
224                ph.store_and_wake(buffer_id, spans);
225            }
226
227            Ok(guard)
228        })
229    }
230}
231
232fn refresh(s: Arc<Mutex<LogState>>) -> Option<Effect> {
233    let (handle, wd, path, pending, buffer_id, hl, extra) = {
234        let g = s.lock().ok()?;
235        (
236            g.store.handle_for(g.buffer_id)?,
237            g.workdir.clone(),
238            g.path.clone(),
239            g.pending_highlights.clone(),
240            g.buffer_id,
241            g.headerline.clone(),
242            g.extra_args.clone(),
243        )
244    };
245    // MG.27: the row says "refreshing" from here until the
246    // guard drops — including on every early exit inside the
247    // task, which is why it is a guard and not a matching pair.
248    let busy = headerline::busy(&hl);
249    tokio::task::spawn(async move {
250        let _busy = busy;
251        let for_task = path.clone();
252        let text = tokio::task::spawn_blocking(move || run_log(&wd, for_task.as_deref(), &extra))
253            .await
254            .unwrap_or_default();
255        headerline::publish(&hl, log_header_fields(&text, path.as_deref()));
256        let spans = crate::highlight::log_styled_spans(&text);
257        crate::buffer_io::replace_buffer_text(&handle, text).await;
258        if let Some(ph) = pending {
259            ph.store_and_wake(buffer_id, spans);
260        }
261    });
262    None
263}
264
265/// MG.14: the header for this view — the ref logged, how many
266/// commits are shown, and the path filter when file-scoped. The
267/// count comes from the text just produced rather than a second
268/// `git rev-list`, so the header costs no extra git round-trip.
269/// `--graph` connector rows carry no SHA and are not counted.
270fn log_header_fields(text: &str, path: Option<&std::path::Path>) -> Vec<crate::headerline::Field> {
271    let commits = text.lines().filter(|l| extract_sha(l).is_some()).count();
272    crate::headerline::log_fields("HEAD", commits, path)
273}
274
275/// `git log --oneline --graph --decorate` renders each commit as
276/// `[graph chars] <sha> <subject>` — the sha is the first
277/// hex-looking whitespace-delimited token on the line, wherever the
278/// graph drawing characters end. Returns `None` for graph-only lines
279/// (merge/branch connectors with no commit).
280fn extract_sha(line: &str) -> Option<&str> {
281    line.split_whitespace()
282        .find(|tok| tok.len() >= 4 && tok.chars().all(|c| c.is_ascii_hexdigit()))
283}
284
285/// MG.23k: the arguments `D` offers in a log buffer.
286///
287/// magit's own `L` transient set, minus what this view already does by
288/// default (`--graph`, `--decorate`, `--oneline` are always on — a
289/// toggle for something already enabled does nothing visible, which is
290/// the inert-row failure in a different costume).
291pub(crate) const LOG_ARGS: &[crate::magit_global_mode::RemoteFlag] = &[
292    crate::magit_global_mode::RemoteFlag {
293        name: "all",
294        arg: "--all",
295        key: "-a",
296        doc: "Every ref, not just the current branch",
297        kind: crate::magit_global_mode::RemoteArgKind::Flag,
298    },
299    crate::magit_global_mode::RemoteFlag {
300        name: "count",
301        arg: "-n",
302        key: "-n",
303        doc: "How many commits to show (default 50)",
304        // Separated is correct here — `git log -n 200` is valid, and
305        // this is exactly why the joined/separated distinction is per
306        // argument rather than global.
307        kind: crate::magit_global_mode::RemoteArgKind::Value { prompt: "Commits" },
308    },
309    crate::magit_global_mode::RemoteFlag {
310        name: "author",
311        arg: "--author",
312        key: "-A",
313        doc: "Only commits by an author matching this pattern",
314        kind: crate::magit_global_mode::RemoteArgKind::Value { prompt: "Author" },
315    },
316];
317
318fn run_log(workdir: &std::path::Path, path: Option<&std::path::Path>, extra: &[String]) -> String {
319    let mut args = vec![
320        "log".to_string(),
321        "--oneline".to_string(),
322        "--graph".to_string(),
323        "--decorate".to_string(),
324    ];
325    // The default count is only applied when the `D` menu did not set
326    // one. Appending both and letting git take the last would work,
327    // but it puts two contradictory `-n`s in the argv — and the next
328    // person to read it cannot tell which wins without knowing git.
329    if !extra.iter().any(|a| a == "-n") {
330        args.push("-50".to_string());
331    }
332    args.extend(extra.iter().cloned());
333    if let Some(p) = path {
334        args.push("--".to_string());
335        args.push(p.to_string_lossy().into_owned());
336    }
337    let text = std::process::Command::new("git")
338        .args(&args)
339        .current_dir(workdir)
340        .output()
341        .ok()
342        .and_then(|o| String::from_utf8(o.stdout).ok())
343        .unwrap_or_else(|| "Not a git repository.\n".to_string());
344    if text.trim().is_empty() {
345        // A path-scoped log with no commits is a real, common
346        // outcome (an untracked or brand-new file) — say so rather
347        // than leaving a blank buffer that reads like a failure.
348        match path {
349            Some(p) => format!("No commits touching {}.\n", p.display()),
350            None => "No commits yet.\n".to_string(),
351        }
352    } else {
353        text
354    }
355}
356
357/// `gr` for this view — `magit-core-mode` owns the chord and the one
358/// boot-registered handler; see [`MagitView`].
359struct LogView(Arc<Mutex<LogState>>);
360
361impl MagitView for LogView {
362    fn refresh(&self) -> Option<Effect> {
363        refresh(self.0.clone())
364    }
365
366    /// MG.23k: `D`'s rows for a log buffer. Magit puts these on `L`;
367    /// see `MagitView::argument_flags` for why one chord serves both.
368    fn argument_flags(&self) -> &'static [crate::magit_global_mode::RemoteFlag] {
369        LOG_ARGS
370    }
371
372    fn refresh_with_args(&self, extra: Vec<String>) -> Option<Effect> {
373        if let Ok(mut g) = self.0.lock() {
374            g.extra_args = extra;
375        }
376        refresh(self.0.clone())
377    }
378
379    /// MG.20: the commit on the row under the cursor. `--graph`
380    /// connector rows carry no sha and correctly yield `None`, so
381    /// pressing `V` on one does nothing rather than acting on a
382    /// neighbouring commit.
383    fn commit_at_cursor(&self, cursor: Position) -> Option<String> {
384        let g = self.0.lock().ok()?;
385        let handle = g.store.handle_for(g.buffer_id)?;
386        let snap = handle.snapshot();
387        let line = snap.buffer.line(cursor.line)?;
388        extract_sha(&line).map(|s| s.to_string())
389    }
390
391    fn workdir(&self) -> Option<std::path::PathBuf> {
392        Some(self.0.lock().ok()?.workdir.clone())
393    }
394}