Skip to main content

lattice_magit/
headerline.rs

1//! MG.14 — the sticky headerline every magit buffer carries.
2//!
3//! **What it answers.** Each magit view is a slab of git output whose
4//! identity lives outside the text: `*magit:diff*` does not say which
5//! scope it diffed, `*magit:blame:x.rs*` does not say which revision
6//! it walked back to, the status buffer never showed the branch at
7//! all (`SectionIndex::branch_status_line` was written for it and
8//! never called). The headerline is the one row that answers "what am
9//! I looking at?" without re-deriving it from the body.
10//!
11//! **One provider, every view.** There is exactly one [`Headerline`]
12//! impl here, and the per-view difference is *data*: a `Vec<Field>`,
13//! each field a string plus a [`FieldStyle`] naming its git role. No
14//! `match buffer_kind`, no per-kind impl — adding a view means adding
15//! a field-builder function, not a branch.
16//!
17//! **No work per tick.** The cells worker calls [`Headerline::version`]
18//! every tick and [`Headerline::render`] only when it advanced.
19//! [`MagitHeaderline::set`] compares before it bumps, so a refresh that
20//! finds the same branch and the same counts costs one comparison and
21//! no repaint (paramount goal #1). Fields are produced by the SAME
22//! blocking builder that produces the buffer's text — activation and
23//! `gr` alike — so the header never costs a git round-trip of its own.
24//!
25//! **Theme-live.** The row resolves its colours inside `render()` and
26//! folds the theme's resolved version into its own, so `:colorscheme`
27//! repaints the header instead of leaving it on the previous palette's
28//! colours. The two headerlines that shipped before this one
29//! (compilation, ai-conversation) capture `u32`s at activation and go
30//! stale; this is the better shape and the cost is one uncontended
31//! read-lock per tick.
32//!
33//! Design anchor: `docs/dev/architecture/headerline.md`, slice
34//! `docs/dev/operations/slice-plans/magit.md` §MG.14.
35
36use 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
46/// Provider id tag for magit's headerline. One per buffer scope, so a
47/// single constant covers every view — a magit buffer has exactly one
48/// major mode and therefore exactly one header.
49pub const MAGIT_HEADERLINE_PROVIDER_ID: ProviderId = 0x6d61_6769_745f_686c; // "magit_hl"
50
51/// Separator between fields. Two spaces rather than a glyph: the
52/// fields are already colour-separated, and a `·`/`|` chain reads as
53/// noise at this density.
54const SEP: &str = "  ";
55
56/// MG.27: what the row says while a refresh is running.
57///
58/// A word rather than a `⟳` glyph, which the slice title proposed.
59/// The icon-degradation rule wants a BMP fallback for every glyph
60/// surface, and `⟳` (U+27F3, Miscellaneous Symbols and Arrows) is not
61/// in the fallback set — while the toggle that would select between
62/// them, `ui.nerd-fonts`, is buffer-local to the file tree today, not a
63/// global option this row could read. A word costs three more columns
64/// on a row already full of words (`clean`, `3 staged`, `AMEND`) and
65/// works in every terminal font. The glyph is the natural upgrade once
66/// that toggle is global.
67const BUSY_TEXT: &str = "refreshing";
68
69// ── Fields ───────────────────────────────────────────────────────────
70
71/// The git role a header field plays. Maps to a theme element, which
72/// is what gives the row its identity-by-colour (the compact format
73/// carries no `Head:`-style labels).
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum FieldStyle {
76    /// A commit SHA (`magit.sha`).
77    Sha,
78    /// The checked-out / subject branch (`magit.branch.current`).
79    Branch,
80    /// Any other ref — upstream, rebase target, blamed revision
81    /// (`magit.ref.decoration`).
82    Ref,
83    /// A commit author (`magit.author`).
84    Author,
85    /// A state the user must not miss: `AMEND`, `REBASE IN PROGRESS`
86    /// (`magit.headerline.alert`).
87    Alert,
88    /// Counts, paths, scopes, dates — the supporting detail
89    /// (`magit.headerline.label`).
90    Label,
91}
92
93/// One coloured run in the header row.
94#[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
127/// Resolved theme handle + the element id per [`FieldStyle`]. Ids are
128/// interned once at install; the concrete colours are read per render
129/// so a live `:colorscheme` lands.
130struct 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
153/// Fallback colours for a harness with no theme registry — the header
154/// still renders, just in fixed tones rather than the active palette.
155fn 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
166// ── The provider ─────────────────────────────────────────────────────
167
168/// The one [`Headerline`] impl behind every magit buffer's header row.
169pub struct MagitHeaderline {
170    fields: RwLock<Vec<Field>>,
171    /// Bumped by [`Self::set`] only when the fields actually changed.
172    version: AtomicU64,
173    /// MG.27: a refresh is running. Orthogonal to `fields` — see
174    /// [`Self::set_busy`].
175    busy: std::sync::atomic::AtomicBool,
176    /// MG.56: a one-line notice about the LAST action, cleared by the
177
178    /// next refresh.
179
180    ///
181
182    /// Outside `fields` for the reason [`Self::set_busy`] spells out —
183
184    /// every refresh ends by calling [`Self::set`], which would wipe a
185
186    /// marker living in that vector. Here that wipe is exactly what is
187
188    /// wanted, so [`Self::set`] clears this deliberately: the notice
189
190    /// says "this view may be stale", and a refresh is both the fix and
191
192    /// the thing that makes the notice untrue.
193    notice: RwLock<Option<String>>,
194    /// `None` in a harness without a theme registry.
195    elements: Option<FieldElements>,
196}
197
198/// Cheap-clone handle. The mode keeps one in its per-buffer state so
199/// every refresh path can re-`set` the row; the registered provider
200/// holds another reference to the same allocation.
201pub type MagitHeaderlineHandle = Arc<MagitHeaderline>;
202
203impl MagitHeaderline {
204    /// Build a headerline that resolves its colours through `theme`
205    /// (`None` — a harness with no theme registry — falls back to
206    /// fixed tones). Starts empty, so it renders nothing until the
207    /// owning mode publishes its first fields.
208    ///
209    /// [`install`] wraps this and registers the result as a
210    /// virtual-row provider; benches construct one directly to measure
211    /// the row in isolation.
212    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    /// Replace the row's fields. Returns `true` when something changed
223    /// (and the version was bumped); `false` is the no-work path a
224    /// refresh that found identical data takes.
225    pub fn set(&self, fields: Vec<Field>) -> bool {
226        // Clear any notice FIRST, and unconditionally — before the
227        // identical-fields early return below. A refresh that happens
228        // to produce the same fields is still a refresh, and it is
229        // precisely the case where a stale-data warning has just been
230        // disproved; leaving the warning up there would be the one
231        // outcome the user cannot distinguish from the problem.
232        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    /// MG.27: mark a refresh as in flight (or finished).
245    ///
246    /// Kept OUT of `fields` deliberately. Every refresh path ends by
247    /// calling [`Self::set`] with freshly-computed fields, so a busy
248    /// marker living in that vector would be wiped by the very
249    /// completion it is supposed to survive until — and worse, would
250    /// have to be re-added by each builder, which is a rule every new
251    /// view could forget. A separate flag composes instead: builders
252    /// stay ignorant of it and cannot drop it.
253    ///
254    /// Returns `true` when the state actually changed, matching
255    /// [`Self::set`]'s contract — the version is only bumped then, so
256    /// a refresh that starts and finishes between two ticks costs no
257    /// repaint.
258    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    /// MG.56: set (or clear) the one-line notice about the last action.
271    ///
272    /// For the outcome that is neither success nor error: the operation
273    /// ran, git answered, and the answer was *nothing*. Toggling a
274    /// file's diff when the file no longer has the changes the buffer
275    /// still lists produces an empty patch, and an empty patch inserted
276    /// is indistinguishable from a key that did not fire — so the user
277    /// presses it again, and again.
278    ///
279    /// Returns `true` when the state actually changed, matching
280    /// [`Self::set`] and [`Self::set_busy`]: the version is only bumped
281    /// then, so re-notifying the same thing costs no repaint.
282    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    /// The row's own content version, ignoring the theme. Test seam —
299    /// [`Headerline::version`] folds the theme's version in, which
300    /// would make "did the content change?" unobservable on its own.
301    pub fn content_version(&self) -> u64 {
302        self.version.load(Ordering::Acquire)
303    }
304
305    /// The plain text of the current row, separators included. Used by
306    /// tests and `:describe-buffer`-style introspection; `render()` is
307    /// what the worker paints.
308    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        // Fold the theme's resolved version in so a palette swap
336        // repaints the row. `resolved()` is a read-lock plus an
337        // ArcSwap load — no rebuild unless the theme is dirty — and
338        // this runs on the cells worker, never the UI thread.
339        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            // Nothing known yet (the buffer opened, git has not
351            // answered). Hide the row rather than paint an empty bar
352            // that shifts the content down and back up again.
353            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        // MG.27: appended, not woven in, so it never displaces a field
368        // and a view that gains fields later needs no change here.
369        let busy = self.is_busy().then_some(Field::label(BUSY_TEXT));
370        // MG.56: the notice renders LAST and as an alert — it is about
371        // the thing you just did, so it belongs at the end of the row
372        // where the eye lands after reading the repo state, and it is
373        // the one field that is asking to be noticed.
374        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
401// ── Installation ─────────────────────────────────────────────────────
402
403/// Removes the buffer's headerline provider when the mode deactivates.
404///
405/// The mode owns its full surface: nothing else in the host knows this
406/// provider exists, so nothing else can clean it up.
407pub 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
419/// Build a headerline for `buffer` and register it as a virtual-row
420/// provider. Returns the handle the mode keeps (to `set` fields as its
421/// data lands) and the registration whose drop tears the row down.
422///
423/// Synchronous and cheap — call it above the first `.await` in
424/// `on_activate`, alongside the state publish, so the row exists (and
425/// stays hidden, rendering `None`) from the moment the buffer opens.
426/// Returns `None` only in a harness with no virtual-row registrar.
427pub 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    // `register` refuses to replace a live id, so clear whatever a
446    // previous activation on this buffer left behind — a reopened
447    // magit buffer must bind its own header, not keep the stale one.
448    registrar.unregister(buffer, MAGIT_HEADERLINE_PROVIDER_ID);
449    registrar.register(buffer, provider);
450
451    Some((headerline, HeaderlineRegistration { registrar, buffer }))
452}
453
454/// Intern the element ids the row paints with.
455///
456/// The four git-role colours are the MG.11 palette, already registered
457/// as builtins because `lattice-syntax`'s styled-span table resolves
458/// them by builtin id. The two header-only roles are registered HERE,
459/// owned by the mode — the host has no business naming a magit
460/// element that only magit paints. `register` is idempotent by name,
461/// so repeat activations re-intern rather than duplicate.
462fn 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
498/// MG.26b: the two element ids a blame chunk-heading paints with.
499///
500/// Reuses the same interning `resolve_elements` does — `register` is
501/// idempotent by name — so a heading's sha is the same colour as a
502/// sha anywhere else in magit, and a `:colorscheme` moves both.
503pub(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
511/// MG.26b: the fallback colours for those two, for a harness with no
512/// theme registry. Same table the headerline falls back to.
513pub(crate) fn blame_heading_fallback() -> (u32, u32) {
514    (fallback_fg(FieldStyle::Sha), fallback_fg(FieldStyle::Label))
515}
516
517/// Push `fields` into an optional handle — the shape every mode's
518/// refresh path uses, since `install` yields `None` in a stripped
519/// harness.
520pub(crate) fn publish(handle: &Option<MagitHeaderlineHandle>, fields: Vec<Field>) {
521    if let Some(h) = handle {
522        h.set(fields);
523    }
524}
525
526/// MG.56: put a one-line notice on the row, or clear it.
527///
528/// Same optional-handle shape as [`publish`], for the same reason:
529/// `install` yields `None` in a stripped harness and no caller should
530/// have to care.
531///
532/// The notice survives until the next refresh — which is the point.
533/// It is raised when an action produced nothing because the view has
534/// been overtaken by events, so `gr` is both what clears it and what
535/// makes it untrue.
536pub(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/// MG.27: mark this view busy until the returned guard drops.
543///
544/// **A guard rather than a matching pair of calls**, because a refresh
545/// has several ways out — an early `return` when the buffer handle is
546/// gone, a `spawn_blocking` that panics, a task cancelled when the
547/// buffer closes — and every one of them would leave the row saying
548/// "refreshing" forever. `Drop` runs on all of them. The one thing it
549/// cannot survive is the whole process going away, which takes the row
550/// with it.
551///
552/// Cheap when there is no headerline (a harness without a registry):
553/// the guard holds `None` and does nothing.
554#[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
572// ── Per-view field builders ──────────────────────────────────────────
573//
574// The views differ HERE and nowhere else: same provider, same
575// render path, different data. Each builder is pure, so what a view
576// claims to show is testable without opening a buffer or touching git.
577// Every builder runs inside the same `spawn_blocking` that produced
578// the buffer's text, from the primitives that call already had.
579
580use std::path::Path;
581
582use crate::sections::{SectionIndex, SectionKind};
583
584/// The repository's own name — the workdir's last path component.
585/// Present on the status header because a second lattice window on a
586/// second checkout is otherwise indistinguishable.
587pub(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
594/// magit-status: repo, branch, ahead/behind, dirty counts.
595pub(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    // MG.21f: the bisect alert goes in BEFORE the clean-tree early
620    // return. Bisecting a clean tree is the normal case — git checks
621    // out each candidate for you — so putting it after would hide the
622    // alert exactly when it is always true, and the user would be
623    // testing a detached HEAD with nothing on screen saying why.
624    if let Some(bisect) = &index.bisect {
625        fields.push(Field::alert(bisect_label(bisect)));
626    }
627    // The multi-commit operation the repo is stopped inside, for the
628    // same reason and in the same place as the bisect alert: BEFORE the
629    // clean-tree early return. A merge or rebase stopped on a conflict
630    // can leave the staged/unstaged counts looking ordinary, and the
631    // one moment the user most needs to know they are mid-rebase is the
632    // moment nothing else on screen says so.
633    //
634    // It also disambiguates the unmerged labels directly below it:
635    // "added by us" means the opposite thing during a rebase than
636    // during a merge, because a rebase replays your work onto the
637    // upstream (see `InFlightOp::ours_is_local`).
638    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
662/// Files touched / lines added / lines removed in a unified diff.
663/// Counts `diff --git` headers rather than `+++`/`---` pairs so a
664/// pure-rename or mode-change entry still counts as a file.
665pub(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
683/// magit-commit: the branch being committed to, what is staged, and
684/// whether this rewrites the previous commit.
685pub(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/// One commit's identity, as `git show -s` reports it.
706#[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
714/// Parse the NUL-separated `%h%x00%an%x00%ar%x00%s` format the
715/// revision view asks for. Chosen over scraping `git show`'s human
716/// header because that output is locale- and config-dependent
717/// (`log.date`, `i18n.logOutputEncoding`), while `--format` is not.
718pub(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
728/// magit-revision: short SHA, author, relative date, subject.
729pub(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
746/// magit-file-revision: `<path> @ <ref>`. The `staged` pseudo-ref
747/// reads as `@ index` — "staged" names how it got there, `index`
748/// names where it is, and the latter is what the user is looking at.
749pub(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
759/// magit-diff: which scope was diffed, and the path when file-scoped.
760pub(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
768/// magit-log: the ref being logged, how many commits are shown, and
769/// the path filter when file-scoped. `commits` counts rendered commit
770/// rows, not `--graph` connector lines.
771pub(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
781// magit-blame: the path and the revision currently blamed — `p`
782// walks the revision back, and without this the buffer gives no clue
783
784/// magit-branch: the checked-out branch and how many exist.
785pub(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
795/// MG.40 — magit-cherry: what is being compared, and the two counts.
796///
797/// The counts are the answer the buffer exists to give, and they are
798/// kept apart rather than summed: "3 ahead" and "3 already upstream"
799/// call for opposite actions, and a single total would hide which you
800/// are looking at.
801pub(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
821/// MG.37 — magit-notes: which commit the note belongs to, and whether
822/// there already is one.
823///
824/// The commit identity matters more here than in most magit buffers:
825/// this buffer is a bare text area with nothing in its *content* naming
826/// what it is attached to, so without the row a note written against
827/// the wrong commit looks identical to one written against the right
828/// one. "new" vs "editing" is the second half of that — it says whether
829/// `C-c C-c` will create or overwrite.
830pub(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
843/// MG.35 — magit-refs: how many of each kind.
844///
845/// Three counts rather than one total, because "47 refs" answers
846/// nothing: the question this buffer is opened with is usually about
847/// one of the three groups, and the row says which are worth scrolling
848/// to. Empty groups are omitted here for the same reason the buffer
849/// omits their headings.
850pub(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
868/// MG.21f — the bisect alert's text.
869///
870/// Mirrors git's own "Bisecting: N revisions left to test after this
871/// (roughly M steps)" rather than inventing a phrasing, because the
872/// user is reading git's line in a terminal at the same time. Degrades
873/// to the bare word when git has no numbers to give (a bad end marked
874/// but no good one yet) — an alert with a missing count still says the
875/// thing that matters, which is that HEAD is not where you left it.
876pub(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
884/// MG.21i — magit-submodule: how many, and how many need attention.
885///
886/// The second half is the reason this is not just a count: an
887/// uninitialised or modified submodule is the thing you opened the
888/// buffer to deal with, and a bare "4 submodules" would not say that
889/// any of them need anything.
890pub(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
922/// MG.21c — magit-remote: how many remotes are configured.
923pub(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
928/// magit-stash: how many stashes are held.
929pub(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
934/// MG.15 — magit-stash-show: which stash, and its subject. The ref is
935/// styled as a ref rather than a SHA: `stash@{2}` is a name that
936/// renumbers when its neighbours are dropped, not a fixed commit.
937pub(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
945/// magit-rebase: the upstream being rebased onto, how many commits
946/// the todo carries, and whether a rebase is already running (in
947/// which case `C-c C-c` would compound it, so the state is an alert).
948pub(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    /// The no-work-per-tick guarantee. A refresh that finds the same
1001    /// branch and the same counts must not bump the version — the
1002    /// worker would otherwise rebuild and repaint the row on every
1003    /// `gr` and every auto-refresh (paramount goal #1).
1004    #[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    /// The other half: a background refresh that DID find new data
1014    /// bumps exactly once, not once per field.
1015    #[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    /// The teardown contract: dropping the registration unregisters
1025    /// the provider, so the sticky row cannot outlive the mode.
1026    ///
1027    /// Tested here rather than through `:bd` in the host harness
1028    /// because `Editor::do_buffer_delete` currently removes the buffer
1029    /// from the registry without removing its `active_modes` entry —
1030    /// so no mode's Drop runs on buffer delete, and a host-level test
1031    /// would be asserting on a host gap rather than on this code. See
1032    /// the note in `lattice-ui-tui/src/app/magit_bindings.rs`.
1033    #[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    // ── Per-view builders ────────────────────────────────────────────
1077    //
1078    // Every view must answer "what am I looking at?" — so each test
1079    // below asserts the row is non-empty AND carries that view's
1080    // identifying field. A builder that silently returned an empty vec
1081    // would hide the row, which is the failure this slice exists to
1082    // remove.
1083
1084    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    /// MG.21f: bisecting a CLEAN tree is the normal case — git checks
1115    /// out each candidate for you — so the alert must survive the
1116    /// clean-tree early return. It did not, in the first draft.
1117    #[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    /// The numbers are git's, so when git has none the label degrades
1146    /// rather than inventing a zero — "0 left" would read as finished.
1147    #[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        // `\x00` rather than `\0`: the NUL separators sit next to digits
1242        // here, and `\03` reads as an octal escape even though Rust has
1243        // none (it is NUL followed by `3`).
1244        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    /// A subject containing NUL is impossible, but a truncated read is
1261    /// not — the parser must degrade to fewer fields, never panic.
1262    #[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    // ── MG.27: the in-flight indicator ───────────────────────────
1345
1346    #[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    // ── MG.56: the "that did nothing" notice ─────────────────────
1357
1358    /// A notice reads after the fields, like the busy marker.
1359    #[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    /// **A refresh clears the notice — even one that changes nothing.**
1370    ///
1371    /// This is the opposite of the busy marker's rule and deliberately
1372    /// so. The notice means "this view has been overtaken by events",
1373    /// and a refresh is what makes that untrue, so `set` clears it.
1374    ///
1375    /// The identical-fields case is the one that matters and the one a
1376    /// naive implementation gets wrong: `set` returns early when the
1377    /// new fields equal the old, so a clear placed after that check
1378    /// would leave a stale-data warning up precisely when the refresh
1379    /// had just DISPROVED it — and the user cannot tell that state from
1380    /// the problem it was warning about.
1381    #[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        // Same fields as it already holds — the no-work path.
1389        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    /// A notice and the busy marker coexist; neither eats the other.
1400    #[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    /// The whole reason the flag is not a `Field`: every refresh ends
1411    /// by REPLACING the field vector, so a marker living in it would be
1412    /// wiped by the very completion it must outlive.
1413    #[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    /// Only a real change repaints — a refresh that starts and finishes
1423    /// between two ticks must cost nothing.
1424    #[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    /// The guard is what survives an early return or a panic inside a
1436    /// refresh — the failure mode being a row stuck on "refreshing".
1437    #[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        // A harness with no theme/registry has `None` here; taking the
1453        // guard must not panic or require a handle.
1454        let none: Option<MagitHeaderlineHandle> = None;
1455        drop(busy(&none));
1456    }
1457
1458    /// A busy row still renders its fields — the marker is appended,
1459    /// not substituted, so nothing the user was reading disappears
1460    /// while a refresh runs.
1461    #[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    /// The status headerline must say which operation is in flight.
1488    ///
1489    /// Before this, only a bisect was announced: a merge, rebase,
1490    /// cherry-pick or revert stopped on a conflict showed unmerged
1491    /// files with nothing on screen saying what they were unmerged
1492    /// FROM, or which `--continue` would finish it.
1493    #[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    /// The alert survives a clean tree — a conflict resolved into the
1508    /// index but not yet committed leaves the counts ordinary, and
1509    /// that is exactly when the user needs telling.
1510    #[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}