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 §ion.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}