Skip to main content

lattice_magit/
magit_file_revision_mode.rs

1//! `*magit:file:<ref>:<path>*` — a file's content at a fixed
2//! reference point, read-only.
3//!
4//! `<CR>` on a file line inside any magit buffer tied to a specific
5//! commit or index state (magit-revision, the staged region of
6//! magit-commit/magit-diff) resolves to this buffer instead of the
7//! live working-tree file — the diff/commit you were looking at
8//! describes a SPECIFIC version of the file, and the working-tree
9//! copy may already have diverged from it (mid-rebase, after a
10//! later edit, or simply because the commit is historical). Visiting
11//! the CURRENT file for editing stays `<CR>` in buffers that are
12//! themselves about current state (magit-status, magit-diff's
13//! Unstaged scope) — see `magit.md` §6.3 for the full uniformity
14//! rule across views.
15//!
16//! `<ref>` is either a real commit-ish (sha/tag/branch) or the
17//! literal token `staged`, meaning "the index's blob for this path"
18//! (`git show :<path>`) rather than a commit.
19
20use 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            // MG.23f: blob navigation. Walking one file's history is
45            // what this buffer is for — without these you can open a
46            // revision but not step through them, so every step means
47            // going back to the log.
48            //
49            // magit binds these to `n` / `p`; evil-collection-magit
50            // remaps them to `gk` / `gj` and lattice follows it, for
51            // the reason the remap exists: `n` is search-repeat, and a
52            // read-only view of a file is exactly where you search.
53            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/// MG.23f: which way [`blob_step`] walks the file's history.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum BlobStep {
62    /// Older — the next entry in `git rev-list` order.
63    Previous,
64    /// Newer.
65    Next,
66}
67
68/// The revision one step from `current` in `revisions`, which is
69/// newest-first (`git rev-list` order).
70///
71/// `None` at either end rather than wrapping: a file's history has two
72/// ends and silently jumping from the first commit to HEAD would read
73/// as a glitch rather than as an edge. `None` too when `current` is not
74/// in the list at all — which is what a `staged` pseudo-ref is, since
75/// the index is not a commit and has no place in the walk.
76///
77/// Pure, so the walk is testable without a repository.
78pub(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
87/// Every commit that touched `path`, newest first.
88///
89/// `--follow` is deliberately absent: it would make the walk cross
90/// renames, and the buffer name carries one path — a step that silently
91/// changed which file you were reading would be worse than stopping.
92fn 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
106/// MG.23f: `p` / `n` — the same file one revision older / newer.
107fn 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        // MR.4: the revisions walked are this blob's repository's, not
115        // the process's — `p` / `n` must stay inside the checkout the
116        // buffer came from.
117        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                // MR.3b: stay in the repository this blob came from.
122                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            // Saying which end you are at beats a key that appears
133            // broken — and `staged` lands here too, since the index is
134            // not a commit and has no place in the walk.
135            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    /// MG.14: the headerline registration — this mode's only
159    /// per-activation resource. Dropping it removes the sticky row.
160    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    /// MG.RO: `read-only-mode` is where the gate actually is.
181    ///
182    /// `ReadOnly = true` above stops TYPING and nothing else. It is read by
183    /// `read_only_edit_rejected`, which guards the insert-mode char path;
184    /// operators never reach it, because a `Document`'s grammar dispatch
185    /// applies its own edits and hands the host an already-applied
186    /// `Effect::Edits`. `x` deleted a character out of `*magit:status*` while
187    /// the buffer reported itself read-only — worse than not gating at all,
188    /// because it looks protected.
189    ///
190    /// `read-only-mode` carries the option AND the `invocation_runner`
191    /// (`Editor::run_read_only_motion`) that refuses mutating operators while
192    /// letting motions, `:` and `/` through.
193    ///
194    /// Declared per MAJOR rather than once on `magit-core-mode`: an implied
195    /// mode is followed from the mode being ACTIVATED, and the majors are what
196    /// the host activates. Putting it on the shared minor looked right and was
197    /// verified not to fire.
198    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        // No mode-specific chords — `q`/`gr`/nav come from magit-core
212        // (this mode is in its `ActivationPolicy::Majors` list). `gr`
213        // is a harmless no-op here (no refresh handler registered):
214        // a fixed ref's blob content doesn't change.
215        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            // MR.3: the repository the trigger resolved for THIS
228            // buffer, not the one the editor was started in.
229            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            // MG.14: `<path> @ <ref>`. Without it this buffer is
237            // indistinguishable from the live file — which is exactly
238            // the mistake the mode exists to prevent. Both fields come
239            // out of the buffer name, so the header is complete before
240            // `git show` runs.
241            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            // MG.26c: the content and its highlighting come out of the
251            // SAME blocking task. Parsing a whole file is exactly the
252            // work paramount goal #1 keeps off the actor thread, and
253            // splitting it into a second task would mean reading the
254            // text back out of the buffer to parse it.
255            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                    // An unrecognised extension or a missing grammar
275                    // leaves the buffer plain, which is what it was
276                    // before — never a failure.
277                    Some(spans) => ph.store_and_wake(buffer_id, spans),
278                    None => ph.wake(),
279                }
280            }
281
282            Ok(hl_registration)
283        })
284    }
285}
286
287/// `(ref, path)` → `"*magit:file:<ref>:<path>*"`.
288///
289/// **One producer, one parser.** Nine sites used to build this string
290/// by hand while [`parse_buffer_name`] read it, and the pairing is
291/// load-bearing in a way that fails silently: MG.26b's reverse blame
292/// leaves a request keyed by this exact name for `on_activate` to
293/// consume, so a single formatting difference means the request is
294/// never found and the buffer forward-blames instead — the wrong
295/// answer, with no error. MG.15 lost every stash chord to the same
296/// producer/parser split.
297///
298/// `git_ref` is a commit-ish, the literal `staged`, or a `stash@{N}`.
299pub(crate) const FILE_VIEW: &str = "file";
300
301/// MR.3b: this view's `rest` — `<ref>:<path>`, behind the repository.
302pub(crate) fn file_view_rest(git_ref: &str, path: &std::path::Path) -> String {
303    format!("{git_ref}:{}", path.display())
304}
305
306/// The full name, for the producers that hold a repository label rather
307/// than a trigger's services — `<CR>` on a file row, a hunk, a picker
308/// result. They are all *inside* a magit buffer already, so the label
309/// they pass is the one their own name carries
310/// ([`crate::repo_scope::label_of_buffer`]), and the opened buffer
311/// recovers the path from it at activation.
312pub(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
316/// `"*magit:file:<ref>:<path>*"` → `(ref, path)`. `ref` never
317/// contains `:` (it's a sha/branch name or the literal `staged`
318/// token), so the FIRST `:` in the stripped name is the ref/path
319/// boundary — everything after it is the path, even if the path
320/// itself contains further `:` characters (rare on POSIX, but not
321/// disallowed).
322pub(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
332/// The object git wants for "this path at this ref" — or, for the
333/// `staged` pseudo-ref, `:<path>`, git's own syntax for "stage 0 of the
334/// index" (the staged blob). One producer, because the preview path and
335/// the open path must ask git for the same object.
336fn 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
344/// `git show <ref>:<path>`.
345fn 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
357/// MG.54: the biggest blob worth fetching **synchronously** for a
358/// preview. Matches the host's own bounded preview read, so a blob and a
359/// file of the same size cost the same peek.
360const PREVIEW_MAX_BYTES: u64 = 256 * 1024;
361
362/// MG.54: the same blob, fetched for a PREVIEW rather than to open.
363///
364/// Three things separate it from [`run_show_file`], and each is the
365/// reason it is not simply that function with a cap bolted on:
366///
367/// - **It asks the size first.** `git cat-file -s` reads the object
368///   header, not the object, so refusing a 40MB blob costs nothing —
369///   whereas capping the output of `git show` would already have paid
370///   for it. Over the limit it returns a note, which is a preview pane
371///   saying why it is empty rather than an editor that stopped
372///   responding.
373/// - **It never returns raw bytes.** A blob at a revision can be a PNG;
374///   its escape sequences would reach the terminal and corrupt the
375///   alternate screen. NUL ⇒ binary placeholder, control characters
376///   stripped otherwise (tab kept).
377/// - **It is bounded in lines as well as bytes**, since a 200k-line
378///   minified file is under the byte cap and still nothing anyone reads.
379///
380/// Returns `None` when git has no such object — a file that did not
381/// exist at that revision is the ordinary case (it is why you are
382/// looking), and an error pane would be noise. The caller leaves the
383/// previous preview up.
384pub(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    /// Producer and parser are one pair, and every caller uses the
431    /// producer.
432    ///
433    /// The failure this prevents is silent: MG.26b's reverse blame
434    /// leaves a request keyed by this exact string for `on_activate` to
435    /// consume, so one formatting difference means the request is never
436    /// found and the buffer forward-blames instead — the wrong answer
437    /// with no error. Nine sites used to build it by hand.
438    #[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    /// A path containing `:` is rare but legal on POSIX, and the parser
456    /// splits on the FIRST colon precisely so it survives.
457    #[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        // MR.3b: a name with no `rest` is not a blob at all — there is
489        // no ref and no path, only a repository.
490        assert!(parse_buffer_name("*magit:file:lattice*").is_none());
491        assert!(parse_buffer_name("not a magit buffer").is_none());
492    }
493
494    /// The list is newest-first, so "previous" walks *forward* through
495    /// it — the one inversion in this module and the one worth pinning.
496    #[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    /// `staged` is not a commit, so it is not in the walk. Landing on
517    /// `None` is what turns `p` there into the echo rather than a jump
518    /// to whatever happened to sit at index 0.
519    #[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/// MG.23f: the walk against a real repository — `file_revisions` must
528/// agree with git about both *which* commits touched the file and what
529/// order they come in, or `p`/`n` step somewhere plausible and wrong.
530#[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    /// Three commits, but only two touch `a.txt` — so a walk that
554    /// listed every commit instead of the file's own would step into a
555    /// revision where `a.txt` never changed.
556    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        // ...and back again, so the pair is genuinely an inverse.
601        assert_eq!(
602            blob_step(&revs, &earlier, BlobStep::Next).as_deref(),
603            Some(head.as_str())
604        );
605        // The earlier revision is the file's first, so there is no
606        // further step back.
607        assert!(blob_step(&revs, &earlier, BlobStep::Previous).is_none());
608    }
609
610    // ── MG.54: the preview fetch and its guards ──────────────────
611
612    /// The ordinary case: the content as it was, not as it is.
613    #[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    /// A file that did not exist at that revision is the ORDINARY case —
635    /// it is often why you are looking. `None` (leave the previous
636    /// preview up) rather than an error pane full of git's wording.
637    #[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    /// The size guard reads the object HEADER (`cat-file -s`), so
648    /// refusing costs nothing — the point is that the big blob is never
649    /// fetched, on a path that runs synchronously on the actor thread.
650    #[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    /// A blob at a revision can be a PNG. Its bytes would reach the
671    /// terminal and corrupt the alternate screen, so binary gets a
672    /// placeholder — the same answer the host's file preview gives.
673    #[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    /// Control characters in a *text* blob are stripped too — a "text"
690    /// file carrying a stray escape is the case that leaves garbage on
691    /// screen precisely because it passes the binary check.
692    #[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        // Only the ESC byte is removed; `[31m` is printable and stays,
702        // which is the same trade the host's own bounded file preview
703        // makes — the terminal is protected, the text is not rewritten.
704        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}