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}