Skip to main content

diffler/app/
log.rs

1//! Log screen state and handlers: commits with their branch graph, `<cr>`
2//! opens the commit's diff in the diff view.
3
4use diffler_core::loggraph::{self, Cell};
5use diffler_core::vcs::{LogEntry, VcsError};
6
7use super::App;
8use super::rowsel::{RowSelect, RowText};
9use crate::keymap::Action;
10
11/// How much history one log screen loads (neogit's default max-count).
12pub(super) const LOG_LIMIT: usize = 256;
13
14struct LogHistory {
15    entries: Vec<LogEntry>,
16    graph: Vec<Vec<Cell>>,
17    head: Option<String>,
18    remotes: Vec<String>,
19}
20
21pub struct LogView {
22    pub entries: Vec<LogEntry>,
23    /// The graph cells beside each entry.
24    pub graph: Vec<Vec<Cell>>,
25    /// HEAD's full oid, the commit the graph marks as checked out.
26    pub head: Option<String>,
27    /// Whether the log walks every branch or only HEAD's history.
28    pub all_branches: bool,
29    /// Remote names, so a ref like `origin/main` reads as a remote branch.
30    pub remotes: Vec<String>,
31    pub cursor: usize,
32    /// First visible entry; the renderer keeps the cursor in view.
33    pub scroll: usize,
34    /// Row where `V` started; `Some` means range selection is active.
35    pub visual_anchor: Option<usize>,
36    /// Body height of the last render, drives half-page motions.
37    pub(crate) viewport: u16,
38    /// Entry-area rect of the last render, for mapping mouse clicks to rows.
39    pub(crate) body: ratatui::layout::Rect,
40}
41
42impl RowSelect for LogView {
43    fn cursor(&self) -> usize {
44        self.cursor
45    }
46
47    fn anchor(&self) -> Option<usize> {
48        self.visual_anchor
49    }
50
51    fn set_anchor(&mut self, anchor: Option<usize>) {
52        self.visual_anchor = anchor;
53    }
54}
55
56impl RowText for LogView {
57    fn row_count(&self) -> usize {
58        self.entries.len()
59    }
60
61    fn row_text(&self, row: usize) -> String {
62        self.entries
63            .get(row)
64            .map(|entry| format!("{} {}", entry.short_id(), entry.subject))
65            .unwrap_or_default()
66    }
67}
68
69impl App {
70    fn load_log(&self, all_branches: bool) -> Result<LogHistory, VcsError> {
71        let entries = self.review.vcs.log(LOG_LIMIT, all_branches)?;
72        let head = self.review.vcs.resolve("HEAD").ok();
73        Ok(LogHistory {
74            graph: loggraph::layout(&entries, head.as_deref()),
75            entries,
76            head,
77            remotes: self.review.vcs.remotes().unwrap_or_default(),
78        })
79    }
80
81    pub(crate) fn open_log(&mut self, all_branches: bool) {
82        match self.load_log(all_branches) {
83            Ok(history) => {
84                self.log = Some(LogView {
85                    graph: history.graph,
86                    entries: history.entries,
87                    head: history.head,
88                    all_branches,
89                    remotes: history.remotes,
90                    cursor: 0,
91                    scroll: 0,
92                    visual_anchor: None,
93                    viewport: 0,
94                    body: ratatui::layout::Rect::default(),
95                });
96                self.push_screen(super::Screen::Log);
97            }
98            Err(err) => self.error(err.to_string()),
99        }
100    }
101
102    /// Reload entries after a repo change, keeping the cursor in bounds.
103    pub(super) fn refresh_log(&mut self) {
104        let Some(all_branches) = self.log.as_ref().map(|log| log.all_branches) else {
105            return;
106        };
107        let loaded = self.load_log(all_branches);
108        let Some(log) = self.log.as_mut() else {
109            return;
110        };
111        match loaded {
112            Ok(history) => {
113                log.graph = history.graph;
114                log.entries = history.entries;
115                log.head = history.head;
116                log.remotes = history.remotes;
117                log.cursor = log.cursor.min(log.entries.len().saturating_sub(1));
118                // anchors are row indices; new history would dangle them
119                log.visual_anchor = None;
120            }
121            Err(err) => {
122                let text = err.to_string();
123                self.error(text);
124            }
125        }
126    }
127
128    pub(super) fn dispatch_log(&mut self, action: Action) {
129        if action == Action::Open {
130            self.open_log_selection();
131            return;
132        }
133        let half = self.log_page(false);
134        let full = self.log_page(true);
135        let Some(log) = self.log.as_mut() else {
136            return;
137        };
138        let last = log.entries.len().saturating_sub(1);
139        match action {
140            Action::MoveDown => log.cursor = (log.cursor + 1).min(last),
141            Action::MoveUp => log.cursor = log.cursor.saturating_sub(1),
142            Action::GoTop => log.cursor = 0,
143            Action::GoBottom => log.cursor = last,
144            Action::HalfPageDown => log.cursor = (log.cursor + half).min(last),
145            Action::HalfPageUp => log.cursor = log.cursor.saturating_sub(half),
146            Action::FullPageDown => log.cursor = (log.cursor + full).min(last),
147            Action::FullPageUp => log.cursor = log.cursor.saturating_sub(full),
148            Action::NextSection | Action::PrevSection => {
149                let forward = action == Action::NextSection;
150                if let Some(index) =
151                    super::step_to(&log.entries, log.cursor, forward, |e| !e.refs.is_empty())
152                {
153                    log.cursor = index;
154                }
155            }
156            // V toggles a range selection anchored at the cursor commit
157            Action::VisualSelect => log.toggle_visual(),
158            Action::CopyFileFeedback | Action::CopyAllFeedback => {
159                self.yank_rows("yanked commits");
160            }
161            other => {
162                self.info(format!("{} does nothing on this screen", other.name()));
163            }
164        }
165    }
166
167    /// `<cr>` in the log: with a selection, open the combined diff of the
168    /// selected commit range (rows are newest-first, so the span's high index
169    /// is the oldest commit); otherwise open the single commit under the
170    /// cursor.
171    fn open_log_selection(&mut self) {
172        let Some(log) = self.log.as_ref() else {
173            return;
174        };
175        if let Some((top, bottom)) = log.selection() {
176            let (Some(newest), Some(oldest)) = (log.entries.get(top), log.entries.get(bottom))
177            else {
178                return;
179            };
180            let (oldest, newest) = (oldest.oid.clone(), newest.oid.clone());
181            self.open_range_diff(&oldest, &newest);
182        } else {
183            let Some(oid) = log.entries.get(log.cursor).map(|entry| entry.oid.clone()) else {
184                return;
185            };
186            self.open_commit_diff(&oid);
187        }
188    }
189
190    pub(super) fn log_mouse(&mut self, gesture: super::MouseGesture) {
191        use super::MouseGesture;
192        match gesture {
193            MouseGesture::Scroll { down, .. } => {
194                let delta = if down { 3 } else { -3 };
195                if let Some(log) = self.log.as_mut() {
196                    let last = log.entries.len().saturating_sub(1);
197                    log.cursor = log.cursor.saturating_add_signed(delta).min(last);
198                }
199            }
200            MouseGesture::Press { col, row } | MouseGesture::Select { col, row } => {
201                if let Some(index) = self.log_row_at(col, row)
202                    && let Some(log) = self.log.as_mut()
203                {
204                    log.cursor = index;
205                    log.visual_anchor = None;
206                }
207            }
208            // double-click opens the commit's diff, like `<cr>`
209            MouseGesture::DoublePress { col, row } => {
210                if let Some(index) = self.log_row_at(col, row) {
211                    if let Some(log) = self.log.as_mut() {
212                        log.cursor = index;
213                    }
214                    self.open_log_selection();
215                }
216            }
217            // drag grows the range selection from the press row
218            MouseGesture::Drag { col, row } => {
219                if let Some(index) = self.log_row_at(col, row)
220                    && let Some(log) = self.log.as_mut()
221                {
222                    if log.visual_anchor.is_none() {
223                        log.visual_anchor = Some(log.cursor);
224                    }
225                    log.cursor = index;
226                }
227            }
228        }
229    }
230
231    fn log_row_at(&self, col: u16, row: u16) -> Option<usize> {
232        let log = self.log.as_ref()?;
233        let index = super::hit_index(log.body, log.scroll, col, row)?;
234        (index < log.entries.len()).then_some(index)
235    }
236
237    fn log_page(&self, full: bool) -> usize {
238        super::page_step(self.log.as_ref().map_or(0, |l| l.viewport), full)
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use crate::app::rowsel::RowSelect;
245    use diffler_core::source::ReviewSource;
246
247    use super::super::Screen;
248    use crate::app::App;
249    use crate::config::LoadedConfig;
250    use crate::test_support::{Fixture, ctrl_key, key, standard_fixture};
251
252    /// Three commits and a second branch ref on HEAD, so log rows carry
253    /// subjects and decorations.
254    fn log_fixture() -> Fixture {
255        let fixture = standard_fixture();
256        fixture.write("notes.txt", "alpha\nbeta\n");
257        fixture.commit_all("add beta note");
258        fixture.write(
259            "src/util.rs",
260            "pub fn twice(x: u32) -> u32 {\n    x * 2\n}\n",
261        );
262        fixture.commit_all("add util module");
263        fixture.branch("feat/topic");
264        fixture
265    }
266
267    fn open_log(fixture: &Fixture) -> App {
268        let mut app = App::new(fixture.review(), LoadedConfig::default());
269        app.handle(key('l'));
270        app.handle(key('l'));
271        app
272    }
273
274    #[test]
275    fn ll_opens_the_log_with_entries_newest_first() {
276        let fixture = log_fixture();
277        let app = open_log(&fixture);
278        assert_eq!(app.screen(), Screen::Log);
279        let log = app.log.as_ref().expect("log view");
280        assert_eq!(log.entries.len(), 3);
281        assert_eq!(log.entries[0].subject, "add util module");
282        assert!(
283            log.entries[0].refs.iter().any(|r| r == "feat/topic"),
284            "HEAD decorations include the branch ref: {:?}",
285            log.entries[0].refs
286        );
287        assert_eq!(log.cursor, 0);
288    }
289
290    #[test]
291    fn cursor_moves_and_clamps_in_the_log() {
292        let fixture = log_fixture();
293        let mut app = open_log(&fixture);
294        app.handle(key('k'));
295        assert_eq!(app.log.as_ref().unwrap().cursor, 0);
296        for _ in 0..10 {
297            app.handle(key('j'));
298        }
299        assert_eq!(app.log.as_ref().unwrap().cursor, 2);
300        app.handle(key('g'));
301        app.handle(key('g'));
302        assert_eq!(app.log.as_ref().unwrap().cursor, 0);
303        app.handle(key('G'));
304        assert_eq!(app.log.as_ref().unwrap().cursor, 2);
305    }
306
307    #[test]
308    fn half_page_motions_move_the_log_cursor_by_the_viewport() {
309        let fixture = standard_fixture();
310        for index in 0..12 {
311            fixture.write("notes.txt", &format!("rev {index}\n"));
312            fixture.commit_all(&format!("commit number {index}"));
313        }
314        let mut app = open_log(&fixture);
315        app.log.as_mut().unwrap().viewport = 10;
316        app.handle(ctrl_key('d'));
317        assert_eq!(app.log.as_ref().unwrap().cursor, 5);
318        app.handle(ctrl_key('d'));
319        app.handle(ctrl_key('d'));
320        assert_eq!(app.log.as_ref().unwrap().cursor, 12, "clamps at the end");
321        app.handle(ctrl_key('u'));
322        assert_eq!(app.log.as_ref().unwrap().cursor, 7);
323        app.handle(ctrl_key('u'));
324        app.handle(ctrl_key('u'));
325        assert_eq!(app.log.as_ref().unwrap().cursor, 0, "clamps at the top");
326    }
327
328    #[test]
329    fn enter_opens_the_commit_diff_and_back_returns_to_the_log() {
330        let fixture = log_fixture();
331        let mut app = open_log(&fixture);
332        app.handle(key('j'));
333        app.handle(key('\n'));
334        assert_eq!(app.screen(), Screen::Diff);
335        let diff = app.diff.as_ref().expect("diff view");
336        let ReviewSource::Commit { oid } = &diff.source else {
337            panic!("expected a commit diff");
338        };
339        let expected = &app.log.as_ref().unwrap().entries[1].oid;
340        assert_eq!(oid, expected);
341
342        app.handle(key('q'));
343        assert_eq!(app.screen(), Screen::Log);
344        assert!(app.diff.is_none());
345        app.handle(key('q'));
346        assert_eq!(app.screen(), Screen::Status);
347    }
348
349    #[test]
350    fn visual_select_anchors_and_extends_over_commit_rows() {
351        let fixture = log_fixture();
352        let mut app = open_log(&fixture);
353        app.handle(key('V'));
354        let log = app.log.as_ref().expect("log view");
355        assert_eq!(log.visual_anchor, Some(0));
356        assert_eq!(log.selection(), Some((0, 0)));
357        // j extends the selection down to the older commit
358        app.handle(key('j'));
359        assert_eq!(app.log.as_ref().unwrap().selection(), Some((0, 1)));
360        // k brings the cursor back up, collapsing the selection
361        app.handle(key('k'));
362        assert_eq!(app.log.as_ref().unwrap().selection(), Some((0, 0)));
363    }
364
365    #[test]
366    fn enter_on_a_two_commit_selection_opens_the_combined_range_diff() {
367        let fixture = log_fixture();
368        let mut app = open_log(&fixture);
369        // select the two newest commits (add util module, add beta note)
370        app.handle(key('V'));
371        app.handle(key('j'));
372        let (newest, oldest) = {
373            let log = app.log.as_ref().unwrap();
374            (log.entries[0].oid.clone(), log.entries[1].oid.clone())
375        };
376        app.handle(key('\n'));
377        assert_eq!(app.screen(), Screen::Diff);
378        let diff = app.diff.as_ref().expect("diff view");
379        let ReviewSource::Range {
380            oldest: src_oldest,
381            newest: src_newest,
382        } = &diff.source
383        else {
384            panic!("expected a range diff, got {:?}", diff.source);
385        };
386        assert_eq!(src_oldest, &oldest);
387        assert_eq!(src_newest, &newest);
388        // the combined diff folds both commits' files into one model
389        let paths: Vec<&str> = diff
390            .model(&app.review)
391            .files
392            .iter()
393            .map(|f| f.path.as_str())
394            .collect();
395        assert!(paths.contains(&"notes.txt"), "{paths:?}");
396        assert!(paths.contains(&"src/util.rs"), "{paths:?}");
397    }
398
399    #[test]
400    fn enter_without_a_selection_opens_the_single_commit_diff() {
401        let fixture = log_fixture();
402        let mut app = open_log(&fixture);
403        app.handle(key('j'));
404        app.handle(key('\n'));
405        let diff = app.diff.as_ref().expect("diff view");
406        assert!(
407            matches!(&diff.source, ReviewSource::Commit { .. }),
408            "no selection means a single commit diff: {:?}",
409            diff.source
410        );
411    }
412
413    #[test]
414    fn escape_cancels_the_log_visual_selection() {
415        let fixture = log_fixture();
416        let mut app = open_log(&fixture);
417        app.handle(key('V'));
418        assert!(app.log.as_ref().unwrap().visual_anchor.is_some());
419        app.handle(crate::test_support::esc_key());
420        assert!(app.log.as_ref().unwrap().visual_anchor.is_none());
421        // V twice toggles off as well
422        app.handle(key('V'));
423        app.handle(key('V'));
424        assert!(app.log.as_ref().unwrap().visual_anchor.is_none());
425    }
426}