Skip to main content

lattice_magit/
magit_refs_mode.rs

1//! MG.35: `magit-refs-mode` — magit's `y` show-refs.
2//!
3//! Every local branch, remote-tracking branch and tag, grouped, with
4//! each branch's ahead/behind against its upstream. The question it
5//! answers is "what refs exist, and where does each point" — which
6//! [`crate::magit_branch_mode`] does not: that buffer lists local
7//! branches so you can act on them, and knows nothing about tags,
8//! remote-tracking refs, or how far ahead you are.
9//!
10//! **`<CR>` shows the commit, `c` checks out.** Across magit-log,
11//! magit-blame, magit-rebase and magit-status's recent commits, `<CR>`
12//! already means "show the commit detail"; magit-branch-mode's
13//! checkout-on-`<CR>` is the outlier. Reading is also the safe default
14//! here in a way it is not there: `<CR>` on a tag or a remote-tracking
15//! branch under a checkout reading would silently detach HEAD, a state
16//! that is hard to recognise and hard to leave, from the most reflexive
17//! key in a buffer whose purpose is looking things up.
18
19use std::sync::{Arc, Mutex, OnceLock};
20
21use lattice_cells::{Style, StyledSpan};
22use lattice_config;
23use lattice_grammar::{EchoLevel, Effect};
24use lattice_mode::{
25    ActionContext, ActionHandlerContribution, BufferStoreHandle, CapabilitySet, Keymap,
26    KeymapEntry, LifecycleFuture, Mode, ModeContext, ModeId, ModeKind, OptionOverrideSet,
27    keymap_entry,
28};
29use lattice_protocol::position::Position;
30use lattice_vcs::{RefEntry, RefKind, Reference, Repository};
31
32use crate::buffer_state::{BufferStateGuard, BufferStates, MagitView, MagitViewsHandle};
33use crate::headerline::{self, Field, MagitHeaderlineHandle};
34
35pub struct MagitRefsMode;
36
37impl MagitRefsMode {
38    pub fn mode_id() -> ModeId {
39        ModeId::new("magit-refs-mode")
40    }
41}
42
43/// The view word this mode owns — `*magit:refs:<repo>*`.
44///
45/// MR.3 replaced the fixed `*magit:refs*` this was: "every ref in this
46/// repository" has no argument to vary, but it does vary by repository,
47/// and a constant naming one buffer for all of them was the assumption
48/// the whole slice removes. Composed through
49/// [`crate::workdir::magit_buffer_name`] at the trigger.
50pub const REFS_VIEW: &str = "refs";
51
52fn magit_refs_keymap_entries() -> &'static [KeymapEntry] {
53    static ENTRIES: OnceLock<Vec<KeymapEntry>> = OnceLock::new();
54    ENTRIES.get_or_init(|| {
55        vec![
56            keymap_entry! { mode: Normal, chord: "<CR>", doc: "Show the commit this ref points at", cmd: "action:magit-refs-show" },
57            keymap_entry! { mode: Normal, chord: "c", doc: "Check out the ref at cursor", cmd: "action:magit-refs-checkout" },
58        ]
59    })
60}
61
62pub struct RefsState {
63    buffer_id: lattice_core::BufferId,
64    store: Arc<BufferStoreHandle>,
65    workdir: std::path::PathBuf,
66    pending_highlights: Option<lattice_mode::PendingSyntheticHighlightsHandle>,
67    headerline: Option<MagitHeaderlineHandle>,
68    /// One slot per rendered line: the ref that line shows, or `None`
69    /// for a heading or a blank.
70    ///
71    /// Indexed rather than re-parsed out of the rendered text. The
72    /// renderer pads names into columns, so scraping a row back would
73    /// mean the parser and the formatter having to agree about padding
74    /// forever — and a ref whose name happens to contain the padding
75    /// would read as a different ref. Building the index in the same
76    /// pass that renders makes them agree by construction.
77    rows: Vec<Option<RefEntry>>,
78}
79
80/// MG.13: service alias for this mode's per-buffer state — register and
81/// look up through this exact type (`feedback_servicesregistry_arc_typeid`).
82pub type RefsStatesHandle = Arc<BufferStates<RefsState>>;
83
84fn state(ctx: &ActionContext<'_>) -> Option<Arc<Mutex<RefsState>>> {
85    crate::buffer_state::state_for::<RefsState>(ctx)
86}
87
88struct RefsView(Arc<Mutex<RefsState>>);
89
90impl MagitView for RefsView {
91    fn refresh(&self) -> Option<Effect> {
92        refresh(self.0.clone())
93    }
94
95    /// MG.24c: `A` / `_` / `O` act on the commit a ref points at.
96    ///
97    /// Full id, never the abbreviation on screen: an abbreviation is
98    /// ambiguous in principle and git resolves the ambiguity by
99    /// refusing, which would surface as a cherry-pick that did nothing.
100    fn commit_at_cursor(&self, cursor: Position) -> Option<String> {
101        Some(ref_at_cursor(&*self.0.lock().ok()?, cursor)?.id)
102    }
103
104    fn workdir(&self) -> Option<std::path::PathBuf> {
105        Some(self.0.lock().ok()?.workdir.clone())
106    }
107}
108
109impl Mode for MagitRefsMode {
110    type Guard = BufferStateGuard<RefsState>;
111
112    fn id(&self) -> ModeId {
113        Self::mode_id()
114    }
115    fn kind(&self) -> ModeKind {
116        ModeKind::Major
117    }
118    fn target_buffer_kind(&self) -> Option<lattice_core::BufferKind> {
119        None
120    }
121
122    fn options(&self) -> OptionOverrideSet {
123        lattice_config::overrides! {
124            lattice_config::ReadOnly = true,
125            lattice_config::NoFile = true,
126        }
127    }
128
129    /// MG.RO: `read-only-mode` is where the gate actually is.
130    ///
131    /// `ReadOnly = true` above stops TYPING and nothing else. It is read by
132    /// `read_only_edit_rejected`, which guards the insert-mode char path;
133    /// operators never reach it, because a `Document`'s grammar dispatch
134    /// applies its own edits and hands the host an already-applied
135    /// `Effect::Edits`. `x` deleted a character out of `*magit:status*` while
136    /// the buffer reported itself read-only — worse than not gating at all,
137    /// because it looks protected.
138    ///
139    /// `read-only-mode` carries the option AND the `invocation_runner`
140    /// (`Editor::run_read_only_motion`) that refuses mutating operators while
141    /// letting motions, `:` and `/` through.
142    ///
143    /// Declared per MAJOR rather than once on `magit-core-mode`: an implied
144    /// mode is followed from the mode being ACTIVATED, and the majors are what
145    /// the host activates. Putting it on the shared minor looked right and was
146    /// verified not to fire.
147    fn implies(&self) -> &[lattice_mode::ModeId] {
148        static IMPLIED: std::sync::OnceLock<Vec<lattice_mode::ModeId>> = std::sync::OnceLock::new();
149        IMPLIED.get_or_init(|| vec![lattice_mode::modes::ReadOnlyMode::mode_id()])
150    }
151
152    fn required_capabilities(&self) -> CapabilitySet {
153        CapabilitySet::empty()
154    }
155
156    fn keymap(&self) -> Keymap {
157        Keymap::from_entries(magit_refs_keymap_entries())
158    }
159
160    /// MG.13: registered once at boot, resolving per-buffer state at
161    /// call time — see `buffer_state`'s module docs.
162    fn action_handlers(&self) -> Vec<ActionHandlerContribution> {
163        vec![
164            ActionHandlerContribution {
165                action_name: "action:magit-refs-show",
166                handler: Arc::new(|ctx: &ActionContext<'_>| {
167                    let s = state(ctx)?;
168                    let entry = {
169                        let g = s.lock().ok()?;
170                        ref_at_cursor(&g, ctx.cursor)?
171                    };
172                    Some(crate::magit_global_mode::open_repo_view_from_action_with(
173                        ctx,
174                        crate::magit_revision_mode::SHOW_VIEW,
175                        "magit-revision-mode",
176                        Some(&entry.id),
177                    ))
178                }),
179            },
180            ActionHandlerContribution {
181                action_name: "action:magit-refs-checkout",
182                handler: Arc::new(|ctx: &ActionContext<'_>| {
183                    let s = state(ctx)?;
184                    let (entry, workdir) = {
185                        let g = s.lock().ok()?;
186                        (ref_at_cursor(&g, ctx.cursor)?, g.workdir.clone())
187                    };
188                    // A tag or a remote-tracking branch has no local
189                    // branch to move, so checking it out detaches HEAD.
190                    // Git says so in a paragraph the user never sees
191                    // here, so the refusal says it instead — and points
192                    // at the operation they almost certainly wanted.
193                    if entry.kind != RefKind::Branch {
194                        return Some(Effect::Echo {
195                            level: EchoLevel::Error,
196                            text: format!(
197                                "magit: checking out {} would detach HEAD — \
198                                 make a branch from it instead \
199                                 (:magit-branch-create <name>, or `b` `c` in the dispatch)",
200                                entry.name
201                            ),
202                        });
203                    }
204                    let name = entry.name.clone();
205                    spawn_mutation_and_refresh(s, move || {
206                        // NC.5: reported, not discarded — a refused
207                        // checkout (dirty tree) used to vanish.
208                        let result = Repository::discover(&workdir)
209                            .map_err(|e| format!("not a git repository: {e}"))
210                            .and_then(|repo| {
211                                lattice_vcs::Branch::checkout(&repo, &name)
212                                    .map_err(|e| e.to_string())
213                            })
214                            .map(|()| String::new());
215                        crate::magit_global_mode::finish_task(
216                            &workdir,
217                            &format!("check out {name}"),
218                            result,
219                        );
220                    })
221                }),
222            },
223        ]
224    }
225
226    fn on_activate(&self, ctx: ModeContext) -> LifecycleFuture<'_, Self::Guard> {
227        Box::pin(async move {
228            let buffer_id = lattice_core::BufferId(ctx.buffer_id().0 as u32);
229            let orphan = || BufferStateGuard::new(Arc::new(BufferStates::default()), buffer_id);
230            let Some(store) = ctx.service::<BufferStoreHandle>() else {
231                return Ok(orphan());
232            };
233            let Some(handle) = store.handle_for(buffer_id) else {
234                return Ok(orphan());
235            };
236            // MR.3: the repository the trigger resolved for THIS
237            // buffer, not the one the editor was started in.
238            let workdir =
239                crate::repo_scope::view_workdir(&ctx, buffer_id, &handle).unwrap_or_default();
240            let pending_highlights = ctx.service::<lattice_mode::PendingSyntheticHighlights>();
241
242            let (hl, hl_registration) =
243                match headerline::install(&ctx, buffer_id, Self::mode_id().as_str()) {
244                    Some((h, reg)) => (Some(h), Some(reg)),
245                    None => (None, None),
246                };
247
248            // MG.13: publish BEFORE the first `.await`, or the chords
249            // above resolve to handlers that cannot find their state.
250            // `rows` starts empty; `ref_at_cursor` returns `None` for
251            // every line until the walk lands, so a `<CR>` in that
252            // window declines rather than acting on a stale row.
253            let Some(states) = ctx.service::<RefsStatesHandle>() else {
254                return Ok(orphan());
255            };
256            let state = states.publish(
257                buffer_id,
258                RefsState {
259                    buffer_id,
260                    store: store.clone(),
261                    workdir: workdir.clone(),
262                    pending_highlights: pending_highlights.clone(),
263                    headerline: hl.clone(),
264                    rows: Vec::new(),
265                },
266            );
267            let mut guard = BufferStateGuard::new((*states).clone(), buffer_id)
268                .with_headerline(hl_registration);
269            if let Some(views) = ctx.service::<MagitViewsHandle>() {
270                views.publish(buffer_id, Arc::new(RefsView(state.clone())));
271                guard = guard.with_views((*views).clone());
272            }
273
274            let wd = workdir.clone();
275            let built = tokio::task::spawn_blocking(move || build_refs_buffer(&wd))
276                .await
277                .unwrap_or_default();
278            headerline::publish(&hl, built.header.clone());
279            let spans = built.spans;
280            if let Ok(mut g) = state.lock() {
281                g.rows = built.rows;
282            }
283            crate::buffer_io::replace_buffer_text(&handle, built.text).await;
284            if let Some(ref ph) = pending_highlights {
285                ph.store_and_wake(buffer_id, spans);
286            }
287
288            Ok(guard)
289        })
290    }
291}
292
293/// `gr` — re-walk the refs.
294fn refresh(s: Arc<Mutex<RefsState>>) -> Option<Effect> {
295    respawn(s, || {})
296}
297
298/// Run `mutate` (a blocking git call) off the actor thread, then
299/// re-walk — the shape every mutating handler uses instead of calling
300/// git inline. Paramount goal #1: no `git` on the actor thread, ever.
301fn spawn_mutation_and_refresh(
302    s: Arc<Mutex<RefsState>>,
303    mutate: impl FnOnce() + Send + 'static,
304) -> Option<Effect> {
305    respawn(s, mutate)
306}
307
308fn respawn(s: Arc<Mutex<RefsState>>, mutate: impl FnOnce() + Send + 'static) -> Option<Effect> {
309    let (handle, wd, pending, buffer_id, hl) = {
310        let g = s.lock().ok()?;
311        (
312            g.store.handle_for(g.buffer_id)?,
313            g.workdir.clone(),
314            g.pending_highlights.clone(),
315            g.buffer_id,
316            g.headerline.clone(),
317        )
318    };
319    // MG.27: the headerline says "refreshing" until the guard drops,
320    // including on every early exit inside the task.
321    let busy = headerline::busy(&hl);
322    tokio::task::spawn(async move {
323        let _busy = busy;
324        let _ = tokio::task::spawn_blocking(mutate).await;
325        let built = tokio::task::spawn_blocking(move || build_refs_buffer(&wd))
326            .await
327            .unwrap_or_default();
328        headerline::publish(&hl, built.header.clone());
329        let spans = built.spans;
330        // The row index is replaced BEFORE the text, so there is no
331        // window in which a cursor lands on a new row and resolves
332        // against the old index — which would act on a ref the user is
333        // not looking at.
334        if let Ok(mut g) = s.lock() {
335            g.rows = built.rows;
336        }
337        crate::buffer_io::replace_buffer_text(&handle, built.text).await;
338        if let Some(ph) = pending {
339            ph.store_and_wake(buffer_id, spans);
340        }
341    });
342    None
343}
344
345fn ref_at_cursor(state: &RefsState, cursor: Position) -> Option<RefEntry> {
346    state.rows.get(cursor.line as usize).cloned().flatten()
347}
348
349/// The rendered buffer, its headerline fields, the line→ref index and
350/// the highlight spans — all from one walk, because they must agree.
351///
352/// The spans are built here rather than in `highlight.rs` (where every
353/// other magit buffer's are) because a refs row cannot be scanned back
354/// from its text: the tracking summary contains spaces, and a row
355/// without one runs straight from the id into the subject, so "summary
356/// or subject?" is unanswerable after the fact. See the note in
357/// `highlight.rs`.
358#[derive(Default)]
359pub(crate) struct RefsBuffer {
360    pub(crate) text: String,
361    pub(crate) header: Vec<Field>,
362    pub(crate) rows: Vec<Option<RefEntry>>,
363    pub(crate) spans: Vec<Vec<StyledSpan>>,
364}
365
366fn build_refs_buffer(workdir: &std::path::Path) -> RefsBuffer {
367    let repo = match Repository::discover(workdir) {
368        Ok(r) => r,
369        Err(_) => {
370            return RefsBuffer {
371                text: "Not a git repository.\n".to_string(),
372                ..Default::default()
373            };
374        }
375    };
376    render_refs(&Reference::list(&repo).unwrap_or_default())
377}
378
379/// Column the object id starts at. Long ref names push past it rather
380/// than being truncated — a truncated ref name is not a ref name, and
381/// this buffer's whole job is naming things.
382const NAME_WIDTH: usize = 28;
383/// Column the tracking summary starts at, measured from the id.
384const ID_WIDTH: usize = 10;
385/// Column the subject starts at, measured from the tracking summary.
386const TRACK_WIDTH: usize = 20;
387
388/// Render the grouped buffer.
389///
390/// Split from [`build_refs_buffer`] so the layout is testable without a
391/// repository — the same reason `log_merged_argv` is a separate pure
392/// function from `resolve_merge_commit`.
393pub(crate) fn render_refs(refs: &[RefEntry]) -> RefsBuffer {
394    if refs.is_empty() {
395        return RefsBuffer {
396            text: "No refs.\n".to_string(),
397            header: headerline::refs_fields(0, 0, 0),
398            rows: vec![None],
399            spans: vec![Vec::new()],
400        };
401    }
402    let count = |k: RefKind| refs.iter().filter(|r| r.kind == k).count();
403    let (branches, remotes, tags) = (
404        count(RefKind::Branch),
405        count(RefKind::Remote),
406        count(RefKind::Tag),
407    );
408
409    let mut text = String::new();
410    let mut rows: Vec<Option<RefEntry>> = Vec::new();
411    let mut spans: Vec<Vec<StyledSpan>> = Vec::new();
412    for (kind, label, n) in [
413        (RefKind::Branch, "Branches", branches),
414        (RefKind::Remote, "Remotes", remotes),
415        (RefKind::Tag, "Tags", tags),
416    ] {
417        if n == 0 {
418            // An empty group is omitted rather than shown as a heading
419            // with nothing under it: a repository with no remotes is the
420            // ordinary case, not a state worth a row.
421            continue;
422        }
423        text.push_str(&format!("{label} ({n})\n"));
424        rows.push(None);
425        spans.push(Vec::new());
426        for entry in refs.iter().filter(|r| r.kind == kind) {
427            let (row, row_spans) = render_row(entry);
428            text.push_str(&row);
429            text.push('\n');
430            rows.push(Some(entry.clone()));
431            spans.push(row_spans);
432        }
433        text.push('\n');
434        rows.push(None);
435        spans.push(Vec::new());
436    }
437    RefsBuffer {
438        text,
439        header: headerline::refs_fields(branches, remotes, tags),
440        rows,
441        spans,
442    }
443}
444
445/// One ref row: `<marker><name> <short-id> <track> <subject>`, padded
446/// into columns — and the spans naming each field, whose byte offsets
447/// are recorded as the row is built rather than recovered from it.
448fn render_row(entry: &RefEntry) -> (String, Vec<StyledSpan>) {
449    let marker = if entry.head { "* " } else { "  " };
450    // Char counts, not byte lengths: a ref name or subject may be
451    // non-ASCII, and padding by bytes would misalign every column after
452    // it. (Width in cells is a further step this buffer does not take —
453    // the columns are a reading aid, not a table the cursor indexes.)
454    let pad = |s: &str, w: usize| {
455        let used = s.chars().count();
456        " ".repeat(w.saturating_sub(used).max(1))
457    };
458    let mut row = String::from(marker);
459    let mut spans = Vec::new();
460
461    row.push_str(&entry.name);
462    if entry.head {
463        // Marker and name are one visual unit — the same span
464        // `branch_styled_spans` gives the checked-out branch.
465        spans.push(StyledSpan {
466            start: 0,
467            end: row.len(),
468            style: Style::MagitBranchCurrent,
469        });
470    }
471    row.push_str(&pad(&entry.name, NAME_WIDTH));
472
473    let id_start = row.len();
474    row.push_str(&entry.short_id);
475    spans.push(StyledSpan {
476        start: id_start,
477        end: row.len(),
478        style: Style::MagitSha,
479    });
480    row.push_str(&pad(&entry.short_id, ID_WIDTH));
481
482    if !entry.track.is_empty() {
483        let track_start = row.len();
484        row.push_str(&entry.track);
485        spans.push(StyledSpan {
486            start: track_start,
487            end: row.len(),
488            // It is exactly that: a decoration saying where this ref
489            // sits relative to another.
490            style: Style::MagitRefDecoration,
491        });
492    }
493
494    if entry.subject.is_empty() {
495        // Nothing follows, so the padding after the tracking summary
496        // would be trailing whitespace. Truncating cannot invalidate a
497        // span: every span above ends at or before the id/track text,
498        // and only padding is removed.
499        return (row.trim_end().to_string(), spans);
500    }
501    row.push_str(&pad(&entry.track, TRACK_WIDTH));
502    row.push_str(&entry.subject);
503    (row, spans)
504}
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509
510    fn entry(kind: RefKind, name: &str) -> RefEntry {
511        RefEntry {
512            kind,
513            name: name.to_string(),
514            id: format!("{name}-full-id"),
515            short_id: "a1b2c3d".to_string(),
516            upstream: String::new(),
517            track: String::new(),
518            head: false,
519            subject: "subject".to_string(),
520        }
521    }
522
523    /// The index must name the ref on the line it was written to. This
524    /// is the whole safety argument for indexing rather than scraping:
525    /// every action reads through it, so an off-by-one acts on the wrong
526    /// ref while looking correct.
527    #[test]
528    fn every_row_index_points_at_the_ref_rendered_on_that_line() {
529        let refs = vec![
530            entry(RefKind::Branch, "main"),
531            entry(RefKind::Branch, "feature/x"),
532            entry(RefKind::Remote, "origin/main"),
533            entry(RefKind::Tag, "v1.0.0"),
534        ];
535        let built = render_refs(&refs);
536        let lines: Vec<&str> = built.text.lines().collect();
537        assert_eq!(
538            built.rows.len(),
539            lines.len(),
540            "one index slot per rendered line"
541        );
542        for (i, slot) in built.rows.iter().enumerate() {
543            match slot {
544                Some(e) => assert!(
545                    lines[i].contains(&e.name),
546                    "line {i} ({:?}) must show {}",
547                    lines[i],
548                    e.name
549                ),
550                None => assert!(
551                    !lines[i].starts_with("  ") && !lines[i].starts_with("* "),
552                    "line {i} ({:?}) is indexed as a non-row but looks like one",
553                    lines[i]
554                ),
555            }
556        }
557    }
558
559    /// Headings and the blank separators must resolve to no ref, or
560    /// `<CR>` on the word "Branches" would act on whatever the index
561    /// happened to carry.
562    #[test]
563    fn headings_and_blanks_carry_no_ref() {
564        let built = render_refs(&[entry(RefKind::Branch, "main")]);
565        assert_eq!(built.rows[0], None, "the heading");
566        assert!(built.rows[1].is_some(), "the one branch");
567        assert_eq!(built.rows[2], None, "the trailing blank");
568    }
569
570    /// A group with nothing in it is omitted. A repository with no
571    /// remotes is ordinary, and a `Remotes (0)` heading is a row that
572    /// says nothing.
573    #[test]
574    fn empty_groups_are_omitted() {
575        let built = render_refs(&[entry(RefKind::Branch, "main")]);
576        assert!(built.text.starts_with("Branches (1)"));
577        assert!(!built.text.contains("Remotes"), "got: {}", built.text);
578        assert!(!built.text.contains("Tags"), "got: {}", built.text);
579    }
580
581    /// The checked-out branch is the only row marked, and the marker is
582    /// what `refs_styled_spans` colours.
583    #[test]
584    fn only_the_checked_out_branch_is_marked() {
585        let mut head = entry(RefKind::Branch, "main");
586        head.head = true;
587        let built = render_refs(&[head, entry(RefKind::Branch, "other")]);
588        let marked: Vec<&str> = built.text.lines().filter(|l| l.starts_with("* ")).collect();
589        assert_eq!(marked.len(), 1, "got: {}", built.text);
590        assert!(marked[0].contains("main"));
591    }
592
593    /// A ref name longer than the column pushes the following fields
594    /// right rather than being cut. A truncated ref name is not a ref
595    /// name, and this buffer exists to name things.
596    #[test]
597    fn a_long_name_is_never_truncated() {
598        let long = "feature/a-really-quite-long-branch-name-here";
599        let built = render_refs(&[entry(RefKind::Branch, long)]);
600        assert!(
601            built.text.contains(long),
602            "the name survives whole: {}",
603            built.text
604        );
605        assert!(
606            built.text.contains("a1b2c3d"),
607            "and the id still follows it: {}",
608            built.text
609        );
610    }
611
612    /// Padding counts characters, not bytes — otherwise a non-ASCII ref
613    /// name silently misaligns every column after it, and the misalignment
614    /// scales with how non-ASCII the name is.
615    #[test]
616    fn a_non_ascii_name_pads_by_characters() {
617        let built = render_refs(&[entry(RefKind::Branch, "función/ünïcode")]);
618        let row = built.text.lines().nth(1).expect("the row");
619        let id_at = row.find("a1b2c3d").expect("the id is on the row");
620        // "  " + 15 chars + at least one pad space. Byte-based padding
621        // would have produced a shorter run of spaces here, because the
622        // name is 15 chars but 18 bytes.
623        assert_eq!(
624            row[..id_at].chars().count(),
625            2 + NAME_WIDTH,
626            "columns align by character: {row:?}"
627        );
628    }
629
630    /// A ref with no subject must not leave the tracking column's
631    /// padding behind as trailing whitespace.
632    #[test]
633    fn a_row_with_no_subject_has_no_trailing_whitespace() {
634        let mut e = entry(RefKind::Tag, "v1.0.0");
635        e.subject = String::new();
636        let built = render_refs(&[e]);
637        let row = built.text.lines().nth(1).expect("the row");
638        assert_eq!(row, row.trim_end(), "trailing whitespace in {row:?}");
639    }
640
641    /// Every span must cover the text it claims to. This is the whole
642    /// safety argument for emitting spans here rather than scanning the
643    /// rendered line back: a byte offset that drifts colours the wrong
644    /// characters, and the result still looks like a coloured buffer.
645    #[test]
646    fn every_span_covers_the_field_it_names() {
647        let mut head = entry(RefKind::Branch, "main");
648        head.head = true;
649        head.track = "ahead 3, behind 1".to_string();
650        let built = render_refs(&[head.clone(), entry(RefKind::Tag, "v1.0.0")]);
651        let lines: Vec<&str> = built.text.lines().collect();
652        assert_eq!(built.spans.len(), lines.len(), "one span slot per line");
653
654        let row = lines[1];
655        let spans = &built.spans[1];
656        let at = |s: &StyledSpan| row[s.start..s.end].to_string();
657        let find = |style: Style| spans.iter().find(|s| s.style == style).map(&at);
658
659        assert_eq!(find(Style::MagitBranchCurrent).as_deref(), Some("* main"));
660        assert_eq!(find(Style::MagitSha).as_deref(), Some("a1b2c3d"));
661        assert_eq!(
662            find(Style::MagitRefDecoration).as_deref(),
663            Some("ahead 3, behind 1"),
664            "the summary contains spaces — the reason it cannot be \
665             recovered by scanning the row back"
666        );
667    }
668
669    /// A ref that is level with its upstream has no summary, so there
670    /// must be no decoration span — the subject follows the id directly,
671    /// and colouring it as a decoration is the exact mistake a
672    /// text-scanning highlighter would make.
673    #[test]
674    fn a_ref_with_no_tracking_summary_gets_no_decoration_span() {
675        let built = render_refs(&[entry(RefKind::Branch, "main")]);
676        assert!(
677            !built.spans[1]
678                .iter()
679                .any(|s| s.style == Style::MagitRefDecoration),
680            "no summary, so no decoration: {:?}",
681            built.spans[1]
682        );
683        let row = built.text.lines().nth(1).expect("the row");
684        assert!(
685            row.ends_with("subject"),
686            "the subject is still there: {row:?}"
687        );
688    }
689
690    /// An empty repository says so, and still indexes its one line as
691    /// carrying no ref.
692    #[test]
693    fn no_refs_says_so_and_indexes_nothing() {
694        let built = render_refs(&[]);
695        assert_eq!(built.text, "No refs.\n");
696        assert_eq!(built.rows, vec![None]);
697    }
698}