1use std::path::{Path, PathBuf};
21use std::sync::OnceLock;
22
23use lattice_config;
24use lattice_grammar::Effect;
25use lattice_mode::{
26 CapabilitySet, Keymap, KeymapEntry, LifecycleFuture, Mode, ModeContext, ModeId, ModeKind,
27 OptionOverrideSet, keymap_entry,
28};
29
30use crate::headerline;
31
32pub struct MagitFileRevisionMode;
33
34impl MagitFileRevisionMode {
35 pub fn mode_id() -> ModeId {
36 ModeId::new("magit-file-revision-mode")
37 }
38}
39
40fn magit_file_revision_keymap_entries() -> &'static [KeymapEntry] {
41 static ENTRIES: OnceLock<Vec<KeymapEntry>> = OnceLock::new();
42 ENTRIES.get_or_init(|| {
43 vec![
44 keymap_entry! { mode: Normal, chord: "gj", doc: "This file at the next revision", cmd: "action:magit-blob-next" },
54 keymap_entry! { mode: Normal, chord: "gk", doc: "This file at the previous revision", cmd: "action:magit-blob-previous" },
55 ]
56 })
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum BlobStep {
62 Previous,
64 Next,
66}
67
68pub(crate) fn blob_step(revisions: &[String], current: &str, step: BlobStep) -> Option<String> {
79 let at = revisions.iter().position(|r| r == current)?;
80 let target = match step {
81 BlobStep::Previous => at.checked_add(1)?,
82 BlobStep::Next => at.checked_sub(1)?,
83 };
84 revisions.get(target).cloned()
85}
86
87fn file_revisions(workdir: &Path, path: &Path) -> Vec<String> {
93 let out = std::process::Command::new("git")
94 .args(["log", "--format=%H", "--", &path.to_string_lossy()])
95 .current_dir(workdir)
96 .output();
97 match out {
98 Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
99 .lines()
100 .map(str::to_string)
101 .collect(),
102 _ => Vec::new(),
103 }
104}
105
106fn blob_step_handlers() -> Vec<lattice_mode::ActionHandlerContribution> {
108 fn step(ctx: &lattice_mode::ActionContext<'_>, step: BlobStep) -> Option<Effect> {
109 let buffer_id = lattice_core::BufferId(ctx.buffer_id.0 as u32);
110 let store = ctx.services.get::<lattice_mode::BufferStoreHandle>()?;
111 let (git_ref, path) = store
112 .name_for(buffer_id)
113 .and_then(|name| parse_buffer_name(&name))?;
114 let workdir = crate::repo_scope::action_workdir(ctx);
118 let revisions = file_revisions(&workdir, &path);
119 match blob_step(&revisions, &git_ref, step) {
120 Some(next) => Some(Effect::OpenSyntheticBuffer {
121 name: blob_buffer_name(
123 &crate::repo_scope::label_of_buffer(&store, buffer_id),
124 &next,
125 &path,
126 ),
127 mode_id: MagitFileRevisionMode::mode_id().to_string(),
128 content: None,
129 cursor: None,
130 activate_minor: None,
131 }),
132 None => Some(Effect::Echo {
136 level: lattice_grammar::EchoLevel::Info,
137 text: match step {
138 BlobStep::Previous => "magit: no earlier revision of this file".to_string(),
139 BlobStep::Next => "magit: already at the newest revision".to_string(),
140 },
141 }),
142 }
143 }
144
145 vec![
146 lattice_mode::ActionHandlerContribution {
147 action_name: "action:magit-blob-previous",
148 handler: std::sync::Arc::new(|ctx| step(ctx, BlobStep::Previous)),
149 },
150 lattice_mode::ActionHandlerContribution {
151 action_name: "action:magit-blob-next",
152 handler: std::sync::Arc::new(|ctx| step(ctx, BlobStep::Next)),
153 },
154 ]
155}
156
157impl Mode for MagitFileRevisionMode {
158 type Guard = Option<crate::headerline::HeaderlineRegistration>;
161
162 fn id(&self) -> ModeId {
163 Self::mode_id()
164 }
165 fn kind(&self) -> ModeKind {
166 ModeKind::Major
167 }
168 fn target_buffer_kind(&self) -> Option<lattice_core::BufferKind> {
169 None
170 }
171
172 fn options(&self) -> OptionOverrideSet {
173 lattice_config::overrides! {
174 lattice_config::ReadOnly = true,
175 lattice_config::NoFile = true,
176 lattice_config::Number = false,
177 }
178 }
179
180 fn implies(&self) -> &[lattice_mode::ModeId] {
199 static IMPLIED: std::sync::OnceLock<Vec<lattice_mode::ModeId>> = std::sync::OnceLock::new();
200 IMPLIED.get_or_init(|| vec![lattice_mode::modes::ReadOnlyMode::mode_id()])
201 }
202
203 fn required_capabilities(&self) -> CapabilitySet {
204 CapabilitySet::empty()
205 }
206 fn action_handlers(&self) -> Vec<lattice_mode::ActionHandlerContribution> {
207 blob_step_handlers()
208 }
209
210 fn keymap(&self) -> Keymap {
211 Keymap::from_entries(magit_file_revision_keymap_entries())
216 }
217
218 fn on_activate(&self, ctx: ModeContext) -> LifecycleFuture<'_, Self::Guard> {
219 Box::pin(async move {
220 let buffer_id = lattice_core::BufferId(ctx.buffer_id().0 as u32);
221 let Some(store) = ctx.service::<lattice_mode::BufferStoreHandle>() else {
222 return Ok(None);
223 };
224 let Some(handle) = store.handle_for(buffer_id) else {
225 return Ok(None);
226 };
227 let workdir =
230 crate::repo_scope::view_workdir(&ctx, buffer_id, &handle).unwrap_or_default();
231
232 let parsed = store
233 .name_for(buffer_id)
234 .and_then(|name| parse_buffer_name(&name));
235
236 let (hl, hl_registration) =
242 match headerline::install(&ctx, buffer_id, Self::mode_id().as_str()) {
243 Some((h, reg)) => (Some(h), Some(reg)),
244 None => (None, None),
245 };
246 if let Some((git_ref, path)) = parsed.as_ref() {
247 headerline::publish(&hl, headerline::file_revision_fields(git_ref, path));
248 }
249
250 let wd = workdir.clone();
256 let registry = ctx
257 .service::<std::sync::Arc<lattice_syntax::LangRegistry>>()
258 .map(|outer| (*outer).clone());
259 let (text, spans) = tokio::task::spawn_blocking(move || match parsed {
260 Some((git_ref, path)) => {
261 let text = run_show_file(&wd, &git_ref, &path);
262 let spans = registry
263 .as_ref()
264 .and_then(|r| crate::highlight::file_syntax_spans(&path, &text, r));
265 (text, spans)
266 }
267 None => ("No file/ref given.\n".to_string(), None),
268 })
269 .await
270 .unwrap_or_else(|_| (String::new(), None));
271 crate::buffer_io::replace_buffer_text(&handle, text).await;
272 if let Some(ph) = ctx.service::<lattice_mode::PendingSyntheticHighlights>() {
273 match spans {
274 Some(spans) => ph.store_and_wake(buffer_id, spans),
278 None => ph.wake(),
279 }
280 }
281
282 Ok(hl_registration)
283 })
284 }
285}
286
287pub(crate) const FILE_VIEW: &str = "file";
300
301pub(crate) fn file_view_rest(git_ref: &str, path: &std::path::Path) -> String {
303 format!("{git_ref}:{}", path.display())
304}
305
306pub(crate) fn blob_buffer_name(repo: &str, git_ref: &str, path: &std::path::Path) -> String {
313 crate::workdir::magit_buffer_name_with(FILE_VIEW, repo, &file_view_rest(git_ref, path))
314}
315
316pub(crate) fn parse_buffer_name(name: &str) -> Option<(String, PathBuf)> {
323 let parsed = crate::workdir::parse_magit_name(name)?;
324 (parsed.view == FILE_VIEW).then_some(())?;
325 let (git_ref, path) = parsed.rest?.split_once(':')?;
326 if git_ref.is_empty() || path.is_empty() {
327 return None;
328 }
329 Some((git_ref.to_string(), PathBuf::from(path)))
330}
331
332fn blob_spec(git_ref: &str, path: &Path) -> String {
337 if git_ref == "staged" {
338 format!(":{}", path.display())
339 } else {
340 format!("{git_ref}:{}", path.display())
341 }
342}
343
344fn run_show_file(workdir: &Path, git_ref: &str, path: &Path) -> String {
346 let spec = blob_spec(git_ref, path);
347 std::process::Command::new("git")
348 .args(["show", &spec])
349 .current_dir(workdir)
350 .output()
351 .ok()
352 .filter(|o| o.status.success())
353 .and_then(|o| String::from_utf8(o.stdout).ok())
354 .unwrap_or_else(|| format!("Could not show {spec}\n"))
355}
356
357const PREVIEW_MAX_BYTES: u64 = 256 * 1024;
361
362pub(crate) fn preview_blob(workdir: &Path, git_ref: &str, path: &Path) -> Option<String> {
385 const MAX_LINES: usize = 2000;
386 let spec = blob_spec(git_ref, path);
387 let size = std::process::Command::new("git")
388 .args(["cat-file", "-s", &spec])
389 .current_dir(workdir)
390 .output()
391 .ok()
392 .filter(|o| o.status.success())
393 .and_then(|o| String::from_utf8(o.stdout).ok())
394 .and_then(|s| s.trim().parse::<u64>().ok())?;
395 if size > PREVIEW_MAX_BYTES {
396 return Some(format!(
397 "{spec}\n\n{} KiB — too large to preview.\n\
398 Accept the revision to open it, or raise nothing: the limit \
399 exists so choosing a revision never blocks on a fetch.\n",
400 size / 1024
401 ));
402 }
403 let out = std::process::Command::new("git")
404 .args(["show", &spec])
405 .current_dir(workdir)
406 .output()
407 .ok()
408 .filter(|o| o.status.success())?;
409 if out.stdout.contains(&0) {
410 return Some(format!("{spec}\n\n<binary file — no preview>\n"));
411 }
412 let text = String::from_utf8_lossy(&out.stdout);
413 Some(
414 text.lines()
415 .take(MAX_LINES)
416 .map(|line| {
417 line.chars()
418 .filter(|c| !c.is_control() || *c == '\t')
419 .collect::<String>()
420 })
421 .collect::<Vec<_>>()
422 .join("\n"),
423 )
424}
425
426#[cfg(test)]
427mod blob_name {
428 use super::*;
429
430 #[test]
439 fn every_ref_form_round_trips_through_the_parser() {
440 for (git_ref, path) in [
441 ("a1b2c3d", "src/main.rs"),
442 ("staged", "src/main.rs"),
443 ("stash@{2}", "src/main.rs"),
444 ("HEAD", "a.txt"),
445 ("feature/branch-name", "deep/nested/path.rs"),
446 ] {
447 let name = blob_buffer_name("lattice", git_ref, std::path::Path::new(path));
448 let (back_ref, back_path) =
449 parse_buffer_name(&name).unwrap_or_else(|| panic!("`{name}` must parse back"));
450 assert_eq!(back_ref, git_ref, "ref lost in {name}");
451 assert_eq!(back_path, std::path::Path::new(path), "path lost in {name}");
452 }
453 }
454
455 #[test]
458 fn a_path_containing_a_colon_survives_the_round_trip() {
459 let name = blob_buffer_name("lattice", "HEAD", std::path::Path::new("weird:name.rs"));
460 let (r, p) = parse_buffer_name(&name).expect("parses");
461 assert_eq!(r, "HEAD");
462 assert_eq!(p, std::path::Path::new("weird:name.rs"));
463 }
464}
465
466#[cfg(test)]
467mod tests {
468 use super::*;
469
470 #[test]
471 fn parse_buffer_name_splits_ref_and_path() {
472 let (r, p) = parse_buffer_name("*magit:file:lattice:a1b2c3d:src/main.rs*").unwrap();
473 assert_eq!(r, "a1b2c3d");
474 assert_eq!(p, PathBuf::from("src/main.rs"));
475 }
476
477 #[test]
478 fn parse_buffer_name_handles_staged_pseudo_ref() {
479 let (r, p) = parse_buffer_name("*magit:file:lattice:staged:src/main.rs*").unwrap();
480 assert_eq!(r, "staged");
481 assert_eq!(p, PathBuf::from("src/main.rs"));
482 }
483
484 #[test]
485 fn parse_buffer_name_rejects_malformed_names() {
486 assert!(parse_buffer_name("*magit:file:lattice:*").is_none());
487 assert!(parse_buffer_name("*magit:file:lattice:onlyref*").is_none());
488 assert!(parse_buffer_name("*magit:file:lattice*").is_none());
491 assert!(parse_buffer_name("not a magit buffer").is_none());
492 }
493
494 #[test]
497 fn blob_step_walks_newest_first_order_in_both_directions() {
498 let revs = vec!["new".to_string(), "mid".to_string(), "old".to_string()];
499 assert_eq!(
500 blob_step(&revs, "mid", BlobStep::Previous).as_deref(),
501 Some("old")
502 );
503 assert_eq!(
504 blob_step(&revs, "mid", BlobStep::Next).as_deref(),
505 Some("new")
506 );
507 }
508
509 #[test]
510 fn blob_step_stops_at_both_ends_rather_than_wrapping() {
511 let revs = vec!["new".to_string(), "old".to_string()];
512 assert!(blob_step(&revs, "old", BlobStep::Previous).is_none());
513 assert!(blob_step(&revs, "new", BlobStep::Next).is_none());
514 }
515
516 #[test]
520 fn blob_step_refuses_a_ref_that_is_not_in_the_history() {
521 let revs = vec!["new".to_string(), "old".to_string()];
522 assert!(blob_step(&revs, "staged", BlobStep::Previous).is_none());
523 assert!(blob_step(&revs, "staged", BlobStep::Next).is_none());
524 }
525}
526
527#[cfg(test)]
531mod blob_navigation_round_trip {
532 use super::*;
533 use std::process::Command;
534
535 fn git_ok(dir: &Path, args: &[&str]) {
536 let st = Command::new("git")
537 .args(args)
538 .current_dir(dir)
539 .status()
540 .expect("git");
541 assert!(st.success(), "git {args:?} failed");
542 }
543
544 fn rev(dir: &Path, spec: &str) -> String {
545 let out = Command::new("git")
546 .args(["rev-parse", spec])
547 .current_dir(dir)
548 .output()
549 .expect("git");
550 String::from_utf8_lossy(&out.stdout).trim().to_string()
551 }
552
553 fn three_commit_repo() -> tempfile::TempDir {
557 let dir = tempfile::tempdir().expect("tempdir");
558 let p = dir.path();
559 git_ok(p, &["init"]);
560 git_ok(p, &["config", "user.email", "t@lattice.dev"]);
561 git_ok(p, &["config", "user.name", "lattice-test"]);
562 std::fs::write(p.join("a.txt"), "one\n").unwrap();
563 git_ok(p, &["add", "a.txt"]);
564 git_ok(p, &["commit", "-m", "a: one"]);
565 std::fs::write(p.join("b.txt"), "unrelated\n").unwrap();
566 git_ok(p, &["add", "b.txt"]);
567 git_ok(p, &["commit", "-m", "b: unrelated"]);
568 std::fs::write(p.join("a.txt"), "two\n").unwrap();
569 git_ok(p, &["add", "a.txt"]);
570 git_ok(p, &["commit", "-m", "a: two"]);
571 dir
572 }
573
574 #[test]
575 fn file_revisions_lists_only_the_commits_that_touched_the_file() {
576 let dir = three_commit_repo();
577 let revs = file_revisions(dir.path(), Path::new("a.txt"));
578 assert_eq!(
579 revs.len(),
580 2,
581 "the unrelated middle commit must not be in a.txt's history: {revs:?}"
582 );
583 assert_eq!(revs[0], rev(dir.path(), "HEAD"), "newest first");
584 }
585
586 #[test]
587 fn stepping_back_from_head_lands_on_the_files_earlier_revision() {
588 let dir = three_commit_repo();
589 let p = dir.path();
590 let revs = file_revisions(p, Path::new("a.txt"));
591 let head = rev(p, "HEAD");
592
593 let earlier = blob_step(&revs, &head, BlobStep::Previous).expect("an earlier revision");
594 assert_eq!(
595 run_show_file(p, &earlier, Path::new("a.txt")).trim(),
596 "one",
597 "`p` must land on the content as it was, not on HEAD's"
598 );
599
600 assert_eq!(
602 blob_step(&revs, &earlier, BlobStep::Next).as_deref(),
603 Some(head.as_str())
604 );
605 assert!(blob_step(&revs, &earlier, BlobStep::Previous).is_none());
608 }
609
610 #[test]
614 fn preview_shows_the_blob_at_that_revision() {
615 let dir = three_commit_repo();
616 let p = dir.path();
617 let revs = file_revisions(p, Path::new("a.txt"));
618 let earlier = blob_step(&revs, &rev(p, "HEAD"), BlobStep::Previous).expect("an earlier");
619
620 assert_eq!(
621 preview_blob(p, &earlier, Path::new("a.txt"))
622 .expect("the blob exists at that revision")
623 .trim(),
624 "one"
625 );
626 assert_eq!(
627 preview_blob(p, "HEAD", Path::new("a.txt"))
628 .expect("HEAD has it too")
629 .trim(),
630 "two"
631 );
632 }
633
634 #[test]
638 fn a_path_absent_at_that_revision_previews_nothing() {
639 let dir = three_commit_repo();
640 let p = dir.path();
641 assert!(
642 preview_blob(p, "HEAD", Path::new("never-existed.txt")).is_none(),
643 "no object ⇒ no preview, not an error pane"
644 );
645 }
646
647 #[test]
651 fn an_oversized_blob_is_refused_with_a_note_instead_of_fetched() {
652 let dir = three_commit_repo();
653 let p = dir.path();
654 let big = "x".repeat(PREVIEW_MAX_BYTES as usize + 1024);
655 std::fs::write(p.join("big.txt"), &big).unwrap();
656 git_ok(p, &["add", "big.txt"]);
657 git_ok(p, &["commit", "-m", "big"]);
658
659 let out = preview_blob(p, "HEAD", Path::new("big.txt")).expect("a note, not nothing");
660 assert!(
661 out.contains("too large to preview"),
662 "the pane must say why it is empty; got {out:?}"
663 );
664 assert!(
665 !out.contains("xxxx"),
666 "the blob itself must not have been fetched"
667 );
668 }
669
670 #[test]
674 fn a_binary_blob_previews_as_a_placeholder() {
675 let dir = three_commit_repo();
676 let p = dir.path();
677 std::fs::write(p.join("bin.dat"), [0x89u8, 0x50, 0x00, 0x1b, 0x5b, 0x41]).unwrap();
678 git_ok(p, &["add", "bin.dat"]);
679 git_ok(p, &["commit", "-m", "bin"]);
680
681 let out = preview_blob(p, "HEAD", Path::new("bin.dat")).expect("a placeholder");
682 assert!(out.contains("binary"), "got {out:?}");
683 assert!(
684 !out.contains('\u{1b}'),
685 "an escape byte must never reach the pane"
686 );
687 }
688
689 #[test]
693 fn escape_bytes_in_a_text_blob_are_stripped() {
694 let dir = three_commit_repo();
695 let p = dir.path();
696 std::fs::write(p.join("sneaky.txt"), "before\u{1b}[31mafter\n\tkept\n").unwrap();
697 git_ok(p, &["add", "sneaky.txt"]);
698 git_ok(p, &["commit", "-m", "sneaky"]);
699
700 let out = preview_blob(p, "HEAD", Path::new("sneaky.txt")).expect("text");
701 assert!(!out.contains('\u{1b}'), "escape stripped; got {out:?}");
705 assert!(out.contains("before[31mafter"), "the text itself survives");
706 assert!(out.contains('\t'), "tabs are kept — they are layout");
707 }
708}