1use std::sync::{Arc, Mutex, OnceLock};
20
21use lattice_cells::{Style, StyledSpan};
22use lattice_config;
23use lattice_grammar::{EchoLevel, Effect};
24use lattice_mode::{
25 ActionContext, ActionHandlerContribution, BufferStoreHandle, CapabilitySet, Keymap,
26 KeymapEntry, LifecycleFuture, Mode, ModeContext, ModeId, ModeKind, OptionOverrideSet,
27 keymap_entry,
28};
29use lattice_protocol::position::Position;
30use lattice_vcs::{RefEntry, RefKind, Reference, Repository};
31
32use crate::buffer_state::{BufferStateGuard, BufferStates, MagitView, MagitViewsHandle};
33use crate::headerline::{self, Field, MagitHeaderlineHandle};
34
35pub struct MagitRefsMode;
36
37impl MagitRefsMode {
38 pub fn mode_id() -> ModeId {
39 ModeId::new("magit-refs-mode")
40 }
41}
42
43pub const REFS_VIEW: &str = "refs";
51
52fn magit_refs_keymap_entries() -> &'static [KeymapEntry] {
53 static ENTRIES: OnceLock<Vec<KeymapEntry>> = OnceLock::new();
54 ENTRIES.get_or_init(|| {
55 vec![
56 keymap_entry! { mode: Normal, chord: "<CR>", doc: "Show the commit this ref points at", cmd: "action:magit-refs-show" },
57 keymap_entry! { mode: Normal, chord: "c", doc: "Check out the ref at cursor", cmd: "action:magit-refs-checkout" },
58 ]
59 })
60}
61
62pub struct RefsState {
63 buffer_id: lattice_core::BufferId,
64 store: Arc<BufferStoreHandle>,
65 workdir: std::path::PathBuf,
66 pending_highlights: Option<lattice_mode::PendingSyntheticHighlightsHandle>,
67 headerline: Option<MagitHeaderlineHandle>,
68 rows: Vec<Option<RefEntry>>,
78}
79
80pub type RefsStatesHandle = Arc<BufferStates<RefsState>>;
83
84fn state(ctx: &ActionContext<'_>) -> Option<Arc<Mutex<RefsState>>> {
85 crate::buffer_state::state_for::<RefsState>(ctx)
86}
87
88struct RefsView(Arc<Mutex<RefsState>>);
89
90impl MagitView for RefsView {
91 fn refresh(&self) -> Option<Effect> {
92 refresh(self.0.clone())
93 }
94
95 fn commit_at_cursor(&self, cursor: Position) -> Option<String> {
101 Some(ref_at_cursor(&*self.0.lock().ok()?, cursor)?.id)
102 }
103
104 fn workdir(&self) -> Option<std::path::PathBuf> {
105 Some(self.0.lock().ok()?.workdir.clone())
106 }
107}
108
109impl Mode for MagitRefsMode {
110 type Guard = BufferStateGuard<RefsState>;
111
112 fn id(&self) -> ModeId {
113 Self::mode_id()
114 }
115 fn kind(&self) -> ModeKind {
116 ModeKind::Major
117 }
118 fn target_buffer_kind(&self) -> Option<lattice_core::BufferKind> {
119 None
120 }
121
122 fn options(&self) -> OptionOverrideSet {
123 lattice_config::overrides! {
124 lattice_config::ReadOnly = true,
125 lattice_config::NoFile = true,
126 }
127 }
128
129 fn implies(&self) -> &[lattice_mode::ModeId] {
148 static IMPLIED: std::sync::OnceLock<Vec<lattice_mode::ModeId>> = std::sync::OnceLock::new();
149 IMPLIED.get_or_init(|| vec![lattice_mode::modes::ReadOnlyMode::mode_id()])
150 }
151
152 fn required_capabilities(&self) -> CapabilitySet {
153 CapabilitySet::empty()
154 }
155
156 fn keymap(&self) -> Keymap {
157 Keymap::from_entries(magit_refs_keymap_entries())
158 }
159
160 fn action_handlers(&self) -> Vec<ActionHandlerContribution> {
163 vec![
164 ActionHandlerContribution {
165 action_name: "action:magit-refs-show",
166 handler: Arc::new(|ctx: &ActionContext<'_>| {
167 let s = state(ctx)?;
168 let entry = {
169 let g = s.lock().ok()?;
170 ref_at_cursor(&g, ctx.cursor)?
171 };
172 Some(crate::magit_global_mode::open_repo_view_from_action_with(
173 ctx,
174 crate::magit_revision_mode::SHOW_VIEW,
175 "magit-revision-mode",
176 Some(&entry.id),
177 ))
178 }),
179 },
180 ActionHandlerContribution {
181 action_name: "action:magit-refs-checkout",
182 handler: Arc::new(|ctx: &ActionContext<'_>| {
183 let s = state(ctx)?;
184 let (entry, workdir) = {
185 let g = s.lock().ok()?;
186 (ref_at_cursor(&g, ctx.cursor)?, g.workdir.clone())
187 };
188 if entry.kind != RefKind::Branch {
194 return Some(Effect::Echo {
195 level: EchoLevel::Error,
196 text: format!(
197 "magit: checking out {} would detach HEAD — \
198 make a branch from it instead \
199 (:magit-branch-create <name>, or `b` `c` in the dispatch)",
200 entry.name
201 ),
202 });
203 }
204 let name = entry.name.clone();
205 spawn_mutation_and_refresh(s, move || {
206 let result = Repository::discover(&workdir)
209 .map_err(|e| format!("not a git repository: {e}"))
210 .and_then(|repo| {
211 lattice_vcs::Branch::checkout(&repo, &name)
212 .map_err(|e| e.to_string())
213 })
214 .map(|()| String::new());
215 crate::magit_global_mode::finish_task(
216 &workdir,
217 &format!("check out {name}"),
218 result,
219 );
220 })
221 }),
222 },
223 ]
224 }
225
226 fn on_activate(&self, ctx: ModeContext) -> LifecycleFuture<'_, Self::Guard> {
227 Box::pin(async move {
228 let buffer_id = lattice_core::BufferId(ctx.buffer_id().0 as u32);
229 let orphan = || BufferStateGuard::new(Arc::new(BufferStates::default()), buffer_id);
230 let Some(store) = ctx.service::<BufferStoreHandle>() else {
231 return Ok(orphan());
232 };
233 let Some(handle) = store.handle_for(buffer_id) else {
234 return Ok(orphan());
235 };
236 let workdir =
239 crate::repo_scope::view_workdir(&ctx, buffer_id, &handle).unwrap_or_default();
240 let pending_highlights = ctx.service::<lattice_mode::PendingSyntheticHighlights>();
241
242 let (hl, hl_registration) =
243 match headerline::install(&ctx, buffer_id, Self::mode_id().as_str()) {
244 Some((h, reg)) => (Some(h), Some(reg)),
245 None => (None, None),
246 };
247
248 let Some(states) = ctx.service::<RefsStatesHandle>() else {
254 return Ok(orphan());
255 };
256 let state = states.publish(
257 buffer_id,
258 RefsState {
259 buffer_id,
260 store: store.clone(),
261 workdir: workdir.clone(),
262 pending_highlights: pending_highlights.clone(),
263 headerline: hl.clone(),
264 rows: Vec::new(),
265 },
266 );
267 let mut guard = BufferStateGuard::new((*states).clone(), buffer_id)
268 .with_headerline(hl_registration);
269 if let Some(views) = ctx.service::<MagitViewsHandle>() {
270 views.publish(buffer_id, Arc::new(RefsView(state.clone())));
271 guard = guard.with_views((*views).clone());
272 }
273
274 let wd = workdir.clone();
275 let built = tokio::task::spawn_blocking(move || build_refs_buffer(&wd))
276 .await
277 .unwrap_or_default();
278 headerline::publish(&hl, built.header.clone());
279 let spans = built.spans;
280 if let Ok(mut g) = state.lock() {
281 g.rows = built.rows;
282 }
283 crate::buffer_io::replace_buffer_text(&handle, built.text).await;
284 if let Some(ref ph) = pending_highlights {
285 ph.store_and_wake(buffer_id, spans);
286 }
287
288 Ok(guard)
289 })
290 }
291}
292
293fn refresh(s: Arc<Mutex<RefsState>>) -> Option<Effect> {
295 respawn(s, || {})
296}
297
298fn spawn_mutation_and_refresh(
302 s: Arc<Mutex<RefsState>>,
303 mutate: impl FnOnce() + Send + 'static,
304) -> Option<Effect> {
305 respawn(s, mutate)
306}
307
308fn respawn(s: Arc<Mutex<RefsState>>, mutate: impl FnOnce() + Send + 'static) -> Option<Effect> {
309 let (handle, wd, pending, buffer_id, hl) = {
310 let g = s.lock().ok()?;
311 (
312 g.store.handle_for(g.buffer_id)?,
313 g.workdir.clone(),
314 g.pending_highlights.clone(),
315 g.buffer_id,
316 g.headerline.clone(),
317 )
318 };
319 let busy = headerline::busy(&hl);
322 tokio::task::spawn(async move {
323 let _busy = busy;
324 let _ = tokio::task::spawn_blocking(mutate).await;
325 let built = tokio::task::spawn_blocking(move || build_refs_buffer(&wd))
326 .await
327 .unwrap_or_default();
328 headerline::publish(&hl, built.header.clone());
329 let spans = built.spans;
330 if let Ok(mut g) = s.lock() {
335 g.rows = built.rows;
336 }
337 crate::buffer_io::replace_buffer_text(&handle, built.text).await;
338 if let Some(ph) = pending {
339 ph.store_and_wake(buffer_id, spans);
340 }
341 });
342 None
343}
344
345fn ref_at_cursor(state: &RefsState, cursor: Position) -> Option<RefEntry> {
346 state.rows.get(cursor.line as usize).cloned().flatten()
347}
348
349#[derive(Default)]
359pub(crate) struct RefsBuffer {
360 pub(crate) text: String,
361 pub(crate) header: Vec<Field>,
362 pub(crate) rows: Vec<Option<RefEntry>>,
363 pub(crate) spans: Vec<Vec<StyledSpan>>,
364}
365
366fn build_refs_buffer(workdir: &std::path::Path) -> RefsBuffer {
367 let repo = match Repository::discover(workdir) {
368 Ok(r) => r,
369 Err(_) => {
370 return RefsBuffer {
371 text: "Not a git repository.\n".to_string(),
372 ..Default::default()
373 };
374 }
375 };
376 render_refs(&Reference::list(&repo).unwrap_or_default())
377}
378
379const NAME_WIDTH: usize = 28;
383const ID_WIDTH: usize = 10;
385const TRACK_WIDTH: usize = 20;
387
388pub(crate) fn render_refs(refs: &[RefEntry]) -> RefsBuffer {
394 if refs.is_empty() {
395 return RefsBuffer {
396 text: "No refs.\n".to_string(),
397 header: headerline::refs_fields(0, 0, 0),
398 rows: vec![None],
399 spans: vec![Vec::new()],
400 };
401 }
402 let count = |k: RefKind| refs.iter().filter(|r| r.kind == k).count();
403 let (branches, remotes, tags) = (
404 count(RefKind::Branch),
405 count(RefKind::Remote),
406 count(RefKind::Tag),
407 );
408
409 let mut text = String::new();
410 let mut rows: Vec<Option<RefEntry>> = Vec::new();
411 let mut spans: Vec<Vec<StyledSpan>> = Vec::new();
412 for (kind, label, n) in [
413 (RefKind::Branch, "Branches", branches),
414 (RefKind::Remote, "Remotes", remotes),
415 (RefKind::Tag, "Tags", tags),
416 ] {
417 if n == 0 {
418 continue;
422 }
423 text.push_str(&format!("{label} ({n})\n"));
424 rows.push(None);
425 spans.push(Vec::new());
426 for entry in refs.iter().filter(|r| r.kind == kind) {
427 let (row, row_spans) = render_row(entry);
428 text.push_str(&row);
429 text.push('\n');
430 rows.push(Some(entry.clone()));
431 spans.push(row_spans);
432 }
433 text.push('\n');
434 rows.push(None);
435 spans.push(Vec::new());
436 }
437 RefsBuffer {
438 text,
439 header: headerline::refs_fields(branches, remotes, tags),
440 rows,
441 spans,
442 }
443}
444
445fn render_row(entry: &RefEntry) -> (String, Vec<StyledSpan>) {
449 let marker = if entry.head { "* " } else { " " };
450 let pad = |s: &str, w: usize| {
455 let used = s.chars().count();
456 " ".repeat(w.saturating_sub(used).max(1))
457 };
458 let mut row = String::from(marker);
459 let mut spans = Vec::new();
460
461 row.push_str(&entry.name);
462 if entry.head {
463 spans.push(StyledSpan {
466 start: 0,
467 end: row.len(),
468 style: Style::MagitBranchCurrent,
469 });
470 }
471 row.push_str(&pad(&entry.name, NAME_WIDTH));
472
473 let id_start = row.len();
474 row.push_str(&entry.short_id);
475 spans.push(StyledSpan {
476 start: id_start,
477 end: row.len(),
478 style: Style::MagitSha,
479 });
480 row.push_str(&pad(&entry.short_id, ID_WIDTH));
481
482 if !entry.track.is_empty() {
483 let track_start = row.len();
484 row.push_str(&entry.track);
485 spans.push(StyledSpan {
486 start: track_start,
487 end: row.len(),
488 style: Style::MagitRefDecoration,
491 });
492 }
493
494 if entry.subject.is_empty() {
495 return (row.trim_end().to_string(), spans);
500 }
501 row.push_str(&pad(&entry.track, TRACK_WIDTH));
502 row.push_str(&entry.subject);
503 (row, spans)
504}
505
506#[cfg(test)]
507mod tests {
508 use super::*;
509
510 fn entry(kind: RefKind, name: &str) -> RefEntry {
511 RefEntry {
512 kind,
513 name: name.to_string(),
514 id: format!("{name}-full-id"),
515 short_id: "a1b2c3d".to_string(),
516 upstream: String::new(),
517 track: String::new(),
518 head: false,
519 subject: "subject".to_string(),
520 }
521 }
522
523 #[test]
528 fn every_row_index_points_at_the_ref_rendered_on_that_line() {
529 let refs = vec![
530 entry(RefKind::Branch, "main"),
531 entry(RefKind::Branch, "feature/x"),
532 entry(RefKind::Remote, "origin/main"),
533 entry(RefKind::Tag, "v1.0.0"),
534 ];
535 let built = render_refs(&refs);
536 let lines: Vec<&str> = built.text.lines().collect();
537 assert_eq!(
538 built.rows.len(),
539 lines.len(),
540 "one index slot per rendered line"
541 );
542 for (i, slot) in built.rows.iter().enumerate() {
543 match slot {
544 Some(e) => assert!(
545 lines[i].contains(&e.name),
546 "line {i} ({:?}) must show {}",
547 lines[i],
548 e.name
549 ),
550 None => assert!(
551 !lines[i].starts_with(" ") && !lines[i].starts_with("* "),
552 "line {i} ({:?}) is indexed as a non-row but looks like one",
553 lines[i]
554 ),
555 }
556 }
557 }
558
559 #[test]
563 fn headings_and_blanks_carry_no_ref() {
564 let built = render_refs(&[entry(RefKind::Branch, "main")]);
565 assert_eq!(built.rows[0], None, "the heading");
566 assert!(built.rows[1].is_some(), "the one branch");
567 assert_eq!(built.rows[2], None, "the trailing blank");
568 }
569
570 #[test]
574 fn empty_groups_are_omitted() {
575 let built = render_refs(&[entry(RefKind::Branch, "main")]);
576 assert!(built.text.starts_with("Branches (1)"));
577 assert!(!built.text.contains("Remotes"), "got: {}", built.text);
578 assert!(!built.text.contains("Tags"), "got: {}", built.text);
579 }
580
581 #[test]
584 fn only_the_checked_out_branch_is_marked() {
585 let mut head = entry(RefKind::Branch, "main");
586 head.head = true;
587 let built = render_refs(&[head, entry(RefKind::Branch, "other")]);
588 let marked: Vec<&str> = built.text.lines().filter(|l| l.starts_with("* ")).collect();
589 assert_eq!(marked.len(), 1, "got: {}", built.text);
590 assert!(marked[0].contains("main"));
591 }
592
593 #[test]
597 fn a_long_name_is_never_truncated() {
598 let long = "feature/a-really-quite-long-branch-name-here";
599 let built = render_refs(&[entry(RefKind::Branch, long)]);
600 assert!(
601 built.text.contains(long),
602 "the name survives whole: {}",
603 built.text
604 );
605 assert!(
606 built.text.contains("a1b2c3d"),
607 "and the id still follows it: {}",
608 built.text
609 );
610 }
611
612 #[test]
616 fn a_non_ascii_name_pads_by_characters() {
617 let built = render_refs(&[entry(RefKind::Branch, "función/ünïcode")]);
618 let row = built.text.lines().nth(1).expect("the row");
619 let id_at = row.find("a1b2c3d").expect("the id is on the row");
620 assert_eq!(
624 row[..id_at].chars().count(),
625 2 + NAME_WIDTH,
626 "columns align by character: {row:?}"
627 );
628 }
629
630 #[test]
633 fn a_row_with_no_subject_has_no_trailing_whitespace() {
634 let mut e = entry(RefKind::Tag, "v1.0.0");
635 e.subject = String::new();
636 let built = render_refs(&[e]);
637 let row = built.text.lines().nth(1).expect("the row");
638 assert_eq!(row, row.trim_end(), "trailing whitespace in {row:?}");
639 }
640
641 #[test]
646 fn every_span_covers_the_field_it_names() {
647 let mut head = entry(RefKind::Branch, "main");
648 head.head = true;
649 head.track = "ahead 3, behind 1".to_string();
650 let built = render_refs(&[head.clone(), entry(RefKind::Tag, "v1.0.0")]);
651 let lines: Vec<&str> = built.text.lines().collect();
652 assert_eq!(built.spans.len(), lines.len(), "one span slot per line");
653
654 let row = lines[1];
655 let spans = &built.spans[1];
656 let at = |s: &StyledSpan| row[s.start..s.end].to_string();
657 let find = |style: Style| spans.iter().find(|s| s.style == style).map(&at);
658
659 assert_eq!(find(Style::MagitBranchCurrent).as_deref(), Some("* main"));
660 assert_eq!(find(Style::MagitSha).as_deref(), Some("a1b2c3d"));
661 assert_eq!(
662 find(Style::MagitRefDecoration).as_deref(),
663 Some("ahead 3, behind 1"),
664 "the summary contains spaces — the reason it cannot be \
665 recovered by scanning the row back"
666 );
667 }
668
669 #[test]
674 fn a_ref_with_no_tracking_summary_gets_no_decoration_span() {
675 let built = render_refs(&[entry(RefKind::Branch, "main")]);
676 assert!(
677 !built.spans[1]
678 .iter()
679 .any(|s| s.style == Style::MagitRefDecoration),
680 "no summary, so no decoration: {:?}",
681 built.spans[1]
682 );
683 let row = built.text.lines().nth(1).expect("the row");
684 assert!(
685 row.ends_with("subject"),
686 "the subject is still there: {row:?}"
687 );
688 }
689
690 #[test]
693 fn no_refs_says_so_and_indexes_nothing() {
694 let built = render_refs(&[]);
695 assert_eq!(built.text, "No refs.\n");
696 assert_eq!(built.rows, vec![None]);
697 }
698}