1use 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 path: Option<std::path::PathBuf>,
50 pending_highlights: Option<lattice_mode::PendingSyntheticHighlightsHandle>,
51 headerline: Option<MagitHeaderlineHandle>,
54 extra_args: Vec<String>,
57}
58
59pub 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 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 fn action_handlers(&self) -> Vec<ActionHandlerContribution> {
122 vec![
123 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 let workdir =
157 crate::repo_scope::view_workdir(&ctx, buffer_id, &handle).unwrap_or_default();
158
159 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 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 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 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 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 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
265fn 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
275fn 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
285pub(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 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 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 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
357struct LogView(Arc<Mutex<LogState>>);
360
361impl MagitView for LogView {
362 fn refresh(&self) -> Option<Effect> {
363 refresh(self.0.clone())
364 }
365
366 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 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}