Skip to main content

lattice_magit/
fold_source.rs

1//! Fold ranges for magit-status inline expansions — the ENTRY level.
2//!
3//! An overlay [`FoldSource`] that reads live state on every
4//! `compute_folds()` call rather than caching stale line numbers, so
5//! it never desyncs from concurrent edits. It emits one fold per
6//! expanded entry (file / stash / commit), spanning the entry's header
7//! row through the end of its inserted patch — so folding "this entry"
8//! hides the whole thing.
9//!
10//! **MG.45 moved the levels BELOW that out.** File and hunk folds now
11//! come from [`crate::hunk_fold_source::MagitHunkFoldSource`], which
12//! `magit-hunk-mode` registers on every buffer that renders a diff.
13//! Two reasons, and the second is the one that mattered:
14//!
15//! 1. Only magit-status had a fold source, so the commit, diff,
16//!    revision and stash-show buffers had no diff-aware folds at all.
17//! 2. This source can only see ONE expansion's rows. A commit or
18//!    stash entry expands to a MULTI-file patch (`git show`,
19//!    `stash show -p`), which needs a file level between entry and
20//!    hunk — and every file's hunks were landing as siblings directly
21//!    under the entry instead.
22//!
23//! The two sources compose by range containment, the way the fold
24//! engine expresses nesting everywhere else, so an expanded commit
25//! folds as entry ▸ file ▸ hunk with neither source knowing about the
26//! other.
27//!
28//! Registered by `MagitStatusMode::on_activate` via
29//! `FoldOverlayServiceHandle`; `MagitStatusGuard::drop` removes it
30//! (same Drop-based lifecycle `DiffModeGuard` and
31//! `MultibufferModeGuard` use).
32
33use std::hash::{DefaultHasher, Hash, Hasher};
34use std::sync::{Arc, Mutex};
35
36use lattice_core::{BufferId, Fold, FoldSource, ProviderId};
37
38use crate::actions::{StatusBufferState, classify_line, entry_key};
39
40/// Namespace for per-buffer magit-status fold-source ids, OR'd with
41/// the buffer's id so simultaneous magit-status buffers (unusual,
42/// but not disallowed) register distinct overlay ids.
43pub const MAGIT_STATUS_FOLD_NAMESPACE: u64 = 0x6A61_0001_0000_0000;
44
45pub struct MagitStatusFoldSource {
46    id: ProviderId,
47    state: Arc<Mutex<StatusBufferState>>,
48}
49
50impl MagitStatusFoldSource {
51    pub fn new(state: Arc<Mutex<StatusBufferState>>, buffer_id: BufferId) -> Self {
52        Self {
53            id: ProviderId(MAGIT_STATUS_FOLD_NAMESPACE | buffer_id.0 as u64),
54            state,
55        }
56    }
57}
58
59/// MG.44: a fold's identity, which is what carries its **closed
60/// state** across recomputes.
61///
62/// Keyed by the entry (`Staged:src/a.rs`), NOT by the line it happens
63/// to sit on. `Fold::identity` exists precisely "so that adding or
64/// removing lines elsewhere in the buffer doesn't reopen this fold" —
65/// hashing the line number is the one thing guaranteed to defeat that,
66/// because a refresh reorders sections and rewrites diffs above.
67///
68/// This mattered less when `=` deleted the diff outright: a collapsed
69/// entry left `expanded` and had nothing to reopen. Now that `=`
70/// folds, a fold that silently reopened on `gr` would look exactly
71/// like the editor forgetting what the user just did.
72///
73/// `discriminator` separates the nested levels within one entry — the
74/// entry fold itself from each of its hunks.
75fn fold_identity(namespace: &str, key: &str, discriminator: usize) -> u64 {
76    let mut h = DefaultHasher::new();
77    namespace.hash(&mut h);
78    key.hash(&mut h);
79    discriminator.hash(&mut h);
80    h.finish()
81}
82
83/// The stable key for a section fold.
84///
85/// The rendered header carries a count (`Unstaged changes (3)`) that
86/// changes whenever the working tree does, so the KEY is the fixed
87/// prefix, never the row's text. Identity is what carries closed-state
88/// across a recompute — keying on the text would reopen a collapsed
89/// section on exactly the `gr` that had something to report, which is
90/// the bug `fold_identity` was written to prevent.
91fn section_key(text: &str) -> Option<&'static str> {
92    crate::sections::SECTION_HEADER_PREFIXES
93        .iter()
94        .copied()
95        .find(|prefix| text.starts_with(prefix))
96}
97
98impl FoldSource for MagitStatusFoldSource {
99    fn id(&self) -> ProviderId {
100        self.id
101    }
102
103    fn compute_folds(&self) -> Vec<Fold> {
104        let Ok(g) = self.state.lock() else {
105            return Vec::new();
106        };
107        let Some(handle) = g.store.handle_for(g.buffer_id) else {
108            return Vec::new();
109        };
110        let snap = handle.snapshot();
111        let total = snap.buffer.content_line_count();
112        let mut folds = Vec::new();
113
114        // MG.48: the SECTION level.
115        //
116        // magit-status's sections are the buffer's top-level structure,
117        // and only magit-status knows what a section is — the same
118        // reason this source owns the entry level.
119        //
120        // Declared here because MG.46 pinned `foldmethod=manual` on
121        // every magit buffer that renders a diff, so no primary
122        // provider fabricates code-level folds inside hunk text.
123        // Section collapsing used to fall out of the `indent` primary
124        // as a side effect of the rows being indented under their
125        // header — something magit never declared, and which therefore
126        // vanished the moment the primary was turned off. A structure
127        // this mode depends on should not have been borrowed from a
128        // generic provider's incidental behaviour.
129        //
130        // Computed regardless of `expanded`: a section is foldable
131        // whether or not any entry inside it has been opened.
132        let starts: Vec<(u32, &'static str)> = (0..total)
133            .filter_map(|line| {
134                let text = snap.buffer.line(line)?;
135                section_key(text.trim()).map(|key| (line, key))
136            })
137            .collect();
138        for (n, &(start, key)) in starts.iter().enumerate() {
139            let limit = starts.get(n + 1).map(|&(l, _)| l).unwrap_or(total);
140            // Trim trailing blanks so a collapsed section does not
141            // swallow the blank row that delimits it from the next.
142            let mut end = limit.saturating_sub(1);
143            while end > start
144                && snap
145                    .buffer
146                    .line(end)
147                    .map(|l| l.trim().is_empty())
148                    .unwrap_or(false)
149            {
150                end -= 1;
151            }
152            if end > start {
153                folds.push(Fold {
154                    start_line: start,
155                    end_line: end,
156                    closed: false,
157                    identity: Some(fold_identity("magit:section", key, 0)),
158                });
159            }
160        }
161
162        if g.expanded.is_empty() {
163            return folds;
164        }
165        let mut line = 0u32;
166        while line < total {
167            let Some(text) = snap.buffer.line(line) else {
168                break;
169            };
170            if !text.starts_with("  ") {
171                line += 1;
172                continue;
173            }
174            // Every classified entry is exactly one line; if it's a
175            // currently-expanded one, its patch occupies the next
176            // `count` lines (an exact count from insertion time, not
177            // a re-scanned guess) — fold that span, then jump past it
178            // so the scan never has to disambiguate patch content
179            // from real entries.
180            if let Some(sl) = classify_line(&g, line) {
181                let key = entry_key(&sl);
182                if let Some(&count) = g.expanded.get(&key)
183                    && count > 0
184                {
185                    let count = count as u32;
186                    let body_end = (line + count).min(total.saturating_sub(1));
187                    folds.push(Fold {
188                        start_line: line,
189                        end_line: body_end,
190                        closed: false,
191                        identity: Some(fold_identity("magit:entry", &key, 0)),
192                    });
193                    // MG.45: the hunk level moved to
194                    // `MagitHunkFoldSource`, which magit-hunk-mode
195                    // registers on every buffer that renders a
196                    // diff. Emitting it here too would put two
197                    // folds over the same rows from two providers
198                    // — and it could only ever describe a
199                    // SINGLE-file expansion, because a multi-file
200                    // one (`git show`, `stash show -p`) needs a
201                    // file level between entry and hunk that this
202                    // source has no way to see.
203                    line += 1 + count;
204                    continue;
205                }
206            }
207            line += 1;
208        }
209        folds
210    }
211}
212
213#[cfg(test)]
214mod section_key_tests {
215    use super::section_key;
216
217    /// MG.48: **a section's key must not carry its count.**
218    ///
219    /// The header renders as `Unstaged changes (3)`, and the count
220    /// changes whenever the working tree does. Keying identity on the
221    /// row's text would reopen a collapsed section on exactly the `gr`
222    /// that had something to report — the same failure `fold_identity`
223    /// exists to prevent, one level up.
224    #[test]
225    fn a_sections_key_ignores_its_changing_count() {
226        assert_eq!(
227            section_key("Unstaged changes (3)"),
228            section_key("Unstaged changes (11)"),
229            "the same section before and after a change must key alike",
230        );
231        assert_eq!(
232            section_key("Unstaged changes (3)"),
233            Some("Unstaged changes")
234        );
235    }
236
237    /// Every rendered section is foldable, and nothing else is.
238    #[test]
239    fn every_section_header_keys_and_ordinary_rows_do_not() {
240        for prefix in crate::sections::SECTION_HEADER_PREFIXES {
241            assert_eq!(
242                section_key(&format!("{prefix} (2)")),
243                Some(prefix),
244                "`{prefix}` is a section header and must fold",
245            );
246        }
247        assert_eq!(section_key("  modified   src/a.rs"), None);
248        assert_eq!(section_key("diff --git a/x b/x"), None);
249        assert_eq!(section_key(""), None);
250    }
251
252    /// Distinct sections must not collide, or collapsing one would
253    /// collapse another.
254    #[test]
255    fn distinct_sections_have_distinct_identities() {
256        use super::fold_identity;
257        let id = |k: &str| fold_identity("magit:section", k, 0);
258        assert_ne!(id("Staged changes"), id("Unstaged changes"));
259        assert_ne!(id("Recent commits"), id("Stashes"));
260        // ...and a section must not collide with an entry that happens
261        // to share its key, since the two nest.
262        assert_ne!(
263            fold_identity("magit:section", "Staged changes", 0),
264            fold_identity("magit:entry", "Staged changes", 0),
265        );
266    }
267}
268
269#[cfg(test)]
270mod fold_identity_tests {
271    use super::fold_identity;
272
273    /// MG.44: **a fold's closed state must survive `gr`.**
274    ///
275    /// `gr` re-runs every open entry's `git diff` and rebuilds the
276    /// buffer, so an entry moves whenever anything above it changes —
277    /// a file staged into another section, a diff that got shorter.
278    /// Identity is what carries closed-state across that recompute, so
279    /// keying it on the LINE meant a folded diff silently reopened on
280    /// the refresh that had something to report.
281    ///
282    /// This matters now in a way it did not before: `=` used to delete
283    /// the diff, so a collapsed entry left `expanded` and had nothing
284    /// to reopen.
285    #[test]
286    fn an_entrys_identity_does_not_move_with_its_line() {
287        let key = "Staged:src/a.rs";
288        // Same entry, different position after a refresh.
289        assert_eq!(
290            fold_identity("magit:entry", key, 0),
291            fold_identity("magit:entry", key, 0),
292        );
293    }
294
295    /// Different entries never collide, or folding one would fold
296    /// another.
297    #[test]
298    fn distinct_entries_have_distinct_identities() {
299        assert_ne!(
300            fold_identity("magit:entry", "Staged:src/a.rs", 0),
301            fold_identity("magit:entry", "Staged:src/b.rs", 0),
302        );
303        // The same path staged and unstaged are two rows in the
304        // buffer, and folding one must not fold the other.
305        assert_ne!(
306            fold_identity("magit:entry", "Staged:src/a.rs", 0),
307            fold_identity("magit:entry", "Unstaged:src/a.rs", 0),
308        );
309    }
310
311    /// MG.45: an entry fold must not collide with the file/hunk folds
312    /// that now come from `MagitHunkFoldSource`.
313    ///
314    /// They are nested and come from two DIFFERENT providers, so a
315    /// shared identity would make the outer one inherit an inner
316    /// one's closed state across a recompute.
317    #[test]
318    fn an_entry_fold_does_not_share_an_identity_with_the_diff_folds() {
319        let key = "Staged:src/a.rs";
320        let entry = fold_identity("magit:entry", key, 0);
321        assert_ne!(
322            entry,
323            crate::hunk_fold_source::identity_for_test("magit:diff-file", key, 0)
324        );
325        assert_ne!(
326            entry,
327            crate::hunk_fold_source::identity_for_test("magit:diff-hunk", key, 0)
328        );
329    }
330}