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}