Skip to main content

lattice_host/
pane_history.rs

1//! PBH.1: per-pane buffer history — the trail of buffers one pane has
2//! shown, walkable with `<C-6>` (back) / `<C-7>` (forward).
3//!
4//! Design: `docs/dev/architecture/pane-buffer-history.md`.
5//! Sequencing: `docs/dev/operations/slice-plans/pane-buffer-history.md`.
6//!
7//! This module holds the structure and its **pure** operations, with no
8//! `Editor` in sight, so the walk semantics are unit-testable without
9//! standing up an editor. The host owns the
10//! `HashMap<PaneId, PaneBufferHistory>` side table and the recording
11//! chokepoint (PBH.2/PBH.3).
12//!
13//! ## Why a side table rather than a field on `PaneState`
14//!
15//! `PaneState` is `Copy`, and `PaneTree::split_active` builds the new
16//! leaf with `PaneState { id: PaneId::next(), ..new_state }` — a
17//! field-wise copy. A `history` field there would be **inherited by the
18//! split**, which is exactly the behaviour this feature must not have;
19//! avoiding it would mean remembering to reset that one field, and the
20//! next field added the same way would inherit the bug silently.
21//!
22//! Keyed by `PaneId` in a side table the requirement holds by
23//! construction: `PaneId::next()` is process-monotonic and never reuses
24//! ids, so a freshly split pane has no entry and therefore no history.
25
26use lattice_core::BufferId;
27use lattice_protocol::Position;
28
29/// Default bound on one pane's trail.
30///
31/// PBH.4 replaces this constant with the typed, customizable
32/// `pane.buffer-history-size` option; until then it is the single
33/// place the cap is spelled, so the swap is one edit rather than a
34/// hunt through call sites.
35pub const DEFAULT_PANE_BUFFER_HISTORY_SIZE: usize = 100;
36
37/// One stop on a pane's trail: a buffer plus where the cursor was in it
38/// **in this pane**.
39///
40/// Cursor and scroll are stored per entry rather than looked up from the
41/// buffer. The same buffer can appear at several points in a trail at
42/// different locations, and "take me back where I was" is the whole
43/// point of a back key — a buffer-global last-position lookup would land
44/// every occurrence in the same place.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub struct PaneHistoryEntry {
47    pub buffer: BufferId,
48    pub cursor: Position,
49    pub scroll: u32,
50}
51
52impl PaneHistoryEntry {
53    pub fn new(buffer: BufferId, cursor: Position, scroll: u32) -> Self {
54        Self {
55            buffer,
56            cursor,
57            scroll,
58        }
59    }
60
61    /// An entry at the top of a buffer — the shape used when seeding a
62    /// pane's history from its current buffer.
63    pub fn at_origin(buffer: BufferId) -> Self {
64        Self::new(buffer, Position::ZERO, 0)
65    }
66}
67
68/// One pane's buffer trail plus the walk cursor.
69///
70/// `entries` is oldest → newest; `cursor` indexes the entry currently
71/// displayed. Browser / jump-list semantics: visiting a new buffer while
72/// walked back **truncates the forward tail**.
73#[derive(Debug, Clone, Default, PartialEq, Eq)]
74pub struct PaneBufferHistory {
75    entries: Vec<PaneHistoryEntry>,
76    cursor: usize,
77}
78
79impl PaneBufferHistory {
80    /// Seed a pane's history with the buffer it is already showing, so
81    /// the first `<C-6>` has an origin to go back *from*.
82    pub fn seeded(entry: PaneHistoryEntry) -> Self {
83        Self {
84            entries: vec![entry],
85            cursor: 0,
86        }
87    }
88
89    pub fn entries(&self) -> &[PaneHistoryEntry] {
90        &self.entries
91    }
92
93    pub fn cursor(&self) -> usize {
94        self.cursor
95    }
96
97    pub fn is_empty(&self) -> bool {
98        self.entries.is_empty()
99    }
100
101    pub fn len(&self) -> usize {
102        self.entries.len()
103    }
104
105    /// The entry currently displayed, if any.
106    pub fn current(&self) -> Option<&PaneHistoryEntry> {
107        self.entries.get(self.cursor)
108    }
109
110    /// Update the current entry's cursor/scroll — the pane's *outgoing*
111    /// position, captured just before leaving for another buffer so
112    /// walking back returns to where the user actually was.
113    pub fn update_current_position(&mut self, cursor: Position, scroll: u32) {
114        if let Some(entry) = self.entries.get_mut(self.cursor) {
115            entry.cursor = cursor;
116            entry.scroll = scroll;
117        }
118    }
119
120    /// Point the current entry at a different buffer id without moving
121    /// the trail.
122    ///
123    /// For `:e!` — a reload replaces the buffer *actor* (new
124    /// `BufferId`) while the pane keeps showing the same file. Pushing
125    /// would put the same path in the trail twice for what the user
126    /// experiences as a refresh; leaving it alone would strand the
127    /// entry on a dead id.
128    pub fn repoint_current(&mut self, buffer: BufferId) {
129        if let Some(entry) = self.entries.get_mut(self.cursor) {
130            entry.buffer = buffer;
131        }
132    }
133
134    /// Record a visit to `entry`, truncating any forward tail.
135    ///
136    /// `cap` bounds the ring (`pane.buffer-history-size`); the oldest
137    /// entries are evicted and `cursor` shifts down with them so the
138    /// current position does not drift onto a different buffer.
139    ///
140    /// A visit to the buffer already current is ignored — the recording
141    /// chokepoint guards on this too, but keeping the structure
142    /// idempotent means a second caller can't corrupt the trail.
143    pub fn push(&mut self, entry: PaneHistoryEntry, cap: usize) {
144        if self.current().map(|c| c.buffer) == Some(entry.buffer) {
145            return;
146        }
147        // Drop the forward tail: anything after the walk cursor is a
148        // future that this visit replaces.
149        if !self.entries.is_empty() {
150            self.entries.truncate(self.cursor + 1);
151        }
152        self.entries.push(entry);
153        self.cursor = self.entries.len() - 1;
154        self.evict(cap);
155    }
156
157    /// Enforce `cap`, dropping oldest-first.
158    fn evict(&mut self, cap: usize) {
159        // A zero cap would make the structure meaningless (and would
160        // underflow the cursor shift below); treat it as "at least one".
161        let cap = cap.max(1);
162        if self.entries.len() <= cap {
163            return;
164        }
165        let overflow = self.entries.len() - cap;
166        self.entries.drain(..overflow);
167        self.cursor = self.cursor.saturating_sub(overflow);
168    }
169
170    /// Step back one entry, returning the entry now current.
171    ///
172    /// `None` at the oldest entry — the caller echoes rather than
173    /// wrapping. Wrapping would turn a directional key into a cycle.
174    ///
175    /// **A walk never records.** Moving the cursor is the whole
176    /// operation; pushing here would make the forward direction
177    /// unreachable.
178    pub fn back(&mut self) -> Option<PaneHistoryEntry> {
179        if self.cursor == 0 {
180            return None;
181        }
182        self.cursor -= 1;
183        self.entries.get(self.cursor).copied()
184    }
185
186    /// Step forward one entry, returning the entry now current. `None`
187    /// at the newest entry.
188    pub fn forward(&mut self) -> Option<PaneHistoryEntry> {
189        if self.cursor + 1 >= self.entries.len() {
190            return None;
191        }
192        self.cursor += 1;
193        self.entries.get(self.cursor).copied()
194    }
195
196    /// Move the walk cursor to an explicit index — the picker's
197    /// random-access walk. Accepting a picker row is *not* a new visit,
198    /// so this moves rather than pushes. Out-of-range is a no-op.
199    pub fn jump_to(&mut self, index: usize) -> Option<PaneHistoryEntry> {
200        if index >= self.entries.len() {
201            return None;
202        }
203        self.cursor = index;
204        self.entries.get(index).copied()
205    }
206
207    /// Drop every entry whose buffer `still_live` rejects, keeping the
208    /// walk cursor on the nearest surviving entry.
209    ///
210    /// Buffers deleted with `:bd` are pruned lazily as the walk passes
211    /// them rather than eagerly on delete — that keeps `:bd` from having
212    /// to know about a structure it should not know about.
213    pub fn retain_live<F>(&mut self, still_live: F)
214    where
215        F: Fn(BufferId) -> bool,
216    {
217        if self.entries.is_empty() {
218            return;
219        }
220        // How many entries at-or-before the cursor survive? That is
221        // where the cursor lands, so it keeps pointing at the same
222        // logical position in the surviving trail.
223        let surviving_before = self.entries[..=self.cursor.min(self.entries.len() - 1)]
224            .iter()
225            .filter(|e| still_live(e.buffer))
226            .count();
227        self.entries.retain(|e| still_live(e.buffer));
228        self.cursor = surviving_before
229            .saturating_sub(1)
230            .min(self.entries.len().saturating_sub(1));
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    fn buf(n: u32) -> BufferId {
239        BufferId(n)
240    }
241
242    fn entry(n: u32) -> PaneHistoryEntry {
243        PaneHistoryEntry::at_origin(buf(n))
244    }
245
246    const CAP: usize = 100;
247
248    fn trail(ids: &[u32]) -> PaneBufferHistory {
249        let mut h = PaneBufferHistory::default();
250        for &n in ids {
251            h.push(entry(n), CAP);
252        }
253        h
254    }
255
256    fn buffers(h: &PaneBufferHistory) -> Vec<u32> {
257        h.entries().iter().map(|e| e.buffer.0).collect()
258    }
259
260    #[test]
261    fn a_fresh_history_is_empty() {
262        let h = PaneBufferHistory::default();
263        assert!(h.is_empty());
264        assert_eq!(h.current(), None);
265    }
266
267    #[test]
268    fn seeding_gives_one_entry_at_the_cursor() {
269        let h = PaneBufferHistory::seeded(entry(1));
270        assert_eq!(buffers(&h), vec![1]);
271        assert_eq!(h.cursor(), 0);
272        assert_eq!(h.current().map(|e| e.buffer), Some(buf(1)));
273    }
274
275    #[test]
276    fn push_appends_and_advances_the_cursor() {
277        let h = trail(&[1, 2, 3]);
278        assert_eq!(buffers(&h), vec![1, 2, 3]);
279        assert_eq!(h.cursor(), 2);
280    }
281
282    #[test]
283    fn pushing_the_current_buffer_again_is_ignored() {
284        // The recording chokepoint guards on this too; the structure
285        // stays idempotent so a second caller cannot corrupt the trail.
286        let mut h = trail(&[1, 2]);
287        h.push(entry(2), CAP);
288        assert_eq!(buffers(&h), vec![1, 2]);
289        assert_eq!(h.cursor(), 1);
290    }
291
292    #[test]
293    fn non_adjacent_repeats_are_kept() {
294        // A → B → A is a real trail with three stops, not two.
295        let h = trail(&[1, 2, 1]);
296        assert_eq!(buffers(&h), vec![1, 2, 1]);
297    }
298
299    #[test]
300    fn back_and_forward_round_trip() {
301        let mut h = trail(&[1, 2, 3]);
302        assert_eq!(h.back().map(|e| e.buffer), Some(buf(2)));
303        assert_eq!(h.back().map(|e| e.buffer), Some(buf(1)));
304        assert_eq!(h.forward().map(|e| e.buffer), Some(buf(2)));
305        assert_eq!(h.forward().map(|e| e.buffer), Some(buf(3)));
306    }
307
308    #[test]
309    fn back_at_the_oldest_entry_returns_none_and_does_not_wrap() {
310        let mut h = trail(&[1, 2]);
311        h.back();
312        assert_eq!(h.cursor(), 0);
313        assert_eq!(h.back(), None, "must not wrap to the newest entry");
314        assert_eq!(h.cursor(), 0, "a refused back must not move the cursor");
315    }
316
317    #[test]
318    fn forward_at_the_newest_entry_returns_none_and_does_not_wrap() {
319        let mut h = trail(&[1, 2]);
320        assert_eq!(h.forward(), None);
321        assert_eq!(h.cursor(), 1);
322    }
323
324    #[test]
325    fn walking_does_not_record() {
326        // The invariant that makes forward reachable at all: if `back`
327        // pushed, the tail it walked into would be truncated by its own
328        // move and `forward` could never return anything.
329        let mut h = trail(&[1, 2, 3]);
330        let before = buffers(&h);
331        h.back();
332        h.back();
333        assert_eq!(buffers(&h), before, "a walk must not alter the entries");
334    }
335
336    #[test]
337    fn visiting_while_walked_back_truncates_the_forward_tail() {
338        // Browser semantics: A→B→C, back to B, open D ⇒ C is gone.
339        let mut h = trail(&[1, 2, 3]);
340        h.back();
341        h.push(entry(4), CAP);
342        assert_eq!(buffers(&h), vec![1, 2, 4]);
343        assert_eq!(h.cursor(), 2);
344        assert_eq!(h.forward(), None, "the truncated tail must not survive");
345    }
346
347    #[test]
348    fn update_current_position_records_the_outgoing_cursor() {
349        let mut h = trail(&[1, 2]);
350        h.back();
351        h.update_current_position(Position::new(12, 3), 7);
352        h.forward();
353        let back = h.back().expect("entry 1 exists");
354        assert_eq!(back.cursor, Position::new(12, 3));
355        assert_eq!(back.scroll, 7);
356    }
357
358    #[test]
359    fn eviction_drops_oldest_and_keeps_the_cursor_on_the_same_entry() {
360        let mut h = PaneBufferHistory::default();
361        for n in 1..=5 {
362            h.push(entry(n), 3);
363        }
364        assert_eq!(buffers(&h), vec![3, 4, 5], "cap of 3 keeps the newest 3");
365        assert_eq!(
366            h.current().map(|e| e.buffer),
367            Some(buf(5)),
368            "the cursor must still point at the entry it pointed at before eviction",
369        );
370    }
371
372    #[test]
373    fn a_zero_cap_is_treated_as_one_rather_than_underflowing() {
374        // `:set pane.buffer-history-size=0` must not panic or produce an
375        // empty-but-cursored structure.
376        let mut h = PaneBufferHistory::default();
377        h.push(entry(1), 0);
378        h.push(entry(2), 0);
379        assert_eq!(buffers(&h), vec![2]);
380        assert_eq!(h.cursor(), 0);
381        assert_eq!(h.current().map(|e| e.buffer), Some(buf(2)));
382    }
383
384    #[test]
385    fn jump_to_moves_without_recording() {
386        // The picker's random-access walk: accepting a row is not a new
387        // visit, so the trail is unchanged and the forward tail survives.
388        let mut h = trail(&[1, 2, 3]);
389        let before = buffers(&h);
390        assert_eq!(h.jump_to(0).map(|e| e.buffer), Some(buf(1)));
391        assert_eq!(buffers(&h), before);
392        assert_eq!(h.cursor(), 0);
393        assert_eq!(h.forward().map(|e| e.buffer), Some(buf(2)));
394    }
395
396    #[test]
397    fn jump_to_out_of_range_is_a_no_op() {
398        let mut h = trail(&[1, 2]);
399        assert_eq!(h.jump_to(9), None);
400        assert_eq!(h.cursor(), 1);
401    }
402
403    #[test]
404    fn retain_live_drops_deleted_buffers() {
405        let mut h = trail(&[1, 2, 3]);
406        h.retain_live(|b| b != buf(2));
407        assert_eq!(buffers(&h), vec![1, 3]);
408    }
409
410    #[test]
411    fn retain_live_keeps_the_cursor_on_the_nearest_survivor() {
412        let mut h = trail(&[1, 2, 3, 4]);
413        h.back(); // cursor on 3
414        h.retain_live(|b| b != buf(2));
415        // Entries [1,3,4]; the cursor was on 3, which survived.
416        assert_eq!(buffers(&h), vec![1, 3, 4]);
417        assert_eq!(h.current().map(|e| e.buffer), Some(buf(3)));
418    }
419
420    #[test]
421    fn retain_live_can_empty_the_history_without_panicking() {
422        let mut h = trail(&[1, 2]);
423        h.retain_live(|_| false);
424        assert!(h.is_empty());
425        assert_eq!(h.current(), None);
426        assert_eq!(h.back(), None);
427        assert_eq!(h.forward(), None);
428    }
429}