1use std::sync::{Arc, Mutex, OnceLock};
22
23use lattice_cells::{Style, StyledSpan};
24use lattice_config;
25use lattice_grammar::Effect;
26use lattice_mode::{
27 ActionContext, ActionHandlerContribution, BufferStoreHandle, CapabilitySet, Keymap,
28 KeymapEntry, LifecycleFuture, Mode, ModeContext, ModeId, ModeKind, OptionOverrideSet,
29 keymap_entry,
30};
31use lattice_protocol::position::Position;
32use lattice_vcs::Repository;
33
34use crate::buffer_state::{BufferStateGuard, BufferStates, MagitView, MagitViewsHandle};
35use crate::headerline::{self, Field, MagitHeaderlineHandle};
36
37pub struct MagitCherryMode;
38
39impl MagitCherryMode {
40 pub fn mode_id() -> ModeId {
41 ModeId::new("magit-cherry-mode")
42 }
43}
44
45pub(crate) const CHERRY_VIEW: &str = "cherry";
51
52pub(crate) fn cherry_view_rest(upstream: &str, head: &str) -> String {
55 format!("{upstream}..{head}")
56}
57
58fn parse_name(name: &str) -> Option<(String, String)> {
60 let parsed = crate::workdir::parse_magit_name(name)?;
61 (parsed.view == CHERRY_VIEW).then_some(())?;
62 let (upstream, head) = parsed.rest?.split_once("..")?;
63 (!upstream.is_empty() && !head.is_empty()).then(|| (upstream.to_string(), head.to_string()))
64}
65
66fn magit_cherry_keymap_entries() -> &'static [KeymapEntry] {
67 static ENTRIES: OnceLock<Vec<KeymapEntry>> = OnceLock::new();
68 ENTRIES.get_or_init(|| {
69 vec![
70 keymap_entry! { mode: Normal, chord: "<CR>", doc: "Show the commit at cursor", cmd: "action:magit-cherry-show" },
71 ]
72 })
73}
74
75pub struct CherryState {
76 buffer_id: lattice_core::BufferId,
77 store: Arc<BufferStoreHandle>,
78 workdir: std::path::PathBuf,
79 pending_highlights: Option<lattice_mode::PendingSyntheticHighlightsHandle>,
80 headerline: Option<MagitHeaderlineHandle>,
81 upstream: String,
82 head: String,
83 rows: Vec<Option<String>>,
89}
90
91pub type CherryStatesHandle = Arc<BufferStates<CherryState>>;
93
94fn state(ctx: &ActionContext<'_>) -> Option<Arc<Mutex<CherryState>>> {
95 crate::buffer_state::state_for::<CherryState>(ctx)
96}
97
98struct CherryView(Arc<Mutex<CherryState>>);
99
100impl MagitView for CherryView {
101 fn refresh(&self) -> Option<Effect> {
102 refresh(self.0.clone())
103 }
104
105 fn commit_at_cursor(&self, cursor: Position) -> Option<String> {
109 let g = self.0.lock().ok()?;
110 g.rows.get(cursor.line as usize).cloned().flatten()
111 }
112
113 fn workdir(&self) -> Option<std::path::PathBuf> {
114 Some(self.0.lock().ok()?.workdir.clone())
115 }
116}
117
118impl Mode for MagitCherryMode {
119 type Guard = BufferStateGuard<CherryState>;
120
121 fn id(&self) -> ModeId {
122 Self::mode_id()
123 }
124 fn kind(&self) -> ModeKind {
125 ModeKind::Major
126 }
127 fn target_buffer_kind(&self) -> Option<lattice_core::BufferKind> {
128 None
129 }
130
131 fn options(&self) -> OptionOverrideSet {
132 lattice_config::overrides! {
133 lattice_config::ReadOnly = true,
134 lattice_config::NoFile = true,
135 }
136 }
137
138 fn implies(&self) -> &[lattice_mode::ModeId] {
157 static IMPLIED: std::sync::OnceLock<Vec<lattice_mode::ModeId>> = std::sync::OnceLock::new();
158 IMPLIED.get_or_init(|| vec![lattice_mode::modes::ReadOnlyMode::mode_id()])
159 }
160
161 fn required_capabilities(&self) -> CapabilitySet {
162 CapabilitySet::empty()
163 }
164
165 fn keymap(&self) -> Keymap {
166 Keymap::from_entries(magit_cherry_keymap_entries())
167 }
168
169 fn action_handlers(&self) -> Vec<ActionHandlerContribution> {
170 vec![ActionHandlerContribution {
171 action_name: "action:magit-cherry-show",
172 handler: Arc::new(|ctx: &ActionContext<'_>| {
173 let s = state(ctx)?;
174 let sha = {
175 let g = s.lock().ok()?;
176 g.rows.get(ctx.cursor.line as usize).cloned().flatten()?
177 };
178 Some(crate::magit_global_mode::open_repo_view_from_action_with(
179 ctx,
180 crate::magit_revision_mode::SHOW_VIEW,
181 "magit-revision-mode",
182 Some(&sha),
183 ))
184 }),
185 }]
186 }
187
188 fn on_activate(&self, ctx: ModeContext) -> LifecycleFuture<'_, Self::Guard> {
189 Box::pin(async move {
190 let buffer_id = lattice_core::BufferId(ctx.buffer_id().0 as u32);
191 let orphan = || BufferStateGuard::new(Arc::new(BufferStates::default()), buffer_id);
192 let Some(store) = ctx.service::<BufferStoreHandle>() else {
193 return Ok(orphan());
194 };
195 let Some(handle) = store.handle_for(buffer_id) else {
196 return Ok(orphan());
197 };
198 let workdir =
201 crate::repo_scope::view_workdir(&ctx, buffer_id, &handle).unwrap_or_default();
202 let pending_highlights = ctx.service::<lattice_mode::PendingSyntheticHighlights>();
203 let (upstream, head) = store
204 .name_for(buffer_id)
205 .as_deref()
206 .and_then(parse_name)
207 .unwrap_or_default();
208
209 let (hl, hl_registration) =
210 match headerline::install(&ctx, buffer_id, Self::mode_id().as_str()) {
211 Some((h, reg)) => (Some(h), Some(reg)),
212 None => (None, None),
213 };
214
215 let Some(states) = ctx.service::<CherryStatesHandle>() else {
219 return Ok(orphan());
220 };
221 let state = states.publish(
222 buffer_id,
223 CherryState {
224 buffer_id,
225 store: store.clone(),
226 workdir: workdir.clone(),
227 pending_highlights: pending_highlights.clone(),
228 headerline: hl.clone(),
229 upstream: upstream.clone(),
230 head: head.clone(),
231 rows: Vec::new(),
232 },
233 );
234 let mut guard = BufferStateGuard::new((*states).clone(), buffer_id)
235 .with_headerline(hl_registration);
236 if let Some(views) = ctx.service::<MagitViewsHandle>() {
237 views.publish(buffer_id, Arc::new(CherryView(state.clone())));
238 guard = guard.with_views((*views).clone());
239 }
240
241 let wd = workdir.clone();
242 let (u, h) = (upstream.clone(), head.clone());
243 let built = tokio::task::spawn_blocking(move || build_cherry_buffer(&wd, &u, &h))
244 .await
245 .unwrap_or_default();
246 headerline::publish(&hl, built.header.clone());
247 if let Ok(mut g) = state.lock() {
248 g.rows = built.rows;
249 }
250 crate::buffer_io::replace_buffer_text(&handle, built.text).await;
251 if let Some(ref ph) = pending_highlights {
252 ph.store_and_wake(buffer_id, built.spans);
253 }
254
255 Ok(guard)
256 })
257 }
258}
259
260fn refresh(s: Arc<Mutex<CherryState>>) -> Option<Effect> {
262 let (handle, wd, pending, buffer_id, hl, upstream, head) = {
263 let g = s.lock().ok()?;
264 (
265 g.store.handle_for(g.buffer_id)?,
266 g.workdir.clone(),
267 g.pending_highlights.clone(),
268 g.buffer_id,
269 g.headerline.clone(),
270 g.upstream.clone(),
271 g.head.clone(),
272 )
273 };
274 let busy = headerline::busy(&hl);
275 tokio::task::spawn(async move {
276 let _busy = busy;
277 let built = tokio::task::spawn_blocking(move || build_cherry_buffer(&wd, &upstream, &head))
278 .await
279 .unwrap_or_default();
280 headerline::publish(&hl, built.header.clone());
281 if let Ok(mut g) = s.lock() {
282 g.rows = built.rows;
283 }
284 crate::buffer_io::replace_buffer_text(&handle, built.text).await;
285 if let Some(ph) = pending {
286 ph.store_and_wake(buffer_id, built.spans);
287 }
288 });
289 None
290}
291
292#[derive(Default)]
295pub(crate) struct CherryBuffer {
296 pub(crate) text: String,
297 pub(crate) header: Vec<Field>,
298 pub(crate) rows: Vec<Option<String>>,
299 pub(crate) spans: Vec<Vec<StyledSpan>>,
300}
301
302fn build_cherry_buffer(workdir: &std::path::Path, upstream: &str, head: &str) -> CherryBuffer {
303 if upstream.is_empty() {
304 return CherryBuffer {
305 text: "magit: cherries needs an upstream to compare against.\n".to_string(),
306 rows: vec![None],
307 spans: vec![Vec::new()],
308 ..Default::default()
309 };
310 }
311 let Ok(repo) = Repository::discover(workdir) else {
312 return CherryBuffer {
313 text: "Not a git repository.\n".to_string(),
314 rows: vec![None],
315 spans: vec![Vec::new()],
316 ..Default::default()
317 };
318 };
319 let raw = repo
324 .run_git_str(["cherry", "-v", upstream, head])
325 .unwrap_or_default();
326 render_cherries(&raw, upstream, head)
327}
328
329pub(crate) fn render_cherries(raw: &str, upstream: &str, head: &str) -> CherryBuffer {
334 let mut text = format!("Cherries {head} vs {upstream}\n\n");
335 let mut rows: Vec<Option<String>> = vec![None, None];
336 let mut spans: Vec<Vec<StyledSpan>> = vec![Vec::new(), Vec::new()];
337 let (mut ahead, mut equivalent) = (0usize, 0usize);
338
339 for line in raw.lines() {
340 let Some((mark, rest)) = line.split_once(' ') else {
342 continue;
343 };
344 let (sha, subject) = match rest.split_once(' ') {
345 Some((s, subj)) => (s, subj),
346 None => (rest, ""),
347 };
348 if sha.is_empty() || !sha.chars().all(|c| c.is_ascii_hexdigit()) {
349 continue;
350 }
351 match mark {
352 "+" => ahead += 1,
353 "-" => equivalent += 1,
354 _ => continue,
355 }
356 let short: String = sha.chars().take(7).collect();
357 let row = if subject.is_empty() {
358 format!("{mark} {short}")
359 } else {
360 format!("{mark} {short} {subject}")
361 };
362 let sha_start = mark.len() + 1;
366 spans.push(vec![StyledSpan {
367 start: sha_start,
368 end: sha_start + short.len(),
369 style: Style::MagitSha,
370 }]);
371 text.push_str(&row);
372 text.push('\n');
373 rows.push(Some(sha.to_string()));
374 }
375
376 if ahead == 0 && equivalent == 0 {
377 text.push_str("Nothing to compare — no commits on either side.\n");
378 rows.push(None);
379 spans.push(Vec::new());
380 }
381 CherryBuffer {
382 text,
383 header: headerline::cherry_fields(upstream, head, ahead, equivalent),
384 rows,
385 spans,
386 }
387}
388
389#[cfg(test)]
390mod tests {
391 use super::*;
392
393 const SAMPLE: &str = "+ a1b2c3d4e5f6 not upstream yet\n\
394 - e4f5a6b7c8d9 already upstream under another sha\n\
395 + 0123456789ab another one\n";
396
397 #[test]
398 fn the_name_carries_both_ends_and_round_trips() {
399 let name = crate::workdir::magit_buffer_name_with(
400 CHERRY_VIEW,
401 "lattice",
402 &cherry_view_rest("origin/main", "main"),
403 );
404 assert_eq!(
405 parse_name(&name),
406 Some(("origin/main".to_string(), "main".to_string()))
407 );
408 }
409
410 #[test]
413 fn names_missing_an_end_resolve_to_nothing() {
414 for name in [
415 "*magit:cherry:..main*",
416 "*magit:cherry:origin/main..*",
417 "*magit:cherry:origin/main*",
418 "*magit:commit:abc*",
419 ] {
420 assert_eq!(parse_name(name), None, "{name:?}");
421 }
422 }
423
424 #[test]
428 fn every_row_index_points_at_the_commit_on_that_line() {
429 let built = render_cherries(SAMPLE, "origin/main", "main");
430 let lines: Vec<&str> = built.text.lines().collect();
431 assert_eq!(built.rows.len(), lines.len(), "one slot per line");
432 assert_eq!(built.spans.len(), lines.len(), "one span slot per line");
433 for (i, slot) in built.rows.iter().enumerate() {
434 if let Some(sha) = slot {
435 assert!(
436 lines[i].contains(&sha[..7]),
437 "line {i} ({:?}) must show {sha}",
438 lines[i]
439 );
440 }
441 }
442 }
443
444 #[test]
449 fn the_index_carries_full_shas_even_though_rows_show_short_ones() {
450 let built = render_cherries(SAMPLE, "origin/main", "main");
451 let shas: Vec<&String> = built.rows.iter().flatten().collect();
452 assert_eq!(shas.len(), 3);
453 assert_eq!(shas[0], "a1b2c3d4e5f6");
454 assert!(
455 built.text.contains("+ a1b2c3d"),
456 "but the row shows the short one: {}",
457 built.text
458 );
459 }
460
461 #[test]
465 fn both_marks_survive_and_are_counted_apart() {
466 let built = render_cherries(SAMPLE, "origin/main", "main");
467 assert!(built.text.starts_with("Cherries"));
468 assert_eq!(
469 built.text.lines().filter(|l| l.starts_with("+ ")).count(),
470 2,
471 "two not-upstream commits"
472 );
473 assert_eq!(
474 built.text.lines().filter(|l| l.starts_with("- ")).count(),
475 1,
476 "one already-upstream commit"
477 );
478 }
479
480 #[test]
483 fn unparseable_lines_are_skipped_not_rendered() {
484 let built = render_cherries("garbage\n* wrongmark abc subject\n+ \n", "u", "h");
485 assert!(
486 built.rows.iter().flatten().next().is_none(),
487 "no row survived: {:?}",
488 built.text
489 );
490 assert!(built.text.contains("Nothing to compare"));
491 }
492
493 #[test]
495 fn the_sha_span_covers_the_sha() {
496 let built = render_cherries(SAMPLE, "origin/main", "main");
497 let lines: Vec<&str> = built.text.lines().collect();
498 for (i, spans) in built.spans.iter().enumerate() {
499 for s in spans {
500 assert_eq!(
501 &lines[i][s.start..s.end],
502 &built.rows[i].as_ref().expect("a row")[..7],
503 "span on line {i} must cover the short sha"
504 );
505 }
506 }
507 }
508}