1use std::sync::atomic::{AtomicU64, Ordering};
37use std::sync::{Arc, RwLock};
38
39use lattice_cells::{Cell, Headerline, HeaderlineProvider, HeaderlineRow, ProviderId};
40use lattice_core::BufferId;
41use lattice_mode::{ModeContext, VirtualRowRegistrar};
42use lattice_theme::{
43 ColorRef, ElementId, ElementName, ElementOwner, StyleSpec, ThemeRegistryHandle,
44};
45
46pub const MAGIT_HEADERLINE_PROVIDER_ID: ProviderId = 0x6d61_6769_745f_686c; const SEP: &str = " ";
55
56const BUSY_TEXT: &str = "refreshing";
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum FieldStyle {
76 Sha,
78 Branch,
80 Ref,
83 Author,
85 Alert,
88 Label,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct Field {
96 pub text: String,
97 pub style: FieldStyle,
98}
99
100impl Field {
101 pub fn new(text: impl Into<String>, style: FieldStyle) -> Self {
102 Self {
103 text: text.into(),
104 style,
105 }
106 }
107 pub fn sha(text: impl Into<String>) -> Self {
108 Self::new(text, FieldStyle::Sha)
109 }
110 pub fn branch(text: impl Into<String>) -> Self {
111 Self::new(text, FieldStyle::Branch)
112 }
113 pub fn git_ref(text: impl Into<String>) -> Self {
114 Self::new(text, FieldStyle::Ref)
115 }
116 pub fn author(text: impl Into<String>) -> Self {
117 Self::new(text, FieldStyle::Author)
118 }
119 pub fn alert(text: impl Into<String>) -> Self {
120 Self::new(text, FieldStyle::Alert)
121 }
122 pub fn label(text: impl Into<String>) -> Self {
123 Self::new(text, FieldStyle::Label)
124 }
125}
126
127struct FieldElements {
131 theme: ThemeRegistryHandle,
132 sha: ElementId,
133 branch: ElementId,
134 reference: ElementId,
135 author: ElementId,
136 alert: ElementId,
137 label: ElementId,
138}
139
140impl FieldElements {
141 fn id_for(&self, style: FieldStyle) -> ElementId {
142 match style {
143 FieldStyle::Sha => self.sha,
144 FieldStyle::Branch => self.branch,
145 FieldStyle::Ref => self.reference,
146 FieldStyle::Author => self.author,
147 FieldStyle::Alert => self.alert,
148 FieldStyle::Label => self.label,
149 }
150 }
151}
152
153fn fallback_fg(style: FieldStyle) -> u32 {
156 match style {
157 FieldStyle::Sha => 0x89b4fa,
158 FieldStyle::Branch => 0xa6e3a1,
159 FieldStyle::Ref => 0xf5c2e7,
160 FieldStyle::Author => 0x9399b2,
161 FieldStyle::Alert => 0xf38ba8,
162 FieldStyle::Label => 0x888888,
163 }
164}
165
166pub struct MagitHeaderline {
170 fields: RwLock<Vec<Field>>,
171 version: AtomicU64,
173 busy: std::sync::atomic::AtomicBool,
176 notice: RwLock<Option<String>>,
194 elements: Option<FieldElements>,
196}
197
198pub type MagitHeaderlineHandle = Arc<MagitHeaderline>;
202
203impl MagitHeaderline {
204 pub fn new(theme: Option<ThemeRegistryHandle>, mode_id: &str) -> MagitHeaderlineHandle {
213 Arc::new(Self {
214 fields: RwLock::new(Vec::new()),
215 version: AtomicU64::new(0),
216 busy: std::sync::atomic::AtomicBool::new(false),
217 notice: RwLock::new(None),
218 elements: theme.map(|t| resolve_elements(t, mode_id)),
219 })
220 }
221
222 pub fn set(&self, fields: Vec<Field>) -> bool {
226 let cleared = self.set_notice(None);
233 let Ok(mut slot) = self.fields.write() else {
234 return cleared;
235 };
236 if *slot == fields {
237 return cleared;
238 }
239 *slot = fields;
240 self.version.fetch_add(1, Ordering::Release);
241 true
242 }
243
244 pub fn set_busy(&self, busy: bool) -> bool {
259 if self.busy.swap(busy, Ordering::AcqRel) == busy {
260 return false;
261 }
262 self.version.fetch_add(1, Ordering::Release);
263 true
264 }
265
266 pub fn is_busy(&self) -> bool {
267 self.busy.load(Ordering::Acquire)
268 }
269
270 pub fn set_notice(&self, text: Option<String>) -> bool {
283 let Ok(mut slot) = self.notice.write() else {
284 return false;
285 };
286 if *slot == text {
287 return false;
288 }
289 *slot = text;
290 self.version.fetch_add(1, Ordering::Release);
291 true
292 }
293
294 pub fn notice(&self) -> Option<String> {
295 self.notice.read().ok().and_then(|n| n.clone())
296 }
297
298 pub fn content_version(&self) -> u64 {
302 self.version.load(Ordering::Acquire)
303 }
304
305 pub fn text(&self) -> String {
309 self.fields
310 .read()
311 .map(|f| {
312 let notice = self.notice();
313 f.iter()
314 .map(|f| f.text.to_string())
315 .chain(self.is_busy().then(|| BUSY_TEXT.to_string()))
316 .chain(notice)
317 .collect::<Vec<_>>()
318 .join(SEP)
319 })
320 .unwrap_or_default()
321 }
322}
323
324impl std::fmt::Debug for MagitHeaderline {
325 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
326 f.debug_struct("MagitHeaderline")
327 .field("version", &self.content_version())
328 .field("text", &self.text())
329 .finish()
330 }
331}
332
333impl Headerline for MagitHeaderline {
334 fn version(&self) -> u64 {
335 let theme_version = self
340 .elements
341 .as_ref()
342 .map(|e| e.theme.resolved().version())
343 .unwrap_or(0);
344 self.content_version().wrapping_add(theme_version)
345 }
346
347 fn render(&self) -> Option<HeaderlineRow> {
348 let fields = self.fields.read().ok()?;
349 if fields.is_empty() {
350 return None;
354 }
355 let resolved = self.elements.as_ref().map(|e| (e, e.theme.resolved()));
356 let fg = |style: FieldStyle| -> u32 {
357 resolved
358 .as_ref()
359 .and_then(|(e, table)| table.get(e.id_for(style)).fg)
360 .map(|c| c.to_rgb_u32(0))
361 .unwrap_or_else(|| fallback_fg(style))
362 };
363
364 let mut cells: Vec<Cell> = Vec::new();
365 let label_fg = fg(FieldStyle::Label);
366 cells.push(Cell::new(' ' as u32, label_fg, 0, 0));
367 let busy = self.is_busy().then_some(Field::label(BUSY_TEXT));
370 let notice = self.notice().map(Field::alert);
375 for (i, field) in fields
376 .iter()
377 .chain(busy.iter())
378 .chain(notice.iter())
379 .enumerate()
380 {
381 if i > 0 {
382 cells.extend(SEP.chars().map(|c| Cell::new(c as u32, label_fg, 0, 0)));
383 }
384 let colour = fg(field.style);
385 cells.extend(
386 field
387 .text
388 .chars()
389 .map(|c| Cell::new(c as u32, colour, 0, 0)),
390 );
391 }
392 cells.push(Cell::new(' ' as u32, label_fg, 0, 0));
393
394 Some(HeaderlineRow {
395 cells: cells.into(),
396 bg: None,
397 })
398 }
399}
400
401pub struct HeaderlineRegistration {
408 registrar: Arc<dyn VirtualRowRegistrar>,
409 buffer: BufferId,
410}
411
412impl Drop for HeaderlineRegistration {
413 fn drop(&mut self) {
414 self.registrar
415 .unregister(self.buffer, MAGIT_HEADERLINE_PROVIDER_ID);
416 }
417}
418
419pub fn install(
428 ctx: &ModeContext,
429 buffer: BufferId,
430 mode_id: &str,
431) -> Option<(MagitHeaderlineHandle, HeaderlineRegistration)> {
432 let registrar: Arc<dyn VirtualRowRegistrar> = ctx
433 .service::<Arc<dyn VirtualRowRegistrar>>()
434 .map(|outer| (*outer).clone())?;
435
436 let theme = ctx
437 .service::<ThemeRegistryHandle>()
438 .map(|outer| (*outer).clone());
439 let headerline = MagitHeaderline::new(theme, mode_id);
440
441 let provider = Arc::new(HeaderlineProvider::new(
442 MAGIT_HEADERLINE_PROVIDER_ID,
443 headerline.clone() as Arc<dyn Headerline>,
444 ));
445 registrar.unregister(buffer, MAGIT_HEADERLINE_PROVIDER_ID);
449 registrar.register(buffer, provider);
450
451 Some((headerline, HeaderlineRegistration { registrar, buffer }))
452}
453
454fn resolve_elements(theme: ThemeRegistryHandle, mode_id: &str) -> FieldElements {
463 let owner = ElementOwner::Mode(mode_id.to_string().into());
464 let alert = theme.register(
465 ElementName::from_static("magit.headerline.alert"),
466 owner.clone(),
467 StyleSpec::new().fg(ColorRef::Palette("red".into())).bold(),
468 "Magit headerline: a state the user must not miss (`AMEND`, `REBASE IN PROGRESS`).",
469 );
470 let label = theme.register(
471 ElementName::from_static("magit.headerline.label"),
472 owner,
473 StyleSpec::new().fg(ColorRef::Palette("muted".into())),
474 "Magit headerline: supporting detail — counts, paths, scopes, dates, separators.",
475 );
476 let by_name = |name: &'static str| {
477 theme
478 .id(&ElementName::from_static(name))
479 .unwrap_or(ElementId::INVALID)
480 };
481 let (sha, branch, reference, author) = (
482 by_name("magit.sha"),
483 by_name("magit.branch.current"),
484 by_name("magit.ref.decoration"),
485 by_name("magit.author"),
486 );
487 FieldElements {
488 theme,
489 sha,
490 branch,
491 reference,
492 author,
493 alert,
494 label,
495 }
496}
497
498pub(crate) fn intern_blame_heading_elements(
504 theme: &ThemeRegistryHandle,
505 mode_id: &str,
506) -> (ElementId, ElementId) {
507 let e = resolve_elements(theme.clone(), mode_id);
508 (e.sha, e.label)
509}
510
511pub(crate) fn blame_heading_fallback() -> (u32, u32) {
514 (fallback_fg(FieldStyle::Sha), fallback_fg(FieldStyle::Label))
515}
516
517pub(crate) fn publish(handle: &Option<MagitHeaderlineHandle>, fields: Vec<Field>) {
521 if let Some(h) = handle {
522 h.set(fields);
523 }
524}
525
526pub(crate) fn publish_notice(handle: &Option<MagitHeaderlineHandle>, text: Option<String>) {
537 if let Some(h) = handle {
538 h.set_notice(text);
539 }
540}
541
542#[must_use = "the row stays busy until this guard drops"]
555pub(crate) fn busy(handle: &Option<MagitHeaderlineHandle>) -> BusyGuard {
556 if let Some(h) = handle {
557 h.set_busy(true);
558 }
559 BusyGuard(handle.clone())
560}
561
562pub(crate) struct BusyGuard(Option<MagitHeaderlineHandle>);
563
564impl Drop for BusyGuard {
565 fn drop(&mut self) {
566 if let Some(h) = &self.0 {
567 h.set_busy(false);
568 }
569 }
570}
571
572use std::path::Path;
581
582use crate::sections::{SectionIndex, SectionKind};
583
584pub(crate) fn repo_name(workdir: &Path) -> String {
588 workdir
589 .file_name()
590 .map(|n| n.to_string_lossy().into_owned())
591 .unwrap_or_default()
592}
593
594pub(crate) fn status_fields(index: &SectionIndex, workdir: &Path) -> Vec<Field> {
596 let mut fields = Vec::new();
597 let repo = repo_name(workdir);
598 if !repo.is_empty() {
599 fields.push(Field::label(repo));
600 }
601 if !index.branch.is_empty() {
602 let mut branch = index.branch.clone();
603 if index.ahead > 0 {
604 branch.push_str(&format!(" \u{2191}{}", index.ahead));
605 }
606 if index.behind > 0 {
607 branch.push_str(&format!(" \u{2193}{}", index.behind));
608 }
609 fields.push(Field::branch(branch));
610 }
611 let count = |kind: SectionKind| {
612 index
613 .sections
614 .iter()
615 .find(|s| s.kind == kind)
616 .map(|s| s.entries.len())
617 .unwrap_or(0)
618 };
619 if let Some(bisect) = &index.bisect {
625 fields.push(Field::alert(bisect_label(bisect)));
626 }
627 if let Some(op) = index.in_flight {
639 fields.push(Field::alert(op.label()));
640 }
641 let (staged, unstaged, untracked) = (
642 count(SectionKind::Staged),
643 count(SectionKind::Unstaged),
644 count(SectionKind::Untracked),
645 );
646 if staged == 0 && unstaged == 0 && untracked == 0 {
647 fields.push(Field::label("clean"));
648 return fields;
649 }
650 if staged > 0 {
651 fields.push(Field::label(format!("{staged} staged")));
652 }
653 if unstaged > 0 {
654 fields.push(Field::label(format!("{unstaged} unstaged")));
655 }
656 if untracked > 0 {
657 fields.push(Field::label(format!("{untracked} untracked")));
658 }
659 fields
660}
661
662pub(crate) fn diff_counts(diff: &str) -> (usize, usize, usize) {
666 let mut files = 0;
667 let mut added = 0;
668 let mut removed = 0;
669 for line in diff.lines() {
670 if line.starts_with("diff --git") {
671 files += 1;
672 } else if line.starts_with("+++") || line.starts_with("---") {
673 continue;
674 } else if line.starts_with('+') {
675 added += 1;
676 } else if line.starts_with('-') {
677 removed += 1;
678 }
679 }
680 (files, added, removed)
681}
682
683pub(crate) fn commit_fields(branch: &str, staged_diff: &str, amend: bool) -> Vec<Field> {
686 let mut fields = Vec::new();
687 if !branch.is_empty() {
688 fields.push(Field::branch(branch.to_string()));
689 }
690 let (files, added, removed) = diff_counts(staged_diff);
691 if files == 0 {
692 fields.push(Field::label("nothing staged"));
693 } else {
694 let plural = if files == 1 { "file" } else { "files" };
695 fields.push(Field::label(format!(
696 "{files} {plural} +{added} \u{2212}{removed}"
697 )));
698 }
699 if amend {
700 fields.push(Field::alert("AMEND"));
701 }
702 fields
703}
704
705#[derive(Debug, Default, Clone, PartialEq, Eq)]
707pub(crate) struct RevisionMeta {
708 pub sha: String,
709 pub author: String,
710 pub date: String,
711 pub subject: String,
712}
713
714pub(crate) fn parse_revision_meta(raw: &str) -> RevisionMeta {
719 let mut parts = raw.trim_end_matches('\n').split('\0');
720 RevisionMeta {
721 sha: parts.next().unwrap_or_default().to_string(),
722 author: parts.next().unwrap_or_default().to_string(),
723 date: parts.next().unwrap_or_default().to_string(),
724 subject: parts.next().unwrap_or_default().to_string(),
725 }
726}
727
728pub(crate) fn revision_fields(meta: &RevisionMeta) -> Vec<Field> {
730 let mut fields = Vec::new();
731 if !meta.sha.is_empty() {
732 fields.push(Field::sha(meta.sha.clone()));
733 }
734 if !meta.author.is_empty() {
735 fields.push(Field::author(meta.author.clone()));
736 }
737 if !meta.date.is_empty() {
738 fields.push(Field::label(meta.date.clone()));
739 }
740 if !meta.subject.is_empty() {
741 fields.push(Field::label(meta.subject.clone()));
742 }
743 fields
744}
745
746pub(crate) fn file_revision_fields(git_ref: &str, path: &Path) -> Vec<Field> {
750 let mut fields = vec![Field::label(path.display().to_string()), Field::label("@")];
751 if git_ref == "staged" {
752 fields.push(Field::git_ref("index"));
753 } else {
754 fields.push(Field::sha(git_ref.to_string()));
755 }
756 fields
757}
758
759pub(crate) fn diff_fields(scope: &str, path: Option<&Path>) -> Vec<Field> {
761 let mut fields = vec![Field::git_ref(scope.to_string())];
762 if let Some(p) = path {
763 fields.push(Field::label(p.display().to_string()));
764 }
765 fields
766}
767
768pub(crate) fn log_fields(git_ref: &str, commits: usize, path: Option<&Path>) -> Vec<Field> {
772 let mut fields = vec![Field::git_ref(git_ref.to_string())];
773 let plural = if commits == 1 { "commit" } else { "commits" };
774 fields.push(Field::label(format!("{commits} {plural}")));
775 if let Some(p) = path {
776 fields.push(Field::label(p.display().to_string()));
777 }
778 fields
779}
780
781pub(crate) fn branch_fields(current: &str, total: usize) -> Vec<Field> {
786 let mut fields = Vec::new();
787 if !current.is_empty() {
788 fields.push(Field::branch(current.to_string()));
789 }
790 let plural = if total == 1 { "branch" } else { "branches" };
791 fields.push(Field::label(format!("{total} {plural}")));
792 fields
793}
794
795pub(crate) fn cherry_fields(
802 upstream: &str,
803 head: &str,
804 ahead: usize,
805 equivalent: usize,
806) -> Vec<Field> {
807 let mut fields = Vec::new();
808 if !head.is_empty() {
809 fields.push(Field::branch(head.to_string()));
810 }
811 if !upstream.is_empty() {
812 fields.push(Field::git_ref(format!("vs {upstream}")));
813 }
814 fields.push(Field::label(format!("{ahead} ahead")));
815 if equivalent > 0 {
816 fields.push(Field::label(format!("{equivalent} already upstream")));
817 }
818 fields
819}
820
821pub(crate) fn note_fields(meta: &RevisionMeta, has_existing: bool) -> Vec<Field> {
831 let mut fields = vec![Field::label(
832 if has_existing {
833 "editing note"
834 } else {
835 "new note"
836 }
837 .to_string(),
838 )];
839 fields.extend(revision_fields(meta));
840 fields
841}
842
843pub(crate) fn refs_fields(branches: usize, remotes: usize, tags: usize) -> Vec<Field> {
851 let mut fields = Vec::new();
852 for (n, singular, plural) in [
853 (branches, "branch", "branches"),
854 (remotes, "remote", "remotes"),
855 (tags, "tag", "tags"),
856 ] {
857 if n > 0 {
858 let word = if n == 1 { singular } else { plural };
859 fields.push(Field::label(format!("{n} {word}")));
860 }
861 }
862 if fields.is_empty() {
863 fields.push(Field::label("no refs".to_string()));
864 }
865 fields
866}
867
868pub(crate) fn bisect_label(state: &lattice_vcs::BisectState) -> String {
877 match (state.revisions_left, state.steps) {
878 (Some(left), Some(steps)) => format!("BISECTING {left} left, ~{steps} steps"),
879 (Some(left), None) => format!("BISECTING {left} left"),
880 _ => "BISECTING".to_string(),
881 }
882}
883
884pub(crate) fn submodule_fields(entries: &[lattice_vcs::SubmoduleEntry]) -> Vec<Field> {
891 use lattice_vcs::SubmoduleState;
892 let plural = if entries.len() == 1 {
893 "submodule"
894 } else {
895 "submodules"
896 };
897 let mut fields = vec![Field::label(format!("{} {plural}", entries.len()))];
898 let uninit = entries
899 .iter()
900 .filter(|e| e.state == SubmoduleState::Uninitialised)
901 .count();
902 if uninit > 0 {
903 fields.push(Field::alert(format!("{uninit} uninitialised")));
904 }
905 let modified = entries
906 .iter()
907 .filter(|e| e.state == SubmoduleState::Modified)
908 .count();
909 if modified > 0 {
910 fields.push(Field::label(format!("{modified} modified")));
911 }
912 let conflicted = entries
913 .iter()
914 .filter(|e| e.state == SubmoduleState::Conflicted)
915 .count();
916 if conflicted > 0 {
917 fields.push(Field::alert(format!("{conflicted} conflicted")));
918 }
919 fields
920}
921
922pub(crate) fn remote_fields(total: usize) -> Vec<Field> {
924 let plural = if total == 1 { "remote" } else { "remotes" };
925 vec![Field::label(format!("{total} {plural}"))]
926}
927
928pub(crate) fn stash_fields(total: usize) -> Vec<Field> {
930 let plural = if total == 1 { "stash" } else { "stashes" };
931 vec![Field::label(format!("{total} {plural}"))]
932}
933
934pub(crate) fn stash_show_fields(index: usize, message: &str) -> Vec<Field> {
938 let mut fields = vec![Field::git_ref(format!("stash@{{{index}}}"))];
939 if !message.is_empty() {
940 fields.push(Field::label(message.to_string()));
941 }
942 fields
943}
944
945pub(crate) fn rebase_fields(upstream: &str, commits: usize, in_progress: bool) -> Vec<Field> {
949 let mut fields = Vec::new();
950 if !upstream.is_empty() {
951 fields.push(Field::label("onto"));
952 fields.push(Field::git_ref(upstream.to_string()));
953 }
954 let plural = if commits == 1 { "commit" } else { "commits" };
955 fields.push(Field::label(format!("{commits} {plural}")));
956 if in_progress {
957 fields.push(Field::alert("REBASE IN PROGRESS"));
958 }
959 fields
960}
961
962#[cfg(test)]
963mod tests {
964 use super::*;
965
966 fn bare(fields: Vec<Field>) -> MagitHeaderlineHandle {
967 let hl = MagitHeaderline::new(None, "test");
968 hl.set(fields);
969 hl
970 }
971
972 #[test]
973 fn empty_fields_hide_the_row() {
974 assert!(bare(Vec::new()).render().is_none());
975 }
976
977 #[test]
978 fn render_emits_one_padded_row_of_every_field() {
979 let hl = bare(vec![Field::branch("main"), Field::label("3 staged")]);
980 let row = hl.render().expect("non-empty fields render");
981 let text: String = row
982 .cells
983 .iter()
984 .map(|c| char::from_u32(c.codepoint).unwrap_or(' '))
985 .collect();
986 assert_eq!(text, " main 3 staged ");
987 }
988
989 #[test]
990 fn each_field_paints_in_its_own_colour() {
991 let hl = bare(vec![Field::sha("a1b2c3d"), Field::label("today")]);
992 let row = hl.render().unwrap();
993 let sha_fg = row.cells[1].fg;
994 let label_fg = row.cells[row.cells.len() - 2].fg;
995 assert_eq!(sha_fg, fallback_fg(FieldStyle::Sha));
996 assert_eq!(label_fg, fallback_fg(FieldStyle::Label));
997 assert_ne!(sha_fg, label_fg, "roles must be distinguishable by colour");
998 }
999
1000 #[test]
1005 fn setting_identical_fields_does_not_advance_the_version() {
1006 let hl = bare(Vec::new());
1007 assert!(hl.set(vec![Field::branch("main")]), "first set changes");
1008 let v = hl.content_version();
1009 assert!(!hl.set(vec![Field::branch("main")]), "identical is no-work");
1010 assert_eq!(hl.content_version(), v);
1011 }
1012
1013 #[test]
1016 fn a_changed_refresh_bumps_the_version_exactly_once() {
1017 let hl = bare(Vec::new());
1018 hl.set(vec![Field::branch("main"), Field::label("3 staged")]);
1019 let v = hl.content_version();
1020 assert!(hl.set(vec![Field::branch("main"), Field::label("4 staged")]));
1021 assert_eq!(hl.content_version(), v + 1);
1022 }
1023
1024 #[test]
1034 fn dropping_the_registration_unregisters_the_provider() {
1035 #[derive(Default)]
1036 struct FakeRegistrar {
1037 unregistered: std::sync::Mutex<Vec<(BufferId, ProviderId)>>,
1038 }
1039 impl VirtualRowRegistrar for FakeRegistrar {
1040 fn register(
1041 &self,
1042 _buffer: BufferId,
1043 _provider: Arc<dyn lattice_cells::VirtualRowProvider>,
1044 ) -> bool {
1045 true
1046 }
1047 fn unregister(&self, buffer: BufferId, id: ProviderId) -> bool {
1048 self.unregistered.lock().unwrap().push((buffer, id));
1049 true
1050 }
1051 }
1052
1053 let registrar = Arc::new(FakeRegistrar::default());
1054 let registration = HeaderlineRegistration {
1055 registrar: registrar.clone(),
1056 buffer: BufferId(7),
1057 };
1058 assert!(registrar.unregistered.lock().unwrap().is_empty());
1059 drop(registration);
1060 assert_eq!(
1061 *registrar.unregistered.lock().unwrap(),
1062 vec![(BufferId(7), MAGIT_HEADERLINE_PROVIDER_ID)],
1063 "the mode owns its full surface — teardown included"
1064 );
1065 }
1066
1067 #[test]
1068 fn text_joins_fields_with_the_separator() {
1069 let hl = bare(vec![
1070 Field::git_ref("origin/main"),
1071 Field::label("4 commits"),
1072 ]);
1073 assert_eq!(hl.text(), "origin/main 4 commits");
1074 }
1075
1076 fn rendered(fields: Vec<Field>) -> String {
1085 assert!(!fields.is_empty(), "a view must publish a non-empty row");
1086 bare(fields).text()
1087 }
1088
1089 fn status_index(branch: &str, ahead: usize, behind: usize) -> SectionIndex {
1090 SectionIndex {
1091 sections: Vec::new(),
1092 branch: branch.to_string(),
1093 ahead,
1094 behind,
1095 bisect: None,
1096 in_flight: None,
1097 upstream: None,
1098 }
1099 }
1100
1101 fn bisecting(
1102 mut index: SectionIndex,
1103 revisions_left: Option<usize>,
1104 steps: Option<usize>,
1105 ) -> SectionIndex {
1106 index.bisect = Some(lattice_vcs::BisectState {
1107 revisions_left,
1108 steps,
1109 start_ref: "main".into(),
1110 });
1111 index
1112 }
1113
1114 #[test]
1118 fn the_bisect_alert_shows_on_a_clean_tree() {
1119 let row = rendered(status_fields(
1120 &bisecting(status_index("main", 0, 0), Some(3), Some(2)),
1121 Path::new("/src/lattice"),
1122 ));
1123 assert!(
1124 row.contains("BISECTING 3 left, ~2 steps"),
1125 "clean-tree row lost the alert: {row}"
1126 );
1127 assert!(
1128 row.contains("clean"),
1129 "and still says the tree is clean: {row}"
1130 );
1131 }
1132
1133 #[test]
1134 fn the_bisect_alert_shows_alongside_dirty_counts() {
1135 let index = with_section(
1136 bisecting(status_index("main", 0, 0), Some(1), Some(1)),
1137 SectionKind::Unstaged,
1138 2,
1139 );
1140 let row = rendered(status_fields(&index, Path::new("/src/lattice")));
1141 assert!(row.contains("BISECTING"), "{row}");
1142 assert!(row.contains("2 unstaged"), "{row}");
1143 }
1144
1145 #[test]
1148 fn the_bisect_alert_degrades_when_git_has_no_numbers() {
1149 let state = lattice_vcs::BisectState {
1150 revisions_left: None,
1151 steps: None,
1152 start_ref: "main".into(),
1153 };
1154 assert_eq!(bisect_label(&state), "BISECTING");
1155 assert_eq!(
1156 bisect_label(&lattice_vcs::BisectState {
1157 revisions_left: Some(4),
1158 steps: None,
1159 start_ref: "main".into(),
1160 }),
1161 "BISECTING 4 left"
1162 );
1163 }
1164
1165 #[test]
1166 fn no_bisect_means_no_alert() {
1167 let row = rendered(status_fields(
1168 &status_index("main", 0, 0),
1169 Path::new("/src/lattice"),
1170 ));
1171 assert!(!row.contains("BISECT"), "{row}");
1172 }
1173
1174 fn with_section(mut index: SectionIndex, kind: SectionKind, entries: usize) -> SectionIndex {
1175 use crate::sections::{Section, SectionEntry};
1176 index.sections.push(Section {
1177 kind,
1178 header_line: 0,
1179 body_start: 1,
1180 body_end: 1 + entries,
1181 entries: (0..entries)
1182 .map(|i| SectionEntry::File {
1183 path: std::path::PathBuf::from(format!("f{i}.rs")),
1184 status: lattice_vcs::PathStatus::Modified,
1185 original_path: None,
1186 })
1187 .collect(),
1188 });
1189 index
1190 }
1191
1192 #[test]
1193 fn status_row_carries_branch_ahead_behind_and_counts() {
1194 let index = with_section(
1195 with_section(status_index("main", 2, 1), SectionKind::Staged, 3),
1196 SectionKind::Unstaged,
1197 5,
1198 );
1199 let row = rendered(status_fields(&index, Path::new("/src/lattice")));
1200 assert_eq!(
1201 row,
1202 "lattice main \u{2191}2 \u{2193}1 3 staged 5 unstaged"
1203 );
1204 }
1205
1206 #[test]
1207 fn status_row_says_clean_rather_than_listing_three_zeroes() {
1208 let row = rendered(status_fields(&status_index("main", 0, 0), Path::new("/x")));
1209 assert_eq!(row, "x main clean");
1210 }
1211
1212 #[test]
1213 fn diff_counts_counts_files_adds_and_removes_ignoring_file_markers() {
1214 let diff = "diff --git a/x b/x\n--- a/x\n+++ b/x\n@@ -1 +1,2 @@\n+one\n+two\n-gone\n ctx\n";
1215 assert_eq!(diff_counts(diff), (1, 2, 1));
1216 }
1217
1218 #[test]
1219 fn commit_row_carries_branch_staged_counts_and_the_amend_marker() {
1220 let diff = "diff --git a/x b/x\n--- a/x\n+++ b/x\n+one\n-two\n";
1221 assert_eq!(
1222 rendered(commit_fields("main", diff, false)),
1223 "main 1 file +1 \u{2212}1"
1224 );
1225 assert!(
1226 rendered(commit_fields("main", diff, true)).contains("AMEND"),
1227 "amend must be visible — it rewrites history"
1228 );
1229 }
1230
1231 #[test]
1232 fn commit_row_names_an_empty_index_rather_than_showing_zeroes() {
1233 assert_eq!(
1234 rendered(commit_fields("main", "", false)),
1235 "main nothing staged"
1236 );
1237 }
1238
1239 #[test]
1240 fn revision_row_carries_sha_author_date_and_subject() {
1241 let meta = parse_revision_meta("a1b2c3d\x00Jane Doe\x003 days ago\x00Fix the thing\n");
1245 assert_eq!(
1246 meta,
1247 RevisionMeta {
1248 sha: "a1b2c3d".into(),
1249 author: "Jane Doe".into(),
1250 date: "3 days ago".into(),
1251 subject: "Fix the thing".into(),
1252 }
1253 );
1254 assert_eq!(
1255 rendered(revision_fields(&meta)),
1256 "a1b2c3d Jane Doe 3 days ago Fix the thing"
1257 );
1258 }
1259
1260 #[test]
1263 fn revision_meta_tolerates_a_short_read() {
1264 let meta = parse_revision_meta("a1b2c3d\0Jane Doe");
1265 assert_eq!(meta.date, "");
1266 assert_eq!(rendered(revision_fields(&meta)), "a1b2c3d Jane Doe");
1267 }
1268
1269 #[test]
1270 fn file_revision_row_reads_path_at_ref() {
1271 assert_eq!(
1272 rendered(file_revision_fields("a1b2c3d", Path::new("src/main.rs"))),
1273 "src/main.rs @ a1b2c3d"
1274 );
1275 }
1276
1277 #[test]
1278 fn file_revision_row_names_the_staged_pseudo_ref_index() {
1279 assert_eq!(
1280 rendered(file_revision_fields("staged", Path::new("src/main.rs"))),
1281 "src/main.rs @ index"
1282 );
1283 }
1284
1285 #[test]
1286 fn diff_row_carries_scope_and_optional_path() {
1287 assert_eq!(rendered(diff_fields("HEAD", None)), "HEAD");
1288 assert_eq!(
1289 rendered(diff_fields("staged", Some(Path::new("src/main.rs")))),
1290 "staged src/main.rs"
1291 );
1292 }
1293
1294 #[test]
1295 fn log_row_carries_ref_commit_count_and_path_filter() {
1296 assert_eq!(rendered(log_fields("HEAD", 50, None)), "HEAD 50 commits");
1297 assert_eq!(
1298 rendered(log_fields("HEAD", 1, Some(Path::new("src/main.rs")))),
1299 "HEAD 1 commit src/main.rs"
1300 );
1301 }
1302
1303 #[test]
1304 fn branch_row_carries_current_branch_and_total() {
1305 assert_eq!(rendered(branch_fields("main", 12)), "main 12 branches");
1306 assert_eq!(rendered(branch_fields("main", 1)), "main 1 branch");
1307 }
1308
1309 #[test]
1310 fn submodule_row_counts_and_flags_the_ones_needing_attention() {
1311 use lattice_vcs::{SubmoduleEntry, SubmoduleState as St};
1312 let e = |state| SubmoduleEntry {
1313 state,
1314 sha: "abc".into(),
1315 path: "vendor/x".into(),
1316 describe: String::new(),
1317 };
1318 assert_eq!(rendered(submodule_fields(&[e(St::InSync)])), "1 submodule");
1319 let row = rendered(submodule_fields(&[
1320 e(St::InSync),
1321 e(St::Uninitialised),
1322 e(St::Modified),
1323 e(St::Conflicted),
1324 ]));
1325 assert!(row.contains("4 submodules"), "{row}");
1326 assert!(row.contains("1 uninitialised"), "{row}");
1327 assert!(row.contains("1 modified"), "{row}");
1328 assert!(row.contains("1 conflicted"), "{row}");
1329 }
1330
1331 #[test]
1332 fn an_all_clean_submodule_row_says_nothing_more_than_the_count() {
1333 use lattice_vcs::{SubmoduleEntry, SubmoduleState as St};
1334 let e = SubmoduleEntry {
1335 state: St::InSync,
1336 sha: "abc".into(),
1337 path: "vendor/x".into(),
1338 describe: String::new(),
1339 };
1340 let row = rendered(submodule_fields(&[e.clone(), e]));
1341 assert_eq!(row, "2 submodules", "no zero-counts padding the row: {row}");
1342 }
1343
1344 #[test]
1347 fn a_busy_row_says_so_after_its_fields() {
1348 let hl = bare(vec![Field::branch("main"), Field::label("clean")]);
1349 assert_eq!(hl.text(), "main clean");
1350 assert!(hl.set_busy(true));
1351 assert_eq!(hl.text(), "main clean refreshing");
1352 assert!(hl.set_busy(false));
1353 assert_eq!(hl.text(), "main clean");
1354 }
1355
1356 #[test]
1360 fn a_notice_says_so_after_its_fields() {
1361 let hl = bare(vec![Field::branch("main"), Field::label("clean")]);
1362 assert!(hl.set_notice(Some("no changes in a.rs — press gr to refresh".into())));
1363 assert_eq!(
1364 hl.text(),
1365 "main clean no changes in a.rs — press gr to refresh"
1366 );
1367 }
1368
1369 #[test]
1382 fn a_refresh_clears_the_notice_even_when_nothing_changed() {
1383 let fields = vec![Field::label("clean")];
1384 let hl = bare(fields.clone());
1385 assert!(hl.set_notice(Some("no changes in a.rs".into())));
1386 assert!(hl.notice().is_some());
1387
1388 hl.set(fields);
1390 assert_eq!(
1391 hl.notice(),
1392 None,
1393 "a refresh that found identical data is still a refresh, and \
1394 it has just disproved the warning"
1395 );
1396 assert_eq!(hl.text(), "clean");
1397 }
1398
1399 #[test]
1401 fn a_notice_and_the_busy_marker_are_independent() {
1402 let hl = bare(vec![Field::label("clean")]);
1403 hl.set_notice(Some("no changes".into()));
1404 hl.set_busy(true);
1405 assert_eq!(hl.text(), "clean refreshing no changes");
1406 hl.set_busy(false);
1407 assert_eq!(hl.text(), "clean no changes");
1408 }
1409
1410 #[test]
1414 fn publishing_fields_does_not_clear_the_busy_marker() {
1415 let hl = bare(vec![Field::label("clean")]);
1416 hl.set_busy(true);
1417 hl.set(vec![Field::branch("main"), Field::label("2 staged")]);
1418 assert!(hl.is_busy(), "a field publish must not clear busy");
1419 assert!(hl.text().ends_with("refreshing"), "{}", hl.text());
1420 }
1421
1422 #[test]
1425 fn setting_busy_to_what_it_already_is_bumps_no_version() {
1426 let hl = bare(vec![Field::label("clean")]);
1427 let before = hl.content_version();
1428 assert!(hl.set_busy(true));
1429 let after_set = hl.content_version();
1430 assert!(after_set > before);
1431 assert!(!hl.set_busy(true), "no change, no bump");
1432 assert_eq!(hl.content_version(), after_set);
1433 }
1434
1435 #[test]
1438 fn the_busy_guard_clears_on_drop() {
1439 let hl = Some(bare(vec![Field::label("clean")]));
1440 {
1441 let _guard = busy(&hl);
1442 assert!(hl.as_ref().unwrap().is_busy());
1443 }
1444 assert!(
1445 !hl.as_ref().unwrap().is_busy(),
1446 "dropping the guard must clear it, however the scope was left"
1447 );
1448 }
1449
1450 #[test]
1451 fn the_busy_guard_is_harmless_without_a_headerline() {
1452 let none: Option<MagitHeaderlineHandle> = None;
1455 drop(busy(&none));
1456 }
1457
1458 #[test]
1462 fn a_busy_row_still_renders_every_field() {
1463 let hl = bare(vec![Field::branch("main"), Field::label("3 staged")]);
1464 hl.set_busy(true);
1465 let row = hl.render().expect("non-empty fields render");
1466 let text: String = row
1467 .cells
1468 .iter()
1469 .map(|c| char::from_u32(c.codepoint).unwrap_or(' '))
1470 .collect();
1471 assert_eq!(text, " main 3 staged refreshing ");
1472 }
1473
1474 #[test]
1475 fn remote_row_carries_the_count() {
1476 assert_eq!(rendered(remote_fields(2)), "2 remotes");
1477 assert_eq!(rendered(remote_fields(1)), "1 remote");
1478 assert_eq!(rendered(remote_fields(0)), "0 remotes");
1479 }
1480
1481 #[test]
1482 fn stash_row_carries_the_count() {
1483 assert_eq!(rendered(stash_fields(3)), "3 stashes");
1484 assert_eq!(rendered(stash_fields(0)), "0 stashes");
1485 }
1486
1487 #[test]
1494 fn status_headerline_announces_the_operation_in_flight() {
1495 for op in lattice_vcs::InFlightOp::ALL {
1496 let mut index = status_index("main", 0, 0);
1497 index.in_flight = Some(op);
1498 let row = rendered(status_fields(&index, std::path::Path::new("/tmp/repo")));
1499 assert!(
1500 row.contains(op.label()),
1501 "{:?} must be announced, got {row}",
1502 op
1503 );
1504 }
1505 }
1506
1507 #[test]
1511 fn the_in_flight_alert_survives_a_clean_tree() {
1512 let mut index = status_index("main", 0, 0);
1513 index.in_flight = Some(lattice_vcs::InFlightOp::Rebase);
1514 let row = rendered(status_fields(&index, std::path::Path::new("/tmp/repo")));
1515 assert!(row.contains("REBASING"), "{row}");
1516 assert!(row.contains("clean"), "still reports the tree state: {row}");
1517 }
1518
1519 #[test]
1520 fn rebase_row_carries_upstream_count_and_in_progress_alert() {
1521 assert_eq!(
1522 rendered(rebase_fields("origin/main", 4, false)),
1523 "onto origin/main 4 commits"
1524 );
1525 assert!(
1526 rendered(rebase_fields("origin/main", 4, true)).contains("REBASE IN PROGRESS"),
1527 "an already-running rebase must be visible before C-c C-c compounds it"
1528 );
1529 }
1530}