1use std::collections::{HashMap, HashSet};
9use std::path::PathBuf;
10use std::sync::Arc;
11
12use lattice_cells::StyledSpan;
13use lattice_core::BufferId;
14use lattice_mode::PendingSyntheticHighlightsHandle;
15use lattice_runtime::Document;
16use lattice_vcs::{PathStatus, Repository, Stash, WorkingTree};
17
18use crate::headerline::{self, Field};
19use crate::sections::{Section, SectionEntry, SectionIndex, SectionKind};
20
21pub fn build_and_format(
31 workdir: &PathBuf,
32 expanded: &HashSet<String>,
33 context: i64,
34 lang_registry: Option<&std::sync::Arc<lattice_syntax::LangRegistry>>,
38) -> (
39 String,
40 Vec<Vec<StyledSpan>>,
41 Vec<Field>,
42 HashMap<String, usize>,
43 Vec<Vec<lattice_cells::RefineSpan>>,
45) {
46 let repo = match Repository::discover(workdir) {
47 Ok(r) => r,
48 Err(e) => {
49 tracing::debug!(target: "lattice_magit", "refresh: repo discover failed: {e}");
50 return (
51 "Not a git repository.\n".to_string(),
52 Vec::new(),
53 Vec::new(),
54 HashMap::new(),
55 Vec::new(),
56 );
57 }
58 };
59
60 let index = build_section_index(&repo);
61 let header = headerline::status_fields(&index, workdir);
62 let (text, spans, reopened, refine) = index.format_buffer_styled_with(
68 |entry, kind| {
69 let line = entry_as_status_line(entry, kind)?;
70 let key = crate::actions::entry_key(&line);
71 if !expanded.contains(&key) {
72 return None;
73 }
74 let diff = crate::actions::run_show(workdir, &line, context)?;
75 (!diff.trim().is_empty()).then_some((key, diff))
76 },
77 lang_registry,
78 );
79 if text.is_empty() {
80 (
81 "No changes (working tree clean)\n".to_string(),
82 Vec::new(),
83 header,
84 HashMap::new(),
85 Vec::new(),
86 )
87 } else {
88 (text, spans, header, reopened.into_iter().collect(), refine)
89 }
90}
91
92fn entry_as_status_line(
100 entry: &SectionEntry,
101 kind: SectionKind,
102) -> Option<crate::actions::StatusLine> {
103 use crate::actions::StatusLine;
104 Some(match entry {
105 SectionEntry::File {
106 path,
107 status,
108 original_path,
109 } => StatusLine::File {
110 path: path.clone(),
111 staged: kind == SectionKind::Staged,
112 untracked: *status == lattice_vcs::PathStatus::Untracked,
113 original_path: original_path.clone(),
114 },
115 SectionEntry::UntrackedFile { path } => StatusLine::File {
119 original_path: None,
120 path: path.clone(),
121 staged: false,
122 untracked: true,
123 },
124 SectionEntry::Stash { index, .. } => StatusLine::Stash { index: *index },
125 SectionEntry::Commit { sha, .. } => StatusLine::Commit { sha: sha.clone() },
126 })
127}
128
129fn build_section_index(repo: &Repository) -> SectionIndex {
130 let mut index = SectionIndex {
131 branch: current_branch(repo),
132 ..Default::default()
133 };
134 populate_ahead_behind(repo, &mut index);
135 index.in_flight = lattice_vcs::InFlightOp::detect(repo);
142 index.bisect = match lattice_vcs::Bisect::state(repo) {
143 Ok(state) => state,
144 Err(e) => {
145 tracing::debug!("magit-status: bisect state unreadable: {e}");
146 None
147 }
148 };
149
150 let statuses = match WorkingTree::statuses(repo) {
151 Ok(s) => s,
152 Err(_) => return index,
153 };
154
155 let mut staged: Vec<SectionEntry> = Vec::new();
156 let mut unstaged: Vec<SectionEntry> = Vec::new();
157 let mut untracked: Vec<SectionEntry> = Vec::new();
158
159 for (path, change) in statuses {
167 if let Some(staged_status) = change.staged {
168 staged.push(SectionEntry::File {
169 path: path.clone(),
170 status: staged_status,
171 original_path: change.original_path.clone(),
173 });
174 }
175 match change.unstaged {
176 Some(PathStatus::Untracked) => {
177 untracked.push(SectionEntry::UntrackedFile { path });
178 }
179 Some(PathStatus::Ignored) | None => {}
182 Some(unstaged_status) => {
183 unstaged.push(SectionEntry::File {
184 path,
185 status: unstaged_status,
186 original_path: None,
187 });
188 }
189 }
190 }
191
192 let stashes: Vec<SectionEntry> = Stash::list(repo)
193 .unwrap_or_default()
194 .into_iter()
195 .map(|s| SectionEntry::Stash {
196 index: s.index,
197 message: s.message,
198 })
199 .collect();
200
201 index.upstream = upstream_ref(repo);
207 let unmerged: Vec<SectionEntry> = unmerged_commits(repo)
208 .into_iter()
209 .map(|(sha, subject)| SectionEntry::Commit { sha, subject })
210 .collect();
211
212 let commits: Vec<SectionEntry> = if unmerged.is_empty() {
216 recent_commits(repo)
217 .into_iter()
218 .map(|(sha, subject)| SectionEntry::Commit { sha, subject })
219 .collect()
220 } else {
221 Vec::new()
222 };
223
224 let mut line = 0usize;
225
226 let push_section = |idx: &mut SectionIndex,
227 entries: Vec<SectionEntry>,
228 kind: SectionKind,
229 line: &mut usize| {
230 if entries.is_empty() {
231 return;
232 }
233 let body_start = *line + 1;
234 let body_end = body_start + entries.len();
235 idx.sections.push(Section {
236 kind,
237 header_line: *line,
238 body_start,
239 body_end,
240 entries,
241 });
242 *line = body_end + 1; };
244
245 push_section(&mut index, staged, SectionKind::Staged, &mut line);
246 push_section(&mut index, unstaged, SectionKind::Unstaged, &mut line);
247 push_section(&mut index, untracked, SectionKind::Untracked, &mut line);
248 push_section(&mut index, stashes, SectionKind::Stashes, &mut line);
249 push_section(&mut index, unmerged, SectionKind::Unmerged, &mut line);
250 push_section(&mut index, commits, SectionKind::RecentCommits, &mut line);
253
254 index
255}
256
257fn current_branch(repo: &Repository) -> String {
258 repo.run_git_str(["rev-parse", "--abbrev-ref", "HEAD"])
259 .map(|s| s.trim().to_string())
260 .unwrap_or_else(|_| "(detached)".to_string())
261}
262
263fn populate_ahead_behind(repo: &Repository, index: &mut SectionIndex) {
264 if let Ok(output) =
265 repo.run_git_str(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"])
266 {
267 let parts: Vec<&str> = output.split_whitespace().collect();
268 if parts.len() == 2 {
269 index.ahead = parts[0].parse().unwrap_or(0);
272 index.behind = parts[1].parse().unwrap_or(0);
273 }
274 }
275}
276
277fn upstream_ref(repo: &Repository) -> Option<String> {
283 let out = repo
284 .run_git_str(["rev-parse", "--abbrev-ref", "@{upstream}"])
285 .ok()?;
286 let name = out.trim();
287 (!name.is_empty()).then(|| name.to_string())
288}
289
290fn unmerged_commits(repo: &Repository) -> Vec<(String, String)> {
300 let output = repo
301 .run_git_str(["log", "--format=%h %s", "@{upstream}..HEAD"])
302 .unwrap_or_default();
303 parse_oneline_log(&output)
304}
305
306fn recent_commits(repo: &Repository) -> Vec<(String, String)> {
307 let output = repo
308 .run_git_str(["log", "--oneline", "-20", "--format=%h %s"])
309 .unwrap_or_default();
310 parse_oneline_log(&output)
311}
312
313fn parse_oneline_log(output: &str) -> Vec<(String, String)> {
316 output
317 .lines()
318 .filter(|l| !l.is_empty())
319 .map(|line| {
320 let mut parts = line.splitn(2, ' ');
321 let sha = parts.next().unwrap_or("").to_string();
322 let subject = parts.next().unwrap_or("").to_string();
323 (sha, subject)
324 })
325 .collect()
326}
327
328pub async fn apply_and_highlight(
333 handle: Arc<dyn Document>,
334 text: String,
335 spans: Vec<Vec<StyledSpan>>,
336 pending_highlights: Option<PendingSyntheticHighlightsHandle>,
337 buffer_id: BufferId,
338) {
339 apply_and_highlight_refined(
340 handle,
341 text,
342 spans,
343 Vec::new(),
344 pending_highlights,
345 buffer_id,
346 )
347 .await
348}
349
350pub async fn apply_and_highlight_refined(
354 handle: Arc<dyn Document>,
355 text: String,
356 spans: Vec<Vec<StyledSpan>>,
357 refine: Vec<Vec<lattice_cells::RefineSpan>>,
358 pending_highlights: Option<PendingSyntheticHighlightsHandle>,
359 buffer_id: BufferId,
360) {
361 crate::buffer_io::replace_buffer_text(&handle, text).await;
362 if let Some(ref ph) = pending_highlights {
363 ph.store_refined_and_wake(buffer_id, spans, refine);
364 }
365}
366
367#[cfg(test)]
374mod expansion_survives_refresh {
375 use super::*;
376 use std::process::Command;
377
378 fn git_ok(dir: &std::path::Path, args: &[&str]) {
379 let st = Command::new("git")
380 .args(args)
381 .current_dir(dir)
382 .status()
383 .expect("git");
384 assert!(st.success(), "git {args:?} failed");
385 }
386
387 fn repo_with_an_unstaged_change() -> tempfile::TempDir {
389 let dir = tempfile::tempdir().expect("tempdir");
390 let p = dir.path();
391 git_ok(p, &["init"]);
392 git_ok(p, &["config", "user.email", "t@lattice.dev"]);
393 git_ok(p, &["config", "user.name", "lattice-test"]);
394 let base: String = (1..=20).map(|i| format!("line {i}\n")).collect();
395 std::fs::write(p.join("a.txt"), &base).unwrap();
396 git_ok(p, &["add", "a.txt"]);
397 git_ok(p, &["commit", "-m", "base"]);
398 let edited: String = (1..=20)
399 .map(|i| match i {
400 2 => "line 2 EDITED\n".to_string(),
401 19 => "line 19 EDITED\n".to_string(),
402 _ => format!("line {i}\n"),
403 })
404 .collect();
405 std::fs::write(p.join("a.txt"), &edited).unwrap();
406 dir
407 }
408
409 fn open_key() -> String {
410 crate::actions::entry_key(&crate::actions::StatusLine::File {
411 path: PathBuf::from("a.txt"),
412 staged: false,
413 untracked: false,
414 original_path: None,
415 })
416 }
417
418 fn repo_with_an_unstaged_rust_change() -> tempfile::TempDir {
421 let dir = tempfile::tempdir().expect("tempdir");
422 let p = dir.path();
423 git_ok(p, &["init"]);
424 git_ok(p, &["config", "user.email", "t@lattice.dev"]);
425 git_ok(p, &["config", "user.name", "lattice-test"]);
426 std::fs::write(p.join("a.rs"), "fn main() {\n let old = 1;\n}\n").unwrap();
427 git_ok(p, &["add", "a.rs"]);
428 git_ok(p, &["commit", "-m", "base"]);
429 std::fs::write(p.join("a.rs"), "fn main() {\n let new = 2;\n}\n").unwrap();
430 dir
431 }
432
433 fn rust_open_key() -> String {
434 crate::actions::entry_key(&crate::actions::StatusLine::File {
435 path: PathBuf::from("a.rs"),
436 staged: false,
437 untracked: false,
438 original_path: None,
439 })
440 }
441
442 #[test]
452 fn a_refreshed_expansion_keeps_its_syntax_highlighting() {
453 let dir = repo_with_an_unstaged_rust_change();
454 let wd = dir.path().to_path_buf();
455 let open: HashSet<String> = [rust_open_key()].into_iter().collect();
456 let registry = lattice_syntax::LangRegistry::standard().expect("standard registry");
457
458 let (_text, with_syntax, _h, _r, _) = build_and_format(&wd, &open, 3, Some(®istry));
459 let (_text, flat, _h, _r, _) = build_and_format(&wd, &open, 3, None);
460
461 let count = |rows: &Vec<Vec<StyledSpan>>| rows.iter().map(|r| r.len()).sum::<usize>();
464 assert!(
465 count(&with_syntax) > count(&flat),
466 "a registry must add the syntax layer: {} spans with, {} without",
467 count(&with_syntax),
468 count(&flat),
469 );
470 }
471
472 #[test]
477 fn refresh_and_toggle_produce_the_same_spans_for_one_diff() {
478 let dir = repo_with_an_unstaged_rust_change();
479 let wd = dir.path().to_path_buf();
480 let registry = lattice_syntax::LangRegistry::standard().expect("standard registry");
481 let line = crate::actions::StatusLine::File {
482 path: PathBuf::from("a.rs"),
483 staged: false,
484 untracked: false,
485 original_path: None,
486 };
487 let diff = crate::actions::run_show(&wd, &line, 3).expect("diff");
488 let diff = diff.trim_end();
489
490 let toggle = crate::hunk_syntax::diff_spans(diff, Some(®istry));
492
493 let open: HashSet<String> = [rust_open_key()].into_iter().collect();
495 let (text, refreshed, _h, _r, _) = build_and_format(&wd, &open, 3, Some(®istry));
496 let start = text
497 .lines()
498 .position(|l| l.starts_with("diff --git"))
499 .expect("the inlined diff is in the buffer");
500 let slice: Vec<Vec<StyledSpan>> = refreshed
501 .into_iter()
502 .skip(start)
503 .take(toggle.len())
504 .collect();
505
506 assert_eq!(
507 slice, toggle,
508 "an expansion must look identical however it got there"
509 );
510 }
511
512 #[test]
513 fn an_open_entrys_diff_comes_back_in_the_rebuilt_text() {
514 let dir = repo_with_an_unstaged_change();
515 let wd = dir.path().to_path_buf();
516
517 let collapsed = build_and_format(&wd, &HashSet::new(), 3, None);
518 assert!(
519 !collapsed.0.contains("@@"),
520 "nothing was open, so no diff is inlined:\n{}",
521 collapsed.0
522 );
523 assert!(collapsed.3.is_empty());
524
525 let open: HashSet<String> = [open_key()].into_iter().collect();
526 let (text, spans, _, reopened, _) = build_and_format(&wd, &open, 3, None);
527 assert!(
528 text.contains("line 2 EDITED"),
529 "the open entry's diff is inlined:\n{text}"
530 );
531 assert_eq!(
537 reopened.get(&open_key()).copied(),
538 Some(text.lines().count() - collapsed.0.lines().count()),
539 "the recomputed count is exactly the rows the expansion added"
540 );
541 assert_eq!(
542 spans.len(),
543 text.lines().count(),
544 "one span row per text row — a mismatch shifts every highlight below the diff"
545 );
546 }
547
548 #[test]
570 fn an_entry_with_nothing_expanded_contributes_no_fold_range() {
571 let dir = repo_with_an_unstaged_change();
572 let wd = dir.path().to_path_buf();
573
574 let (collapsed_text, _, _, collapsed_expanded, _) =
578 build_and_format(&wd, &HashSet::new(), 3, None);
579 assert!(collapsed_expanded.is_empty(), "nothing is expanded");
580
581 let open: HashSet<String> = [open_key()].into_iter().collect();
585 let (text, _, _, expanded, _) = build_and_format(&wd, &open, 3, None);
586 let count = expanded
587 .get(&open_key())
588 .copied()
589 .expect("the open entry records its row count");
590 let added = text.lines().count() - collapsed_text.lines().count();
591 assert_eq!(
592 count, added,
593 "the fold body is exactly the rows the expansion added — a \
594 count larger than that is precisely the range that swallows \
595 the entries below it"
596 );
597
598 let header = text
600 .lines()
601 .position(|l| l.contains("a.txt"))
602 .expect("the entry row");
603 let body: Vec<&str> = text.lines().skip(header + 1).take(count).collect();
604 assert!(
605 body.iter().any(|l| l.starts_with("@@")),
606 "the folded body is the diff:\n{body:#?}"
607 );
608 assert!(
609 !body
610 .iter()
611 .any(|l| l.trim_start().starts_with("Untracked") || l.contains("Recent commits")),
612 "and it stops before the next section:\n{body:#?}"
613 );
614 }
615
616 #[test]
619 fn the_rebuilt_key_is_the_one_the_toggle_uses() {
620 let dir = repo_with_an_unstaged_change();
621 let open: HashSet<String> = [open_key()].into_iter().collect();
622 let (_, _, _, reopened, _) = build_and_format(&dir.path().to_path_buf(), &open, 3, None);
623 assert!(
624 reopened.contains_key(&open_key()),
625 "keyed as `f:false:a.txt`, the same as `classify_line` derives from the row"
626 );
627 }
628
629 #[test]
632 fn a_stale_key_expands_nothing_and_does_not_survive() {
633 let dir = repo_with_an_unstaged_change();
634 let stale: HashSet<String> = ["f:false:gone.txt".to_string()].into_iter().collect();
635 let (text, _, _, reopened, _) =
636 build_and_format(&dir.path().to_path_buf(), &stale, 3, None);
637 assert!(!text.contains("@@"), "nothing inlined:\n{text}");
638 assert!(reopened.is_empty(), "the stale key is dropped, not carried");
639 }
640}
641
642#[cfg(test)]
643mod unmerged_into_upstream {
644 use super::*;
645 use std::process::Command;
646
647 fn git_ok(dir: &std::path::Path, args: &[&str]) {
648 let st = Command::new("git")
649 .args(args)
650 .current_dir(dir)
651 .status()
652 .expect("git");
653 assert!(st.success(), "git {args:?} failed");
654 }
655
656 fn repo_tracking_upstream(ahead: usize) -> (tempfile::TempDir, tempfile::TempDir) {
663 let remote = tempfile::tempdir().expect("tempdir");
664 git_ok(remote.path(), &["init", "--bare"]);
665
666 let dir = tempfile::tempdir().expect("tempdir");
667 let p = dir.path();
668 git_ok(p, &["init"]);
669 git_ok(p, &["config", "user.email", "t@lattice.dev"]);
670 git_ok(p, &["config", "user.name", "lattice-test"]);
671 std::fs::write(p.join("a.txt"), "base\n").unwrap();
672 git_ok(p, &["add", "a.txt"]);
673 git_ok(p, &["commit", "-m", "base"]);
674 git_ok(p, &["branch", "-M", "main"]);
675 let remote_url = remote.path().to_string_lossy().into_owned();
676 git_ok(p, &["remote", "add", "origin", &remote_url]);
677 git_ok(p, &["push", "-u", "origin", "main"]);
678
679 for i in 0..ahead {
680 std::fs::write(p.join("a.txt"), format!("change {i}\n")).unwrap();
681 git_ok(p, &["add", "a.txt"]);
682 git_ok(p, &["commit", "-m", &format!("local change {i}")]);
683 }
684 (dir, remote)
685 }
686
687 fn kinds(index: &SectionIndex) -> Vec<SectionKind> {
688 index.sections.iter().map(|s| s.kind).collect()
689 }
690
691 #[test]
694 fn unpushed_commits_appear_under_the_upstream_name() {
695 let (dir, _remote) = repo_tracking_upstream(3);
696 let repo = Repository::discover(dir.path()).expect("repo");
697 let index = build_section_index(&repo);
698
699 assert_eq!(index.upstream.as_deref(), Some("origin/main"));
700 let unmerged = index
701 .sections
702 .iter()
703 .find(|s| s.kind == SectionKind::Unmerged)
704 .expect("an unmerged section");
705 assert_eq!(unmerged.entries.len(), 3, "one row per unpushed commit");
706
707 let rendered = index.format_buffer();
708 assert!(
709 rendered.contains("Unmerged into origin/main (3)"),
710 "header names the upstream and counts: {rendered}"
711 );
712 }
713
714 #[test]
718 fn recent_commits_are_hidden_while_anything_is_unmerged() {
719 let (dir, _remote) = repo_tracking_upstream(2);
720 let repo = Repository::discover(dir.path()).expect("repo");
721 let k = kinds(&build_section_index(&repo));
722 assert!(k.contains(&SectionKind::Unmerged), "got {k:?}");
723 assert!(
724 !k.contains(&SectionKind::RecentCommits),
725 "recent commits must yield to unmerged: {k:?}"
726 );
727 }
728
729 #[test]
732 fn recent_commits_return_once_everything_is_pushed() {
733 let (dir, _remote) = repo_tracking_upstream(2);
734 git_ok(dir.path(), &["push", "origin", "main"]);
735 let repo = Repository::discover(dir.path()).expect("repo");
736 let k = kinds(&build_section_index(&repo));
737 assert!(
738 !k.contains(&SectionKind::Unmerged),
739 "nothing is unpushed now: {k:?}"
740 );
741 assert!(
742 k.contains(&SectionKind::RecentCommits),
743 "recent commits come back: {k:?}"
744 );
745 }
746
747 #[test]
751 fn a_branch_without_an_upstream_shows_recent_commits() {
752 let dir = tempfile::tempdir().expect("tempdir");
753 let p = dir.path();
754 git_ok(p, &["init"]);
755 git_ok(p, &["config", "user.email", "t@lattice.dev"]);
756 git_ok(p, &["config", "user.name", "lattice-test"]);
757 std::fs::write(p.join("a.txt"), "base\n").unwrap();
758 git_ok(p, &["add", "a.txt"]);
759 git_ok(p, &["commit", "-m", "base"]);
760
761 let repo = Repository::discover(p).expect("repo");
762 let index = build_section_index(&repo);
763 assert_eq!(index.upstream, None);
764 let k = kinds(&index);
765 assert!(!k.contains(&SectionKind::Unmerged), "got {k:?}");
766 assert!(k.contains(&SectionKind::RecentCommits), "got {k:?}");
767 }
768
769 #[test]
770 fn the_log_parser_splits_sha_from_subject() {
771 let parsed = parse_oneline_log("abc1234 fix the thing\ndef5678 another\n");
772 assert_eq!(
773 parsed,
774 vec![
775 ("abc1234".to_string(), "fix the thing".to_string()),
776 ("def5678".to_string(), "another".to_string()),
777 ]
778 );
779 }
780}