Skip to main content

lattice_magit/
sections.rs

1//! MG.2: Magit status section index and content formatting.
2//!
3//! Lazy by default: stores file paths + status labels only.
4//! No diffs are pre-computed — diffs load on demand via `=`.
5
6use std::path::PathBuf;
7
8use lattice_vcs::PathStatus;
9
10#[derive(Debug, Clone)]
11pub enum SectionEntry {
12    File {
13        path: PathBuf,
14        status: PathStatus,
15        /// Where a renamed / copied path came from, so the row can
16        /// render `old -> new` the way git and magit do — and so
17        /// unstaging can reset BOTH paths. Resetting only the new one
18        /// leaves the old path staged-DELETED, a deletion the user
19        /// never asked for and the next commit would record.
20        original_path: Option<PathBuf>,
21    },
22    Stash {
23        index: usize,
24        message: String,
25    },
26    Commit {
27        sha: String,
28        subject: String,
29    },
30    UntrackedFile {
31        path: PathBuf,
32    },
33}
34
35#[derive(Debug, Clone)]
36pub struct Section {
37    pub kind: SectionKind,
38    pub header_line: usize,
39    pub body_start: usize,
40    pub body_end: usize,
41    pub entries: Vec<SectionEntry>,
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
45pub enum SectionKind {
46    Staged,
47    Unstaged,
48    Untracked,
49    Stashes,
50    /// Commits on this branch that the upstream does not have —
51    /// magit's `Unmerged into <upstream>`.
52    ///
53    /// The upstream's NAME is not carried here: the variant stays
54    /// `Copy` and the ref lives on [`SectionIndex::upstream`], beside
55    /// `branch` / `ahead` / `behind`, which is where this struct
56    /// already keeps repo-level facts that a section header renders.
57    Unmerged,
58    RecentCommits,
59}
60
61/// The fixed prefix `format_buffer_styled` renders for each
62/// [`SectionKind`]'s header line, before the `" (N)"` count suffix.
63/// Single source of truth for "is this line a section header" —
64/// previously hand-duplicated as three independent string-prefix
65/// lists (here implicitly, `actions.rs::section_header_above`, and
66/// `magit_core_mode.rs::section_headers`), free to drift out of sync
67/// with each other and with this list.
68pub const SECTION_HEADER_PREFIXES: [&str; 6] = [
69    "Staged changes",
70    "Unstaged changes",
71    "Untracked files",
72    "Stashes",
73    // The upstream ref follows, so only the fixed part is listed —
74    // `is_section_header` is a prefix test, which is why this works.
75    "Unmerged into",
76    "Recent commits",
77];
78
79/// True if `text` (already trimmed of buffer indentation) is a
80/// section header line.
81pub fn is_section_header(text: &str) -> bool {
82    SECTION_HEADER_PREFIXES
83        .iter()
84        .any(|prefix| text.starts_with(prefix))
85}
86
87#[derive(Debug, Clone, Default)]
88pub struct SectionIndex {
89    pub sections: Vec<Section>,
90    pub branch: String,
91    pub ahead: usize,
92    pub behind: usize,
93    /// MG.21f: the bisect in progress, if any.
94    ///
95    /// Repo state rather than a section — this struct already carries
96    /// `branch` / `ahead` / `behind` for the same reason, and the
97    /// headerline is where lattice answers "what state is this repo
98    /// in" (it already carries `REBASE IN PROGRESS`). A `SectionKind`
99    /// would have been the wrong home: every `SectionEntry` variant
100    /// carries diff-bearing-file invariants — a path, a stage
101    /// operation, an expandable patch — and a bisect has none of them.
102    pub bisect: Option<lattice_vcs::BisectState>,
103    /// MG: the multi-commit operation the repo is stopped inside —
104    /// merge, rebase, cherry-pick, revert, `am` — or `None` when idle.
105    ///
106    /// Repo state like `bisect`, and here for the same reason: the
107    /// status buffer is where a user lands to resolve conflicts, and
108    /// without this it showed unmerged files with nothing saying WHAT
109    /// they are unmerged from or how to get out.
110    pub in_flight: Option<lattice_vcs::InFlightOp>,
111    /// The upstream ref this branch tracks (`origin/main`), when it has
112    /// one, for the `Unmerged into <upstream>` header.
113    ///
114    /// Repo state like `branch` / `ahead` / `behind`, and here for the
115    /// same reason: it is a fact about the repository that a section
116    /// header renders, not a property of any entry.
117    pub upstream: Option<String>,
118}
119
120impl SectionIndex {
121    pub fn format_buffer(&self) -> String {
122        self.format_buffer_styled().0
123    }
124
125    pub fn format_buffer_styled(&self) -> (String, Vec<Vec<lattice_cells::style::StyledSpan>>) {
126        let (text, spans, _, _) = self.format_buffer_styled_with(|_, _| None, None);
127        (text, spans)
128    }
129
130    /// MG.18d: format the buffer with each entry's diff **inlined**
131    /// where `inline` supplies one.
132    ///
133    /// A refresh replaces the whole buffer, so any expansion the user
134    /// had open dies with it — which at hunk granularity means losing
135    /// your place after every single `s`. Rebuilding *with* the
136    /// expansions is what lets the entry survive: one edit, one span
137    /// vector, no splice arithmetic against a buffer that is being
138    /// replaced underneath it, and no collapse-then-expand flicker.
139    ///
140    /// `inline(entry, kind)` returns `(key, diff_text)` for an entry
141    /// that should come back expanded. The returned
142    /// `Vec<(key, line_count)>` is the rebuilt expansion bookkeeping —
143    /// counts are recomputed rather than carried, because staging a
144    /// hunk makes the diff shorter.
145    /// DS-fix (2026-08-12): `lang_registry` is what makes a refreshed
146    /// expansion look like a freshly-opened one. `None` degrades to the
147    /// flat classifier, the same designed degradation
148    /// `hunk_syntax::diff_spans` documents — a harness without the
149    /// grammar service renders the diff the way it always did.
150    pub fn format_buffer_styled_with(
151        &self,
152        inline: impl Fn(&SectionEntry, SectionKind) -> Option<(String, String)>,
153        lang_registry: Option<&std::sync::Arc<lattice_syntax::LangRegistry>>,
154    ) -> (
155        String,
156        Vec<Vec<lattice_cells::style::StyledSpan>>,
157        Vec<(String, usize)>,
158        // DR.3: intra-line refinement, indexed by the SAME line numbers
159        // as `spans` and produced by the same walk — so an inline
160        // expansion shifts both by construction, never one without the
161        // other.
162        Vec<Vec<lattice_cells::RefineSpan>>,
163    ) {
164        let mut out = String::new();
165        let mut spans: Vec<Vec<lattice_cells::style::StyledSpan>> = Vec::new();
166        let mut refine: Vec<Vec<lattice_cells::RefineSpan>> = Vec::new();
167        let mut expanded: Vec<(String, usize)> = Vec::new();
168
169        for section in &self.sections {
170            if section.entries.is_empty() {
171                continue;
172            }
173            let header = match section.kind {
174                SectionKind::Staged => format!("Staged changes ({})", section.entries.len()),
175                SectionKind::Unstaged => format!("Unstaged changes ({})", section.entries.len()),
176                SectionKind::Untracked => format!("Untracked files ({})", section.entries.len()),
177                SectionKind::Stashes => format!("Stashes ({})", section.entries.len()),
178                // The upstream name comes from the index rather than
179                // the variant, so a repo with no upstream still renders
180                // a sensible header instead of an empty ref.
181                SectionKind::Unmerged => format!(
182                    "Unmerged into {} ({})",
183                    self.upstream.as_deref().unwrap_or("upstream"),
184                    section.entries.len()
185                ),
186                SectionKind::RecentCommits => format!("Recent commits ({})", section.entries.len()),
187            };
188            let line_idx = out.matches('\n').count();
189            while spans.len() <= line_idx {
190                spans.push(Vec::new());
191                refine.push(Vec::new());
192            }
193            out.push_str(&header);
194            out.push('\n');
195            spans[line_idx] = vec![lattice_cells::style::StyledSpan {
196                start: 0,
197                end: header.len(),
198                style: lattice_cells::style::Style::Heading2,
199            }];
200
201            for entry in &section.entries {
202                let line_idx = out.matches('\n').count();
203                while spans.len() <= line_idx {
204                    spans.push(Vec::new());
205                    refine.push(Vec::new());
206                }
207                match entry {
208                    SectionEntry::File {
209                        path,
210                        status,
211                        original_path,
212                    } => {
213                        let label = status_label(*status);
214                        // A rename / copy renders `old -> new`, as git
215                        // and magit both do: the origin is half the
216                        // information, and a row saying only `new` is
217                        // indistinguishable from a fresh file.
218                        let path_s = match original_path {
219                            Some(from) => std::borrow::Cow::Owned(format!(
220                                "{} -> {}",
221                                from.to_string_lossy(),
222                                path.to_string_lossy()
223                            )),
224                            None => path.to_string_lossy(),
225                        };
226                        let path_text = format!("  {label:<LABEL_WIDTH$} {path_s}");
227                        out.push_str(&path_text);
228                        out.push('\n');
229                        let path_start = 2 + LABEL_WIDTH + 1;
230                        let label_end = 2 + label.len();
231                        spans[line_idx] = match status {
232                            PathStatus::Deleted => vec![
233                                lattice_cells::style::StyledSpan {
234                                    start: 2,
235                                    end: label_end,
236                                    style: lattice_cells::style::Style::DiagnosticError,
237                                },
238                                lattice_cells::style::StyledSpan {
239                                    start: path_start,
240                                    end: path_text.len(),
241                                    style: lattice_cells::style::Style::String,
242                                },
243                            ],
244                            PathStatus::Added => vec![
245                                lattice_cells::style::StyledSpan {
246                                    start: 2,
247                                    end: label_end,
248                                    style: lattice_cells::style::Style::String,
249                                },
250                                lattice_cells::style::StyledSpan {
251                                    start: path_start,
252                                    end: path_text.len(),
253                                    style: lattice_cells::style::Style::String,
254                                },
255                            ],
256                            PathStatus::Unmerged(_) => vec![
257                                lattice_cells::style::StyledSpan {
258                                    start: 2,
259                                    end: label_end,
260                                    style: lattice_cells::style::Style::DiagnosticWarning,
261                                },
262                                lattice_cells::style::StyledSpan {
263                                    start: path_start,
264                                    end: path_text.len(),
265                                    style: lattice_cells::style::Style::String,
266                                },
267                            ],
268                            _ => vec![
269                                lattice_cells::style::StyledSpan {
270                                    start: 2,
271                                    end: label_end,
272                                    style: lattice_cells::style::Style::Keyword,
273                                },
274                                lattice_cells::style::StyledSpan {
275                                    start: path_start,
276                                    end: path_text.len(),
277                                    style: lattice_cells::style::Style::String,
278                                },
279                            ],
280                        };
281                    }
282                    SectionEntry::Stash { index, message } => {
283                        let idx_str = format!("{}", index);
284                        // ONE writer for the stash row, shared with the
285                        // stash-list buffer. It was duplicated here, and
286                        // that is the same writer/reader split that made
287                        // every chord in the stash buffer dead in MG.15
288                        // — now both views also *read* it back through
289                        // `parse_index` to resolve the stash under the
290                        // cursor, so a drift would break the chords in
291                        // whichever buffer lost the coin toss.
292                        let line_text = crate::magit_stash_mode::list_row(*index, message);
293                        out.push_str(&line_text);
294                        out.push('\n');
295                        spans[line_idx] = vec![
296                            lattice_cells::style::StyledSpan {
297                                start: 2,
298                                end: 9,
299                                style: lattice_cells::style::Style::Keyword,
300                            },
301                            lattice_cells::style::StyledSpan {
302                                start: 9,
303                                end: 9 + idx_str.len(),
304                                style: lattice_cells::style::Style::Number,
305                            },
306                            lattice_cells::style::StyledSpan {
307                                start: 11 + idx_str.len(),
308                                end: line_text.len(),
309                                style: lattice_cells::style::Style::Comment,
310                            },
311                        ];
312                    }
313                    SectionEntry::Commit { sha, subject } => {
314                        let sha_len = sha.len();
315                        let line_text = format!("  {} {}", sha, subject);
316                        out.push_str(&line_text);
317                        out.push('\n');
318                        spans[line_idx] = vec![
319                            lattice_cells::style::StyledSpan {
320                                start: 2,
321                                end: 2 + sha_len,
322                                style: lattice_cells::style::Style::Link,
323                            },
324                            lattice_cells::style::StyledSpan {
325                                start: 2 + sha_len + 1,
326                                end: line_text.len(),
327                                style: lattice_cells::style::Style::Comment,
328                            },
329                        ];
330                    }
331                    SectionEntry::UntrackedFile { path } => {
332                        let label = "untracked";
333                        let path_s = path.to_string_lossy();
334                        let line_text = format!("  {label:<LABEL_WIDTH$} {path_s}");
335                        out.push_str(&line_text);
336                        out.push('\n');
337                        let path_start = 2 + LABEL_WIDTH + 1;
338                        spans[line_idx] = vec![
339                            lattice_cells::style::StyledSpan {
340                                start: 2,
341                                end: 2 + label.len(),
342                                style: lattice_cells::style::Style::Comment,
343                            },
344                            lattice_cells::style::StyledSpan {
345                                start: path_start,
346                                end: line_text.len(),
347                                style: lattice_cells::style::Style::Comment,
348                            },
349                        ];
350                    }
351                }
352                // MG.18d: the entry's own diff, if it was open before
353                // the refresh. Highlighted through the same
354                // `hunk_syntax::diff_spans` the `=` toggle uses, so an
355                // expansion looks identical however it got there.
356                //
357                // DS-fix (2026-08-12): this used to call the FLAT
358                // `highlight::diff_styled_spans` directly, so a refresh
359                // silently dropped the syntax layer DS.1–DS.5 added and
360                // an expansion rebuilt by `gr` lost its colour. The
361                // comment above already claimed the invariant; the code
362                // stopped honouring it when the layered path landed at
363                // the `=` site only.
364                if let Some((key, diff)) = inline(entry, section.kind) {
365                    let diff = diff.trim_end();
366                    if !diff.is_empty() {
367                        // One call, both axes — a view cannot compute
368                        // one without the other and render
369                        // inconsistently.
370                        let styled = crate::hunk_syntax::styled_diff(diff, lang_registry);
371                        let (diff_spans, diff_refine) = (styled.spans, styled.refine);
372                        let mut count = 0usize;
373                        for (i, line) in diff.lines().enumerate() {
374                            let line_idx = out.matches('\n').count();
375                            while spans.len() <= line_idx {
376                                spans.push(Vec::new());
377                                refine.push(Vec::new());
378                            }
379                            out.push_str(line);
380                            out.push('\n');
381                            if let Some(row) = diff_spans.get(i) {
382                                spans[line_idx] = row.clone();
383                            }
384                            if let Some(row) = diff_refine.get(i) {
385                                refine[line_idx] = row.clone();
386                            }
387                            count += 1;
388                        }
389                        expanded.push((key, count));
390                    }
391                }
392            }
393            // blank separator line
394            let line_idx = out.matches('\n').count();
395            while spans.len() <= line_idx {
396                spans.push(Vec::new());
397                refine.push(Vec::new());
398            }
399            out.push('\n');
400        }
401
402        (out, spans, expanded, refine)
403    }
404
405    // MG.14 removed `branch_status_line()`. It formatted branch +
406    // ahead/behind into a `String` and had no callers in any revision
407    // of this crate — the header it was written for did not exist.
408    // `headerline::status_fields` renders the same data now, as
409    // per-role coloured fields rather than one flat string, and is
410    // reached from the one place a status refresh runs.
411}
412
413fn status_label(status: PathStatus) -> &'static str {
414    match status {
415        PathStatus::Modified => "modified",
416        PathStatus::Added => "new file",
417        PathStatus::Deleted => "deleted",
418        // Git's own wording for each. `renamed` / `copied` used to
419        // render as "new file" because the parser collapsed them into
420        // `Added`; the origin path travels on
421        // `PathChange::original_path` but is not shown yet — see the
422        // note in `refresh`.
423        PathStatus::Renamed => "renamed",
424        PathStatus::Copied => "copied",
425        PathStatus::TypeChanged => "typechange",
426        PathStatus::Untracked => "untracked",
427        PathStatus::Ignored => "ignored",
428        // Git's own wording per combination — "both modified" and
429        // "deleted by them" are different problems and a generic
430        // "unmerged" tells the user neither.
431        PathStatus::Unmerged(kind) => kind.label(),
432    }
433}
434
435/// Width of the label column in a rendered file row.
436///
437/// Was a bare `12` written out at three sites. The unmerged labels
438/// ("deleted by them" is 15 columns) overflow that, and an overflowing
439/// `{:<12}` silently stops padding — which would have left the styled
440/// spans' hardcoded `path_start` pointing INTO the label. Derived from
441/// the labels themselves, with `label_column_fits_every_label` holding
442/// it honest.
443pub(crate) const LABEL_WIDTH: usize = 15;
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448
449    /// The unmerged header carries the upstream ref, so the prefix
450    /// list holds only its fixed part — `is_section_header` must still
451    /// recognise the rendered line.
452    #[test]
453    fn the_unmerged_header_is_recognised_with_its_upstream_name() {
454        assert!(is_section_header("Unmerged into origin/main (34)"));
455        assert!(is_section_header("Unmerged into upstream (1)"));
456    }
457
458    /// A repo with no upstream still renders a sensible header rather
459    /// than an empty ref — the section is normally absent in that case,
460    /// but the renderer must not depend on that to be readable.
461    #[test]
462    fn a_missing_upstream_falls_back_to_a_readable_header() {
463        let index = SectionIndex {
464            sections: vec![Section {
465                kind: SectionKind::Unmerged,
466                header_line: 0,
467                body_start: 1,
468                body_end: 2,
469                entries: vec![SectionEntry::Commit {
470                    sha: "abc1234".into(),
471                    subject: "do a thing".into(),
472                }],
473            }],
474            upstream: None,
475            ..Default::default()
476        };
477        assert!(
478            index.format_buffer().contains("Unmerged into upstream (1)"),
479            "got {}",
480            index.format_buffer()
481        );
482    }
483
484    /// With an upstream, the header names it — the form magit uses and
485    /// the one the reader recognises.
486    #[test]
487    fn the_upstream_name_reaches_the_header() {
488        let index = SectionIndex {
489            sections: vec![Section {
490                kind: SectionKind::Unmerged,
491                header_line: 0,
492                body_start: 1,
493                body_end: 2,
494                entries: vec![SectionEntry::Commit {
495                    sha: "abc1234".into(),
496                    subject: "do a thing".into(),
497                }],
498            }],
499            upstream: Some("origin/main".into()),
500            ..Default::default()
501        };
502        assert!(
503            index
504                .format_buffer()
505                .contains("Unmerged into origin/main (1)"),
506            "got {}",
507            index.format_buffer()
508        );
509    }
510
511    #[test]
512    fn is_section_header_matches_every_rendered_header_prefix() {
513        for prefix in SECTION_HEADER_PREFIXES {
514            let rendered = format!("{prefix} (3)");
515            assert!(is_section_header(&rendered), "{rendered:?} should match");
516        }
517    }
518
519    #[test]
520    fn is_section_header_rejects_entry_lines() {
521        assert!(!is_section_header("modified     src/lib.rs"));
522        assert!(!is_section_header("stash@{0} WIP on main"));
523        assert!(!is_section_header(""));
524    }
525
526    /// `LABEL_WIDTH` must fit every label, or `{:<WIDTH$}` stops
527    /// padding and the styled spans' `path_start` lands inside the
528    /// label text instead of on the path.
529    #[test]
530    fn label_column_fits_every_label() {
531        for label in crate::actions::FILE_LABELS {
532            assert!(
533                label.len() <= LABEL_WIDTH,
534                "{label:?} is {} columns, wider than LABEL_WIDTH ({LABEL_WIDTH})",
535                label.len()
536            );
537        }
538    }
539
540    #[test]
541    fn status_label_is_a_subset_of_actions_file_labels() {
542        // actions::FILE_LABELS must stay in sync with every label this
543        // renders — the comment there documents the pairing; this
544        // test catches drift mechanically instead of by inspection.
545        let mut all = vec![
546            PathStatus::Modified,
547            PathStatus::Added,
548            PathStatus::Deleted,
549            PathStatus::Renamed,
550            PathStatus::Copied,
551            PathStatus::TypeChanged,
552            PathStatus::Untracked,
553            PathStatus::Ignored,
554        ];
555        // Every unmerged combination renders its own label, so every
556        // one has to be reachable from `classify_line`.
557        all.extend(
558            lattice_vcs::UnmergedKind::ALL
559                .into_iter()
560                .map(PathStatus::Unmerged),
561        );
562        for status in all {
563            let label = status_label(status);
564            assert!(
565                crate::actions::FILE_LABELS.contains(&label),
566                "status_label({status:?}) = {label:?} not in actions::FILE_LABELS"
567            );
568        }
569    }
570}