1use 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
11pub(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 pub graph: Vec<Vec<Cell>>,
25 pub head: Option<String>,
27 pub all_branches: bool,
29 pub remotes: Vec<String>,
31 pub cursor: usize,
32 pub scroll: usize,
34 pub visual_anchor: Option<usize>,
36 pub(crate) viewport: u16,
38 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 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 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 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 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 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 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 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 app.handle(key('j'));
359 assert_eq!(app.log.as_ref().unwrap().selection(), Some((0, 1)));
360 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 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 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 app.handle(key('V'));
423 app.handle(key('V'));
424 assert!(app.log.as_ref().unwrap().visual_anchor.is_none());
425 }
426}