Skip to main content

lattice_magit/
cursor_restore.rs

1//! MG.18d: putting the cursor back on the user's work after a rebuild.
2//!
3//! Every magit mutation ends in a refresh that replaces the whole
4//! buffer, so the row the cursor was on means nothing afterwards —
5//! files move between sections, counts change, the staged hunk is gone.
6//! At file granularity losing your place was tolerable. At hunk
7//! granularity it defeats the feature: staging four of a file's six
8//! hunks means finding your place four times.
9//!
10//! So the cursor is restored by **identity, not by row**: the entry (or
11//! file header) the work belonged to, plus the ordinal of the hunk
12//! within it. Staging hunk *k* removes it, so ordinal *k* now names the
13//! next remaining hunk — the restore rule and magit's behaviour fall
14//! out of the same arithmetic. Clamping to the last hunk covers staging
15//! the final one.
16//!
17//! Pure functions over the rebuilt text, resolved BEFORE it is applied:
18//! the refresh already holds the string it is about to write, so no
19//! second read of the buffer is needed and there is no window in which
20//! the text could change under the lookup.
21
22use std::path::{Path, PathBuf};
23use std::sync::Arc;
24
25use lattice_core::BufferId;
26use lattice_grammar::Effect;
27use lattice_mode::SubsystemBoot;
28use lattice_mode::inbound::InboundBus;
29use lattice_protocol::position::Position;
30
31/// A resolved cursor placement, on its way back to the editor thread.
32pub struct CursorRequest {
33    pub buffer: BufferId,
34    pub position: Position,
35}
36
37/// Service alias — register and look up through this exact type
38/// (`feedback_servicesregistry_arc_typeid`).
39pub type CursorBusHandle = Arc<InboundBus<CursorRequest>>;
40
41/// Install the bus a refresh sends its resolved cursor on.
42///
43/// **`inbound`, not `tick_callback`.** The wake is baked into
44/// `InboundBus::send`, so the editor is woken the moment the position
45/// is known and the drain runs off-keystroke. A bare tick callback has
46/// no wake of its own: the cursor would sit until the user pressed
47/// something else, which reads as "staging works but the cursor only
48/// catches up when I touch a key" — see `boot-composition.md` §3.
49///
50/// The handler is the whole mapping: one request, one
51/// [`Effect::CursorMoveIn`]. Targeted rather than a bare `CursorMove`
52/// because by the time this lands the user may have moved to another
53/// buffer, and the position means nothing there.
54pub(crate) fn install_cursor_bus(boot: &mut impl SubsystemBoot) {
55    let bus = boot.inbound::<CursorRequest, _>(|req| {
56        vec![Effect::CursorMoveIn {
57            target: req.buffer,
58            position: req.position,
59        }]
60    });
61    boot.register_service::<CursorBusHandle>(Arc::new(bus));
62}
63
64/// Hand a resolved position back to the editor thread.
65///
66/// `None` bus means a harness without the service — the refresh still
67/// works, it just does not move the cursor.
68pub(crate) fn send_cursor(bus: &Option<CursorBusHandle>, buffer: BufferId, position: Position) {
69    let Some(bus) = bus else { return };
70    // A failed send means the drain was dropped (the editor is going
71    // away); there is nothing useful to do about a cursor at that point.
72    let _ = bus.send(CursorRequest { buffer, position });
73}
74
75/// Where the work lived, in whichever buffer shape the view uses.
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub enum RestoreAnchor {
78    /// A magit-status entry row (`  modified    src/main.rs`) under the
79    /// section matching `staged`. Its diff, when expanded, follows it.
80    StatusEntry { path: PathBuf, staged: bool },
81    /// A `diff --git a/<path> b/<path>` line. magit-diff's buffers have
82    /// no entry rows — the file header is the only anchor there.
83    DiffHeader { path: PathBuf },
84}
85
86/// The work a mutation interrupted: which file, and which hunk of it.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct HunkRestore {
89    pub anchor: RestoreAnchor,
90    /// 0-based index of the hunk among that file's hunks, as the buffer
91    /// showed them *before* the mutation.
92    pub ordinal: usize,
93}
94
95/// The same fact in view-independent terms, as the shared staging path
96/// can state it: which file, which side of the index, which hunk.
97///
98/// The *anchor* is deliberately not decided here. Turning this into a
99/// row is a question about buffer shape — an entry row in magit-status,
100/// a `diff --git` header in a diff buffer — and only the view knows
101/// which it has. So the staging path names the work and each view names
102/// the landmark.
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct HunkSite {
105    pub path: PathBuf,
106    pub staged: bool,
107    pub ordinal: usize,
108}
109
110impl HunkSite {
111    /// Read as a magit-status entry row.
112    pub fn as_status_entry(self) -> HunkRestore {
113        HunkRestore {
114            anchor: RestoreAnchor::StatusEntry {
115                path: self.path,
116                staged: self.staged,
117            },
118            ordinal: self.ordinal,
119        }
120    }
121
122    /// Read as a `diff --git` header in a raw diff buffer, where the
123    /// staged/unstaged split is a property of the whole buffer rather
124    /// than of a section within it.
125    pub fn as_diff_header(self) -> HunkRestore {
126        HunkRestore {
127            anchor: RestoreAnchor::DiffHeader { path: self.path },
128            ordinal: self.ordinal,
129        }
130    }
131}
132
133/// Resolve `restore` against the rebuilt buffer `text`.
134///
135/// `None` when the anchor is gone — the file was fully staged and left
136/// its section, or the buffer no longer shows it. The caller then sends
137/// no cursor at all, which leaves the user wherever the refresh put
138/// them rather than guessing at a row.
139pub(crate) fn restore_position(text: &str, restore: &HunkRestore) -> Option<Position> {
140    let lines: Vec<&str> = text.lines().collect();
141    let anchor_row = anchor_row(&lines, &restore.anchor)?;
142
143    // The file's own `diff --git` header — present only while its diff
144    // is expanded. Without one there are no hunks to land on and the
145    // entry row itself is the honest answer.
146    let Some(header_row) = file_header_row(&lines, &restore.anchor, anchor_row) else {
147        return Some(Position::new(anchor_row as u32, 0));
148    };
149
150    let hunks = hunk_rows_of_file(&lines, header_row);
151    if hunks.is_empty() {
152        return Some(Position::new(anchor_row as u32, 0));
153    }
154    // Ordinal `k` now names the hunk that took the staged one's place.
155    // Clamp: staging the last hunk lands on the new last.
156    let row = hunks[restore.ordinal.min(hunks.len() - 1)];
157    Some(Position::new(row as u32, 0))
158}
159
160/// The row the anchor names, or `None` if the buffer no longer has it.
161fn anchor_row(lines: &[&str], anchor: &RestoreAnchor) -> Option<usize> {
162    match anchor {
163        RestoreAnchor::DiffHeader { path } => {
164            lines.iter().position(|l| is_file_header_for(l, path))
165        }
166        RestoreAnchor::StatusEntry { path, staged } => {
167            let want_staged = *staged;
168            let mut in_staged_section = false;
169            for (row, line) in lines.iter().enumerate() {
170                if crate::sections::is_section_header(line.trim()) {
171                    in_staged_section = line.starts_with("Staged");
172                    continue;
173                }
174                if in_staged_section != want_staged {
175                    continue;
176                }
177                if entry_path(line).is_some_and(|p| p == path.as_path()) {
178                    return Some(row);
179                }
180            }
181            None
182        }
183    }
184}
185
186/// The `diff --git` row belonging to the anchor.
187///
188/// For a status entry that is the first one below it, and only while it
189/// really belongs to this entry — a collapsed entry is followed by the
190/// *next* entry, whose own expansion must not be adopted. For a
191/// magit-diff anchor the anchor row already IS the header.
192fn file_header_row(lines: &[&str], anchor: &RestoreAnchor, anchor_row: usize) -> Option<usize> {
193    match anchor {
194        RestoreAnchor::DiffHeader { .. } => Some(anchor_row),
195        RestoreAnchor::StatusEntry { .. } => {
196            let next = lines.get(anchor_row + 1)?;
197            next.starts_with("diff --git").then_some(anchor_row + 1)
198        }
199    }
200}
201
202/// Rows of the `@@` headers between `header_row` and the next file's
203/// `diff --git` (or a section header, which ends an inline expansion in
204/// magit-status).
205fn hunk_rows_of_file(lines: &[&str], header_row: usize) -> Vec<usize> {
206    let mut rows = Vec::new();
207    for (offset, line) in lines.iter().enumerate().skip(header_row + 1) {
208        if line.starts_with("diff --git") || crate::sections::is_section_header(line.trim()) {
209            break;
210        }
211        if line.starts_with("@@") {
212            rows.push(offset);
213        }
214    }
215    rows
216}
217
218fn is_file_header_for(line: &str, path: &Path) -> bool {
219    let Some(rest) = line.strip_prefix("diff --git a/") else {
220        return false;
221    };
222    rest.split(" b/")
223        .next()
224        .is_some_and(|p| Path::new(p) == path)
225}
226
227/// The path on a magit-status entry row, or `None` for anything else.
228///
229/// Reuses the crate's one entry classifier rather than re-deriving the
230/// row layout — the section is already known by the caller, so the
231/// staged flag it would compute is discarded here.
232fn entry_path(line: &str) -> Option<PathBuf> {
233    match crate::actions::classify_line_text(line, || None)? {
234        crate::actions::StatusLine::File { path, .. } => Some(path),
235        _ => None,
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    /// A status buffer with one expanded file holding three hunks.
244    const STATUS: &str = "\
245Unstaged changes (2)
246  modified     src/main.rs
247diff --git a/src/main.rs b/src/main.rs
248--- a/src/main.rs
249+++ b/src/main.rs
250@@ -1,2 +1,2 @@
251 keep
252-a
253@@ -10,2 +10,2 @@
254 keep
255-b
256@@ -20,2 +20,2 @@
257 keep
258-c
259  modified     src/other.rs
260
261Recent commits (1)
262  abc1234 something
263";
264
265    fn entry(path: &str, staged: bool) -> RestoreAnchor {
266        RestoreAnchor::StatusEntry {
267            path: PathBuf::from(path),
268            staged,
269        }
270    }
271
272    /// The core rule: staging hunk `k` removes it, so ordinal `k` now
273    /// names the next one — which is where magit leaves you.
274    #[test]
275    fn the_same_ordinal_lands_on_the_hunk_that_took_the_staged_ones_place() {
276        let r = HunkRestore {
277            anchor: entry("src/main.rs", false),
278            ordinal: 1,
279        };
280        assert_eq!(
281            restore_position(STATUS, &r),
282            Some(Position::new(8, 0)),
283            "ordinal 1 is the second `@@`"
284        );
285    }
286
287    /// Staging the LAST hunk has no successor; magit lands on the new
288    /// last rather than falling off the entry.
289    #[test]
290    fn an_ordinal_past_the_end_clamps_to_the_last_hunk() {
291        let r = HunkRestore {
292            anchor: entry("src/main.rs", false),
293            ordinal: 9,
294        };
295        assert_eq!(restore_position(STATUS, &r), Some(Position::new(11, 0)));
296    }
297
298    /// The entry is still listed but its diff is collapsed (nothing was
299    /// re-expanded, e.g. after `gr`): the entry row is the honest
300    /// answer, not a hunk row from someone else's diff.
301    #[test]
302    fn a_collapsed_entry_restores_to_its_own_row() {
303        let text = "\
304Unstaged changes (2)
305  modified     src/main.rs
306  modified     src/other.rs
307";
308        let r = HunkRestore {
309            anchor: entry("src/main.rs", false),
310            ordinal: 2,
311        };
312        assert_eq!(restore_position(text, &r), Some(Position::new(1, 0)));
313    }
314
315    /// The entry below a collapsed one carries its own expansion. The
316    /// collapsed entry must not adopt it — that would jump the cursor
317    /// into a different file's diff.
318    #[test]
319    fn a_collapsed_entry_does_not_adopt_the_next_entrys_diff() {
320        let text = "\
321Unstaged changes (2)
322  modified     src/main.rs
323  modified     src/other.rs
324diff --git a/src/other.rs b/src/other.rs
325@@ -1,2 +1,2 @@
326 keep
327-x
328";
329        let r = HunkRestore {
330            anchor: entry("src/main.rs", false),
331            ordinal: 0,
332        };
333        assert_eq!(
334            restore_position(text, &r),
335            Some(Position::new(1, 0)),
336            "src/main.rs is collapsed — its own row, not src/other.rs's hunk"
337        );
338    }
339
340    /// Staging a file's last remaining hunk moves it out of Unstaged
341    /// entirely. There is nothing to restore to; leaving the cursor
342    /// where the refresh put it beats guessing at a row.
343    #[test]
344    fn a_vanished_entry_yields_no_position() {
345        let text = "\
346Staged changes (1)
347  modified     src/main.rs
348";
349        let r = HunkRestore {
350            anchor: entry("src/main.rs", false),
351            ordinal: 0,
352        };
353        assert_eq!(
354            restore_position(text, &r),
355            None,
356            "the entry moved to Staged — the unstaged anchor is gone"
357        );
358    }
359
360    /// The same path appears in BOTH sections when a file has staged
361    /// and unstaged changes. The anchor's side decides which row.
362    #[test]
363    fn the_staged_flag_picks_between_two_rows_for_one_path() {
364        let text = "\
365Staged changes (1)
366  modified     src/main.rs
367
368Unstaged changes (1)
369  modified     src/main.rs
370";
371        assert_eq!(
372            restore_position(
373                text,
374                &HunkRestore {
375                    anchor: entry("src/main.rs", true),
376                    ordinal: 0
377                }
378            ),
379            Some(Position::new(1, 0))
380        );
381        assert_eq!(
382            restore_position(
383                text,
384                &HunkRestore {
385                    anchor: entry("src/main.rs", false),
386                    ordinal: 0
387                }
388            ),
389            Some(Position::new(4, 0))
390        );
391    }
392
393    /// magit-diff's buffers are raw diffs: no entries, no sections, and
394    /// several files in one buffer.
395    #[test]
396    fn a_diff_buffer_anchors_on_the_file_header() {
397        let text = "\
398diff --git a/a.rs b/a.rs
399--- a/a.rs
400+++ b/a.rs
401@@ -1,2 +1,2 @@
402 keep
403-a
404diff --git a/b.rs b/b.rs
405--- a/b.rs
406+++ b/b.rs
407@@ -1,2 +1,2 @@
408 keep
409-b
410@@ -9,2 +9,2 @@
411 keep
412-c
413";
414        let r = HunkRestore {
415            anchor: RestoreAnchor::DiffHeader {
416                path: PathBuf::from("b.rs"),
417            },
418            ordinal: 1,
419        };
420        assert_eq!(
421            restore_position(text, &r),
422            Some(Position::new(12, 0)),
423            "b.rs's second hunk, not a.rs's"
424        );
425    }
426
427    /// A file's hunk list must stop at the next file's header, or
428    /// ordinal-clamping would walk into the neighbour's hunks.
429    #[test]
430    fn a_files_hunks_do_not_run_into_the_next_files() {
431        let text = "\
432diff --git a/a.rs b/a.rs
433@@ -1,2 +1,2 @@
434 keep
435-a
436diff --git a/b.rs b/b.rs
437@@ -1,2 +1,2 @@
438 keep
439-b
440";
441        let r = HunkRestore {
442            anchor: RestoreAnchor::DiffHeader {
443                path: PathBuf::from("a.rs"),
444            },
445            ordinal: 5,
446        };
447        assert_eq!(
448            restore_position(text, &r),
449            Some(Position::new(1, 0)),
450            "clamped to a.rs's only hunk"
451        );
452    }
453}