1use std::collections::HashMap;
2use std::fs;
3use std::io::Write;
4use std::ops::Range;
5use std::path::PathBuf;
6use std::process::{Command, Stdio};
7
8use anyhow::Result;
9use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
10use ratatui::layout::{Constraint, Flex, Layout, Rect};
11use ratatui::style::{Color, Modifier, Style, Stylize};
12use ratatui::text::{Line, Span};
13use ratatui::widgets::{Block, Clear, List, ListItem, ListState, Paragraph};
14use ratatui::{DefaultTerminal, Frame};
15
16use crate::diff::{Row, RowKind};
17use crate::editor::editor_command;
18use crate::moves::{Direction, Moves, detect};
19use crate::palette::Palette;
20use crate::project::LayerMode;
21use crate::render::{empty_message, file_title};
22use crate::review::{ChangeStatus, Detection, FileReview, Layers, Summary};
23use crate::store::{Note, NoteSide, NoteStore, ReviewStore};
24
25const HELP: &str = " ↑↓ move ←→ pane ]/[ change }/{ file / search r reviewed m note c t i l f layers a all ? help q quit";
27
28type KeyGroup = (&'static str, &'static [(&'static str, &'static str)]);
30
31const KEYS: [&[KeyGroup]; 2] = [
33 &[
34 (
35 "Move",
36 &[
37 ("↑ ↓ j k", "move: files, or the diff cursor"),
38 ("← → Tab ⏎", "switch pane"),
39 ("] [", "next / previous change (on across files)"),
40 ("} {", "next / previous file"),
41 ("Space b", "page down / up"),
42 ("d u", "half a page down / up"),
43 ("g G", "top / bottom (in files: first / last)"),
44 ("/ n N", "search; next / previous match"),
45 ],
46 ),
47 (
48 "Other",
49 &[
50 ("Esc", "back: leave the diff, cancel, clear search"),
51 ("?", "this help"),
52 ("q", "quit"),
53 ],
54 ),
55 ],
56 &[
57 (
58 "Review",
59 &[
60 ("r", "mark reviewed, go to the next file"),
61 ("m", "note the line under the cursor"),
62 ("P", "note on the change as a whole"),
63 ("Alt+⏎", "new line, while writing a note"),
64 ("E", "copy all notes as one prompt"),
65 ("o", "open in $EDITOR at the line"),
66 ],
67 ),
68 (
69 "Layers: key hides / shows, Shift = only",
70 &[
71 ("c C", "comments"),
72 ("t T", "tests"),
73 ("i I", "imports"),
74 ("l L", "logging"),
75 ("f F", "formatting-only changes"),
76 ("a", "all filters off / back on"),
77 ("M", "collapse moved blocks"),
78 ("s", "syntax colouring on / off"),
79 ],
80 ),
81 ],
82];
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum Focus {
87 Files,
88 Diff,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq)]
94pub enum Input {
95 Note(String),
96 General(String),
97 Search(String),
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
102enum Layer {
103 Comments,
104 Tests,
105 Imports,
106 Logging,
107 Formatting,
108}
109
110#[derive(Debug, Clone, PartialEq, Eq)]
112struct Anchor {
113 side: NoteSide,
114 line: usize,
115 code: String,
116}
117
118struct DiffView {
120 lines: Vec<Line<'static>>,
121 anchors: Vec<Option<Anchor>>,
123 texts: Vec<Option<String>>,
125 changes: Vec<usize>,
127}
128
129#[derive(Debug, Clone, Copy)]
131struct Place {
132 side: NoteSide,
133 line: usize,
134 row: usize,
136}
137
138pub type Clipboard = fn(&str) -> bool;
140
141pub struct App {
142 pub files: Vec<FileReview>,
143 pub layers: Layers,
144 visible: Vec<usize>,
146 pub selected: usize,
148 pub cursor: usize,
150 pub scroll: usize,
151 pub focus: Focus,
152 pub store: ReviewStore,
153 pub notes: NoteStore,
154 pub input: Option<Input>,
156 input_cursor: usize,
158 pub search: Option<String>,
160 pub show_help: bool,
161 pub message: Option<String>,
163 pub clipboard: Clipboard,
164 moves: Moves,
166 pub collapse_moves: bool,
168 before_only: HashMap<Layer, LayerMode>,
170 saved_layers: Option<Layers>,
172 pub root: PathBuf,
174 pub open_request: Option<(PathBuf, usize)>,
176 pub title: String,
178 pub palette: Palette,
179 pub syntax: bool,
181 pub quit: bool,
182 diff_height: usize,
184 diff_width: usize,
186}
187
188impl App {
189 pub fn new(files: Vec<FileReview>, layers: Layers) -> App {
190 App::with_stores(
191 files,
192 layers,
193 ReviewStore::in_memory(),
194 NoteStore::in_memory(),
195 )
196 }
197
198 pub fn with_stores(
199 files: Vec<FileReview>,
200 layers: Layers,
201 store: ReviewStore,
202 notes: NoteStore,
203 ) -> App {
204 let mut app = App {
205 files,
206 layers,
207 visible: Vec::new(),
208 selected: 0,
209 cursor: 0,
210 scroll: 0,
211 focus: Focus::Files,
212 store,
213 notes,
214 input: None,
215 input_cursor: 0,
216 search: None,
217 show_help: false,
218 message: None,
219 clipboard: system_clipboard,
220 moves: Moves::new(),
221 collapse_moves: false,
222 before_only: HashMap::new(),
223 saved_layers: None,
224 root: PathBuf::from("."),
225 open_request: None,
226 title: String::new(),
227 palette: Palette::TRUE_COLOR,
228 syntax: true,
229 quit: false,
230 diff_height: 20,
231 diff_width: 80,
232 };
233 app.refilter();
234 app.refresh_moves();
235 app.go_to_first_change();
236 app
237 }
238
239 pub fn current(&self) -> Option<&FileReview> {
241 self.visible
242 .get(self.selected)
243 .map(|&index| &self.files[index])
244 }
245
246 fn refilter(&mut self) {
249 let current = self.visible.get(self.selected).copied();
250 self.visible = (0..self.files.len())
251 .filter(|&index| self.files[index].is_visible(self.layers.tests))
252 .collect();
253 match current.and_then(|index| self.visible.iter().position(|&i| i == index)) {
254 Some(position) => self.selected = position,
255 None => {
256 self.selected = self.selected.min(self.visible.len().saturating_sub(1));
257 self.go_to_first_change();
258 }
259 }
260 }
261
262 fn go_to_first_change(&mut self) {
264 self.cursor = self.diff_view().changes.first().copied().unwrap_or(0);
265 self.scroll = self.cursor.saturating_sub(1);
266 }
267
268 fn refresh_moves(&mut self) {
270 let listed: Vec<&FileReview> = self.visible.iter().map(|&i| &self.files[i]).collect();
271 self.moves = detect(&listed, self.layers.into());
272 }
273
274 pub fn moved_blocks(&self) -> usize {
276 self.moves
277 .values()
278 .filter(|moved| moved.starts_block && moved.direction == Direction::To)
279 .count()
280 }
281
282 pub fn handle_key(&mut self, key: KeyEvent) {
283 if self.show_help {
284 self.show_help = false;
285 return;
286 }
287 if self.input.is_some() {
288 self.handle_input_key(key);
289 return;
290 }
291 self.message = None;
292 let half_page = (self.diff_height / 2).max(1);
293 let page = self.diff_height.saturating_sub(1).max(1);
294 match key.code {
295 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => self.quit = true,
296 KeyCode::Char('q') => self.quit = true,
297 KeyCode::Char('?') => self.show_help = true,
298 KeyCode::Esc => match self.focus {
299 Focus::Diff => self.focus = Focus::Files,
300 Focus::Files => self.search = None,
301 },
302
303 KeyCode::Right | KeyCode::Enter => self.focus = Focus::Diff,
304 KeyCode::Left => self.focus = Focus::Files,
305 KeyCode::Tab | KeyCode::BackTab => {
306 self.focus = match self.focus {
307 Focus::Files => Focus::Diff,
308 Focus::Diff => Focus::Files,
309 }
310 }
311 KeyCode::Down | KeyCode::Char('j') => match self.focus {
312 Focus::Files => self.select(self.selected.saturating_add(1)),
313 Focus::Diff => self.move_cursor(self.cursor.saturating_add(1)),
314 },
315 KeyCode::Up | KeyCode::Char('k') => match self.focus {
316 Focus::Files => self.select(self.selected.saturating_sub(1)),
317 Focus::Diff => self.move_cursor(self.cursor.saturating_sub(1)),
318 },
319 KeyCode::Char(']') => self.next_change(),
320 KeyCode::Char('[') => self.previous_change(),
321 KeyCode::Char('}') => self.select(self.selected.saturating_add(1)),
322 KeyCode::Char('{') => self.select(self.selected.saturating_sub(1)),
323 KeyCode::Char(' ') | KeyCode::PageDown => self.page(page as isize),
324 KeyCode::Char('b') | KeyCode::PageUp => self.page(-(page as isize)),
325 KeyCode::Char('d') => self.page(half_page as isize),
326 KeyCode::Char('u') => self.page(-(half_page as isize)),
327 KeyCode::Char('g') | KeyCode::Home => match self.focus {
328 Focus::Files => self.select(0),
329 Focus::Diff => self.move_cursor(0),
330 },
331 KeyCode::Char('G') | KeyCode::End => match self.focus {
332 Focus::Files => self.select(usize::MAX),
333 Focus::Diff => self.move_cursor(usize::MAX),
334 },
335 KeyCode::Char('/') => self.start_input(Input::Search(String::new())),
336 KeyCode::Char('n') => self.find(true),
337 KeyCode::Char('N') => self.find(false),
338
339 KeyCode::Char('r') => self.toggle_reviewed(),
340 KeyCode::Char('m') => self.open_note(),
341 KeyCode::Char('P') => self.open_general_note(),
342 KeyCode::Char('E') => self.export_notes(),
343 KeyCode::Char('o') => self.request_open(),
344
345 KeyCode::Char('c') => self.toggle_hidden(Layer::Comments),
346 KeyCode::Char('C') => self.toggle_only(Layer::Comments),
347 KeyCode::Char('t') => self.toggle_hidden(Layer::Tests),
348 KeyCode::Char('T') => self.toggle_only(Layer::Tests),
349 KeyCode::Char('i') => self.toggle_hidden(Layer::Imports),
350 KeyCode::Char('I') => self.toggle_only(Layer::Imports),
351 KeyCode::Char('l') => self.toggle_hidden(Layer::Logging),
352 KeyCode::Char('L') => self.toggle_only(Layer::Logging),
353 KeyCode::Char('f') => self.toggle_hidden(Layer::Formatting),
354 KeyCode::Char('F') => self.toggle_only(Layer::Formatting),
355 KeyCode::Char('s') => self.syntax = !self.syntax,
356 KeyCode::Char('a') => self.toggle_all(),
357 KeyCode::Char('M') => {
358 let place = self.place();
359 self.collapse_moves = !self.collapse_moves;
360 self.restore(place);
361 }
362 _ => {}
363 }
364 }
365
366 fn handle_input_key(&mut self, key: KeyEvent) {
367 let Some(input) = self.input.as_mut() else {
368 return;
369 };
370 let is_note = !matches!(input, Input::Search(_));
371 let text = match input {
372 Input::Note(text) | Input::General(text) | Input::Search(text) => text,
373 };
374 let chars = text.chars().count();
375 self.input_cursor = self.input_cursor.min(chars);
376 let at = |cursor: usize| {
377 text.char_indices()
378 .nth(cursor)
379 .map_or(text.len(), |(i, _)| i)
380 };
381 match key.code {
382 KeyCode::Esc => self.input = None,
383 KeyCode::Enter
385 if is_note
386 && key
387 .modifiers
388 .intersects(KeyModifiers::ALT | KeyModifiers::SHIFT) =>
389 {
390 text.insert(at(self.input_cursor), '\n');
391 self.input_cursor += 1;
392 }
393 KeyCode::Enter => match self.input.take() {
394 Some(Input::Note(text)) => self.save_note(text),
395 Some(Input::General(text)) => self.save_general_note(text),
396 Some(Input::Search(query)) => {
397 if !query.is_empty() {
398 self.search = Some(query);
399 }
400 self.find(true);
401 }
402 None => {}
403 },
404 KeyCode::Left => self.input_cursor = self.input_cursor.saturating_sub(1),
405 KeyCode::Right => self.input_cursor = (self.input_cursor + 1).min(chars),
406 KeyCode::Home => self.input_cursor = 0,
407 KeyCode::End => self.input_cursor = chars,
408 KeyCode::Backspace if self.input_cursor > 0 => {
409 self.input_cursor -= 1;
410 text.remove(at(self.input_cursor));
411 }
412 KeyCode::Delete if self.input_cursor < chars => {
413 text.remove(at(self.input_cursor));
414 }
415 KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
416 text.insert(at(self.input_cursor), c);
417 self.input_cursor += 1;
418 }
419 _ => {}
420 }
421 }
422
423 fn start_input(&mut self, input: Input) {
425 self.input_cursor = match &input {
426 Input::Note(text) | Input::General(text) | Input::Search(text) => text.chars().count(),
427 };
428 self.input = Some(input);
429 }
430
431 fn layer_mut(&mut self, layer: Layer) -> &mut LayerMode {
432 match layer {
433 Layer::Comments => &mut self.layers.comments,
434 Layer::Tests => &mut self.layers.tests,
435 Layer::Imports => &mut self.layers.imports,
436 Layer::Logging => &mut self.layers.logging,
437 Layer::Formatting => &mut self.layers.formatting,
438 }
439 }
440
441 fn toggle_hidden(&mut self, layer: Layer) {
443 let before = self.where_am_i();
444 let mode = self.layer_mut(layer);
445 *mode = match *mode {
446 LayerMode::Shown => LayerMode::Hidden,
447 LayerMode::Hidden | LayerMode::Only => LayerMode::Shown,
448 };
449 self.layers_changed(layer, before);
450 }
451
452 fn toggle_only(&mut self, layer: Layer) {
454 let before = self.where_am_i();
455 let current = *self.layer_mut(layer);
456 let next = if current == LayerMode::Only {
457 self.before_only.remove(&layer).unwrap_or(LayerMode::Shown)
458 } else {
459 self.before_only.insert(layer, current);
460 LayerMode::Only
461 };
462 *self.layer_mut(layer) = next;
463 self.layers_changed(layer, before);
464 }
465
466 fn toggle_all(&mut self) {
469 let before = self.where_am_i();
470 let all = |mode| Layers {
471 comments: mode,
472 tests: mode,
473 imports: mode,
474 logging: mode,
475 formatting: mode,
476 };
477 let (next, message) = if self.layers != all(LayerMode::Shown) {
478 self.saved_layers = Some(self.layers);
479 (
480 all(LayerMode::Shown),
481 "all layers shown · a puts the filters back",
482 )
483 } else {
484 match self.saved_layers.take() {
485 Some(saved) => (saved, "filters back on"),
486 None => (all(LayerMode::Hidden), "every layer hidden"),
487 }
488 };
489 self.layers = next;
490 self.before_only.clear();
491 self.layers_changed(Layer::Tests, before);
492 self.message = Some(message.to_string());
493 }
494
495 fn where_am_i(&self) -> (Option<usize>, Option<Place>) {
497 (self.visible.get(self.selected).copied(), self.place())
498 }
499
500 fn layers_changed(&mut self, layer: Layer, (file, place): (Option<usize>, Option<Place>)) {
503 if layer == Layer::Tests {
504 self.refilter();
505 }
506 self.refresh_moves();
507 if self.visible.get(self.selected).copied() == file {
508 self.restore(place);
509 }
510 }
511
512 fn place(&self) -> Option<Place> {
514 let view = self.diff_view();
515 let at = self.cursor.min(view.anchors.len().saturating_sub(1));
516 let anchor = (0..=at)
517 .rev()
518 .chain(at + 1..view.anchors.len())
519 .find_map(|i| view.anchors.get(i)?.as_ref())?;
520 Some(Place {
521 side: anchor.side,
522 line: anchor.line,
523 row: self.cursor.saturating_sub(self.scroll),
524 })
525 }
526
527 fn restore(&mut self, place: Option<Place>) {
530 let Some(place) = place else {
531 self.go_to_first_change();
532 return;
533 };
534 let view = self.diff_view();
535 let closest = view
536 .anchors
537 .iter()
538 .enumerate()
539 .filter_map(|(i, anchor)| Some((i, anchor.as_ref()?)))
540 .min_by_key(|(_, anchor)| {
541 let other_side = usize::from(anchor.side != place.side);
542 (anchor.line.abs_diff(place.line), other_side)
543 });
544 match closest {
545 Some((index, _)) => {
546 self.cursor = index;
547 self.scroll = index.saturating_sub(place.row);
548 }
549 None => self.go_to_first_change(),
550 }
551 }
552
553 fn next_change(&mut self) {
555 if let Some(&line) = self
556 .diff_view()
557 .changes
558 .iter()
559 .find(|&&line| line > self.cursor)
560 {
561 self.jump_to(line);
562 return;
563 }
564 let next = (self.selected + 1..self.visible.len())
565 .find(|&position| !self.diff_view_of(position).changes.is_empty());
566 match next {
567 Some(position) => self.select(position),
568 None => self.message = Some("no more changes".to_string()),
569 }
570 }
571
572 fn previous_change(&mut self) {
574 let view = self.diff_view();
575 if let Some(&line) = view.changes.iter().rev().find(|&&line| line < self.cursor) {
576 self.jump_to(line);
577 return;
578 }
579 let previous = (0..self.selected)
580 .rev()
581 .find(|&position| !self.diff_view_of(position).changes.is_empty());
582 match previous {
583 Some(position) => {
584 self.select(position);
585 if let Some(&last) = self.diff_view().changes.last() {
586 self.jump_to(last);
587 }
588 }
589 None => self.message = Some("no earlier changes".to_string()),
590 }
591 }
592
593 fn jump_to(&mut self, line: usize) {
595 self.cursor = line;
596 self.scroll = line.saturating_sub(1);
597 }
598
599 fn page(&mut self, by: isize) {
600 self.scroll = self.scroll.saturating_add_signed(by);
601 self.move_cursor(self.cursor.saturating_add_signed(by));
602 }
603
604 fn find(&mut self, forward: bool) {
607 let Some(query) = self.search.clone() else {
608 self.message = Some("press / to search".to_string());
609 return;
610 };
611 let matches: Vec<(usize, usize)> = (0..self.visible.len())
612 .flat_map(|position| {
613 let view = self.diff_view_of(position);
614 view.texts
615 .iter()
616 .enumerate()
617 .filter(|(_, text)| {
618 text.as_deref()
619 .is_some_and(|t| !find_all(t, &query).is_empty())
620 })
621 .map(|(line, _)| (position, line))
622 .collect::<Vec<_>>()
623 })
624 .collect();
625 if matches.is_empty() {
626 self.message = Some(format!("no match for “{query}”"));
627 return;
628 }
629 let here = (self.selected, self.cursor);
630 let index = if forward {
631 matches.iter().position(|&m| m > here).unwrap_or(0)
632 } else {
633 matches
634 .iter()
635 .rposition(|&m| m < here)
636 .unwrap_or(matches.len() - 1)
637 };
638 let (position, line) = matches[index];
639 self.select(position);
640 self.focus = Focus::Diff;
641 self.cursor = line;
642 self.scroll = line.saturating_sub(self.diff_height / 2);
643 self.message = Some(format!(
644 "match {} of {} for “{query}”",
645 index + 1,
646 matches.len()
647 ));
648 }
649
650 fn open_note(&mut self) {
653 if self.focus == Focus::Files {
654 self.focus = Focus::Diff;
655 self.go_to_first_change();
656 }
657 let Some((path, anchor)) = self.cursor_anchor() else {
658 self.message = Some("put the cursor on a code line to leave a note".to_string());
659 return;
660 };
661 let existing = self.notes.find(&path, anchor.side, anchor.line);
662 self.start_input(Input::Note(
663 existing.map(|note| note.text.clone()).unwrap_or_default(),
664 ));
665 }
666
667 fn open_general_note(&mut self) {
669 let existing = self.notes.general().map(|note| note.text.clone());
670 self.start_input(Input::General(existing.unwrap_or_default()));
671 }
672
673 fn save_general_note(&mut self, text: String) {
674 let note = Note {
675 draft: false,
676 ..Note::on_pull_request(text)
677 };
678 if let Err(error) = self.notes.set(note) {
679 self.message = Some(format!("could not save the note: {error:#}"));
680 }
681 }
682
683 fn save_note(&mut self, text: String) {
684 let Some((path, anchor)) = self.cursor_anchor() else {
685 return;
686 };
687 let note = Note {
688 path,
689 side: anchor.side,
690 line: anchor.line,
691 code: anchor.code,
692 text,
693 review: None,
694 draft: false,
695 };
696 if let Err(error) = self.notes.set(note) {
697 self.message = Some(format!("could not save the note: {error:#}"));
698 }
699 }
700
701 fn export_notes(&mut self) {
703 let all = self.notes.notes();
704 let count = all.iter().filter(|note| !note.draft).count();
705 if count == 0 {
706 self.message = Some(if all.is_empty() {
707 "no notes yet: press m on a diff line to add one".to_string()
708 } else {
709 "only drafts so far: open one with m to include it".to_string()
710 });
711 return;
712 }
713 let prompt = self.notes.prompt();
714 let saved = self
715 .notes
716 .export_path()
717 .filter(|path| fs::write(path, &prompt).is_ok());
718 let copied = (self.clipboard)(&prompt);
719 let plural = if count == 1 { "" } else { "s" };
720 self.message = Some(match (copied, saved) {
721 (true, Some(path)) => format!(
722 "copied {count} note{plural} to the clipboard (also in {})",
723 path.display()
724 ),
725 (true, None) => format!("copied {count} note{plural} to the clipboard"),
726 (false, Some(path)) => format!("saved {count} note{plural} to {}", path.display()),
727 (false, None) => "could not copy or save the notes; run `declutter notes`".to_string(),
728 });
729 }
730
731 fn request_open(&mut self) {
734 let Some(file) = self.current() else {
735 return;
736 };
737 if file.status == ChangeStatus::Deleted {
738 self.message = Some("the file was deleted; there is nothing to open".to_string());
739 return;
740 }
741 let path = self.root.join(&file.path);
742 let view = self.diff_view();
743 let new_line = |anchor: &Option<Anchor>| {
744 anchor
745 .as_ref()
746 .filter(|anchor| anchor.side == NoteSide::New)
747 .map(|anchor| anchor.line)
748 };
749 let from = match self.focus {
750 Focus::Diff => self.cursor,
751 Focus::Files => view.changes.first().copied().unwrap_or(0),
752 }
753 .min(view.anchors.len());
754 let line = view.anchors[from..]
755 .iter()
756 .find_map(new_line)
757 .or_else(|| view.anchors[..from].iter().rev().find_map(new_line))
758 .unwrap_or(1);
759 self.open_request = Some((path, line));
760 }
761
762 fn cursor_anchor(&self) -> Option<(String, Anchor)> {
763 let path = self.current()?.path.clone();
764 let anchor = self
765 .diff_view()
766 .anchors
767 .into_iter()
768 .nth(self.cursor)
769 .flatten()?;
770 Some((path, anchor))
771 }
772
773 fn toggle_reviewed(&mut self) {
776 let Some(&index) = self.visible.get(self.selected) else {
777 return;
778 };
779 let reviewed = !self.store.is_reviewed(&self.files[index]);
780 if let Err(error) = self.store.set_reviewed(&self.files[index], reviewed) {
781 self.message = Some(format!("could not save review marks: {error:#}"));
782 }
783 if reviewed
784 && let Some(next) = (self.selected + 1..self.visible.len())
785 .find(|&position| !self.store.is_reviewed(&self.files[self.visible[position]]))
786 {
787 self.select(next);
788 }
789 }
790
791 pub fn review_progress(&self) -> (usize, usize) {
793 let reviewed = self
794 .visible
795 .iter()
796 .filter(|&&index| self.store.is_reviewed(&self.files[index]))
797 .count();
798 (reviewed, self.visible.len())
799 }
800
801 fn select(&mut self, index: usize) {
802 let index = index.min(self.visible.len().saturating_sub(1));
803 if index != self.selected {
804 self.selected = index;
805 self.go_to_first_change();
806 }
807 }
808
809 fn move_cursor(&mut self, cursor: usize) {
810 let last = self.diff_view().lines.len().saturating_sub(1);
811 self.cursor = cursor.min(last);
812 self.keep_cursor_visible();
813 }
814
815 fn keep_cursor_visible(&mut self) {
816 let height = self.diff_height.max(1);
817 if self.cursor < self.scroll {
818 self.scroll = self.cursor;
819 } else if self.cursor >= self.scroll + height {
820 self.scroll = self.cursor + 1 - height;
821 }
822 }
823
824 fn diff_view(&self) -> DiffView {
825 self.diff_view_of(self.selected)
826 }
827
828 fn diff_view_of(&self, position: usize) -> DiffView {
830 let message = |text: String| DiffView {
831 lines: vec![Line::from(text).dim()],
832 anchors: vec![None],
833 texts: vec![None],
834 changes: Vec::new(),
835 };
836 let Some(file) = self.visible.get(position).map(|&index| &self.files[index]) else {
837 return message(match (self.files.is_empty(), self.layers.tests) {
838 (true, _) => "No changes.".to_string(),
839 (false, LayerMode::Hidden) => {
840 "Every changed file is a test file. Press t to show tests.".to_string()
841 }
842 (false, _) => "No test files changed. Press T to show the other files.".to_string(),
843 });
844 };
845 let view = file.view(self.layers);
846 if file.detection == Detection::Binary {
847 return message("Binary file not shown.".to_string());
848 }
849 if view.hunks.is_empty() {
850 return message(format!(
851 "No visible changes: {}.",
852 empty_message(file, self.layers.into())
853 ));
854 }
855
856 let mut out = DiffView {
857 lines: Vec::new(),
858 anchors: Vec::new(),
859 texts: Vec::new(),
860 changes: Vec::new(),
861 };
862 let push = |out: &mut DiffView,
863 line: Line<'static>,
864 anchor: Option<Anchor>,
865 text: Option<String>| {
866 out.lines.push(line);
867 out.anchors.push(anchor);
868 out.texts.push(text);
869 };
870 for (hunk_index, hunk) in view.hunks.iter().enumerate() {
871 if hunk_index > 0 {
872 push(&mut out, Line::from(""), None, None);
873 }
874 push(
875 &mut out,
876 Line::from(hunk.header()).fg(Color::Cyan),
877 None,
878 None,
879 );
880 let mut first_change = None;
881 for (row_index, row) in hunk.rows.iter().enumerate() {
882 let moved = self.moves.get(&(position, hunk_index, row_index));
883 if let Some(moved) = moved
884 && moved.starts_block
885 {
886 let marker = moved.describe(&file.path);
887 let hint = if self.collapse_moves {
888 " (M to expand)"
889 } else {
890 ""
891 };
892 if self.collapse_moves && row.kind != RowKind::Context {
893 first_change.get_or_insert(out.lines.len());
894 }
895 push(
896 &mut out,
897 Line::from(format!(" {marker}{hint}"))
898 .fg(Color::Cyan)
899 .italic(),
900 None,
901 None,
902 );
903 }
904 if moved.is_some() && self.collapse_moves {
905 continue;
906 }
907 if row.kind != RowKind::Context {
908 first_change.get_or_insert(out.lines.len());
909 }
910 let anchor = anchor_of(row);
911 push(
912 &mut out,
913 row_line(
914 row,
915 moved.is_some(),
916 self.search.as_deref(),
917 &self.palette,
918 self.syntax,
919 ),
920 anchor.clone(),
921 Some(row.text.clone()),
922 );
923 if let Some(anchor) = anchor
924 && let Some(note) = self.notes.find(&file.path, anchor.side, anchor.line)
925 {
926 for line in note_lines(note, self.diff_width) {
927 push(&mut out, line, Some(anchor.clone()), None);
928 }
929 }
930 }
931 out.changes.extend(first_change);
932 }
933 out
934 }
935}
936
937const NOTE_INDENT: usize = 14;
939
940fn note_lines(note: &Note, width: usize) -> Vec<Line<'static>> {
942 let marker = if note.draft { "✎ draft · " } else { "✎ " };
943 let width = width.saturating_sub(NOTE_INDENT + 2).max(20);
944 let style = if note.draft {
945 Style::new().fg(Color::Yellow).italic()
946 } else {
947 Style::new().fg(Color::Yellow).bold()
948 };
949 let mut first = true;
950 wrap(&format!("{marker}{}", note.text.trim()), width)
951 .into_iter()
952 .map(|text| {
953 let indent = if first { NOTE_INDENT } else { NOTE_INDENT + 2 };
954 first = false;
955 Line::from(format!("{}{text}", " ".repeat(indent))).style(style)
956 })
957 .collect()
958}
959
960fn wrap(text: &str, width: usize) -> Vec<String> {
963 let width = width.max(1);
964 let mut rows = Vec::new();
965 for line in text.split('\n') {
966 let mut row = String::new();
967 for word in line.split(' ') {
968 let row_len = row.chars().count();
969 let word_len = word.chars().count();
970 if row_len > 0 && row_len + 1 + word_len > width {
971 rows.push(std::mem::take(&mut row));
972 } else if row_len > 0 {
973 row.push(' ');
974 }
975 row.push_str(word);
976 while row.chars().count() > width {
977 let rest: String = row.chars().skip(width).collect();
978 rows.push(row.chars().take(width).collect());
979 row = rest;
980 }
981 }
982 rows.push(row);
983 }
984 rows
985}
986
987fn find_all(text: &str, query: &str) -> Vec<Range<usize>> {
989 if query.is_empty() {
990 return Vec::new();
991 }
992 let sensitive = query.chars().any(char::is_uppercase);
993 let (haystack, needle) = if sensitive {
994 (text.to_string(), query.to_string())
995 } else {
996 (text.to_lowercase(), query.to_lowercase())
997 };
998 if haystack.len() != text.len() {
1000 return text
1001 .match_indices(query)
1002 .map(|(at, found)| at..at + found.len())
1003 .collect();
1004 }
1005 haystack
1006 .match_indices(&needle)
1007 .map(|(at, found)| at..at + found.len())
1008 .collect()
1009}
1010
1011fn anchor_of(row: &Row) -> Option<Anchor> {
1012 let (side, line) = match row.kind {
1013 RowKind::Removed => (NoteSide::Old, row.old_line?),
1014 RowKind::Added | RowKind::Context => (NoteSide::New, row.new_line?),
1015 };
1016 Some(Anchor {
1017 side,
1018 line,
1019 code: row.text.clone(),
1020 })
1021}
1022
1023fn row_line(
1026 row: &Row,
1027 moved: bool,
1028 search: Option<&str>,
1029 palette: &Palette,
1030 syntax: bool,
1031) -> Line<'static> {
1032 let number = |n: Option<usize>| n.map_or(" ".to_string(), |n| format!("{n:>5}"));
1033 let (sign, sign_colour, line, emphasis) = match (row.kind, moved) {
1034 (RowKind::Context, _) => (' ', Color::Reset, Style::new(), Style::new()),
1035 (RowKind::Removed, true) => (
1037 '-',
1038 palette.moved_sign,
1039 palette.moved_away,
1040 palette.moved_away,
1041 ),
1042 (RowKind::Added, true) => (
1043 '+',
1044 palette.moved_sign,
1045 palette.moved_here,
1046 palette.moved_here,
1047 ),
1048 (RowKind::Removed, false) => (
1049 '-',
1050 palette.removed_sign,
1051 palette.removed,
1052 palette.removed_emphasis,
1053 ),
1054 (RowKind::Added, false) => (
1055 '+',
1056 palette.added_sign,
1057 palette.added,
1058 palette.added_emphasis,
1059 ),
1060 };
1061 let coloured = syntax && !row.syntax.is_empty();
1062 let style = if coloured || row.kind == RowKind::Context {
1064 line
1065 } else {
1066 line.fg(sign_colour)
1067 };
1068 let syntax_marks = row
1069 .syntax
1070 .iter()
1071 .filter(|_| coloured)
1072 .filter_map(|(range, class)| Some((range.clone(), palette.syntax(*class)?)));
1073 let marks: Vec<(Range<usize>, Style)> = syntax_marks
1074 .chain(row.emphasis.iter().map(|range| (range.clone(), emphasis)))
1075 .chain(
1076 search
1077 .map(|query| find_all(&row.text, query))
1078 .unwrap_or_default()
1079 .into_iter()
1080 .map(|range| (range, palette.search)),
1081 )
1082 .collect();
1083 let mut spans = vec![
1084 Span::styled(
1085 format!("{} {} ", number(row.old_line), number(row.new_line)),
1086 Style::new().add_modifier(Modifier::DIM),
1087 ),
1088 Span::styled(
1089 format!("{sign} "),
1090 line.fg(sign_colour).add_modifier(Modifier::BOLD),
1091 ),
1092 ];
1093 spans.extend(styled_segments(&row.text, style, &marks));
1094 Line::from(spans)
1095}
1096
1097fn styled_segments(
1100 text: &str,
1101 base: Style,
1102 layers: &[(Range<usize>, Style)],
1103) -> Vec<Span<'static>> {
1104 let mut cuts: Vec<usize> = layers
1105 .iter()
1106 .flat_map(|(range, _)| [range.start, range.end])
1107 .filter(|&cut| cut < text.len() && text.is_char_boundary(cut))
1108 .chain([0, text.len()])
1109 .collect();
1110 cuts.sort_unstable();
1111 cuts.dedup();
1112 cuts.windows(2)
1113 .map(|pair| {
1114 let (start, end) = (pair[0], pair[1]);
1115 let style = layers
1116 .iter()
1117 .filter(|(range, _)| range.start <= start && end <= range.end)
1118 .fold(base, |style, (_, layer)| style.patch(*layer));
1119 Span::styled(text[start..end].replace('\t', " "), style)
1120 })
1121 .collect()
1122}
1123
1124pub fn draw(frame: &mut Frame, app: &mut App) {
1125 let [main, status, help] = Layout::vertical([
1126 Constraint::Min(3),
1127 Constraint::Length(1),
1128 Constraint::Length(1),
1129 ])
1130 .areas(frame.area());
1131 let [files_area, diff_area] =
1132 Layout::horizontal([Constraint::Percentage(30), Constraint::Min(20)]).areas(main);
1133
1134 let items: Vec<ListItem> = app
1135 .visible
1136 .iter()
1137 .map(|&index| {
1138 let file = &app.files[index];
1139 let visible = file.view(app.layers).hunks.len();
1140 let reviewed = app.store.is_reviewed(file);
1141 let mark = if reviewed { "✓ " } else { " " };
1142 let noted = app
1143 .notes
1144 .notes()
1145 .iter()
1146 .filter(|note| note.path == file.path)
1147 .count();
1148 let noted = match noted {
1149 0 => String::new(),
1150 n => format!(" ✎{n}"),
1151 };
1152 let item = ListItem::new(format!("{mark}{} ({visible}){noted}", file_title(file)));
1153 if visible == 0 || reviewed {
1154 item.dim()
1155 } else {
1156 item
1157 }
1158 })
1159 .collect();
1160 let mut list_state =
1161 ListState::default().with_selected((!app.visible.is_empty()).then_some(app.selected));
1162 frame.render_stateful_widget(
1163 List::new(items)
1164 .block(pane(
1165 if app.title.is_empty() {
1166 " Files ".to_string()
1167 } else {
1168 format!(" {} ", app.title)
1169 },
1170 app.focus == Focus::Files,
1171 ))
1172 .highlight_style(Style::new().add_modifier(Modifier::REVERSED)),
1173 files_area,
1174 &mut list_state,
1175 );
1176
1177 app.diff_height = diff_area.height.saturating_sub(2) as usize;
1178 app.diff_width = diff_area.width.saturating_sub(3) as usize;
1180 let lines = app.diff_view().lines;
1181 app.cursor = app.cursor.min(lines.len().saturating_sub(1));
1182 app.scroll = app.scroll.min(lines.len().saturating_sub(app.diff_height));
1183 app.keep_cursor_visible();
1184 let lines: Vec<Line> = lines
1185 .into_iter()
1186 .enumerate()
1187 .map(|(i, line)| {
1188 let on_cursor = app.focus == Focus::Diff && i == app.cursor;
1189 let gutter = if on_cursor {
1190 Span::styled("▌", Style::new().fg(Color::Cyan))
1191 } else {
1192 Span::raw(" ")
1193 };
1194 let mut spans = vec![gutter];
1195 spans.extend(line.spans);
1196 Line::from(spans).style(line.style)
1197 })
1198 .collect();
1199 let title = app
1200 .current()
1201 .map(|file| format!(" {} [{}] ", file.path, file.tag()))
1202 .unwrap_or_default();
1203 frame.render_widget(
1204 Paragraph::new(lines)
1205 .block(pane(title, app.focus == Focus::Diff))
1206 .scroll((app.scroll.min(u16::MAX as usize) as u16, 0)),
1207 diff_area,
1208 );
1209
1210 let (reviewed, listed) = app.review_progress();
1211 let summary = match (&app.input, &app.message) {
1212 (Some(Input::Note(_) | Input::General(_)), _) => {
1213 "Enter save · Alt+Enter new line · ← → Home End move · Esc cancel · empty removes"
1214 .to_string()
1215 }
1216 (Some(Input::Search(query)), _) => format!(
1217 "/{} (Enter search · Esc cancel)",
1218 with_cursor(query, app.input_cursor)
1219 ),
1220 (None, Some(message)) => message.clone(),
1221 (None, None) => format!(
1222 "{} · {reviewed}/{listed} reviewed{}",
1223 Summary::new(&app.files, app.layers).status_line(app.layers),
1224 notes_status(&app.notes)
1225 + &match (app.moved_blocks(), app.collapse_moves) {
1226 (0, _) => String::new(),
1227 (n, collapsed) => format!(
1228 " · {n} moved block{}{}",
1229 if n == 1 { "" } else { "s" },
1230 if collapsed { " (collapsed)" } else { "" }
1231 ),
1232 }
1233 ),
1234 };
1235 frame.render_widget(
1236 Paragraph::new(format!(" {summary}")).style(Style::new().add_modifier(Modifier::REVERSED)),
1237 status,
1238 );
1239 frame.render_widget(Paragraph::new(HELP).dim(), help);
1240 if let Some(Input::Note(text) | Input::General(text)) = &app.input {
1241 let title = match &app.input {
1242 Some(Input::General(_)) => " Note on the change as a whole ".to_string(),
1243 _ => match app.cursor_anchor() {
1244 Some((path, anchor)) if anchor.side == NoteSide::Old => {
1245 format!(" Note on {path}, removed line {} ", anchor.line)
1246 }
1247 Some((path, anchor)) => format!(" Note on {path}:{} ", anchor.line),
1248 None => " Note ".to_string(),
1249 },
1250 };
1251 let width = diff_area.width.saturating_sub(4) as usize;
1252 let rows = wrap(&with_cursor(text, app.input_cursor), width);
1253 let height = (rows.len() as u16 + 2).min(diff_area.height);
1254 let area = Rect {
1255 y: diff_area.bottom().saturating_sub(height),
1256 height,
1257 ..diff_area
1258 };
1259 let block = pane(title, true);
1260 let inner = block.inner(area);
1261 frame.render_widget(Clear, area);
1262 frame.render_widget(block, area);
1263 let text: Vec<Line> = rows.into_iter().map(Line::from).collect();
1264 frame.render_widget(
1265 Paragraph::new(text).fg(Color::Yellow),
1266 Rect {
1267 x: inner.x + 1,
1268 width: inner.width.saturating_sub(1),
1269 ..inner
1270 },
1271 );
1272 }
1273 if app.show_help {
1274 draw_help(frame);
1275 }
1276}
1277
1278fn draw_help(frame: &mut Frame) {
1280 let columns: Vec<Vec<Line>> = KEYS
1281 .iter()
1282 .map(|groups| {
1283 let mut lines = Vec::new();
1284 for (group, keys) in groups.iter() {
1285 if !lines.is_empty() {
1286 lines.push(Line::from(""));
1287 }
1288 lines.push(Line::from(*group).bold().fg(Color::Cyan));
1289 for (key, action) in *keys {
1290 lines.push(Line::from(vec![
1291 Span::styled(format!(" {key:<12}"), Style::new().bold()),
1292 Span::raw(*action),
1293 ]));
1294 }
1295 }
1296 lines
1297 })
1298 .collect();
1299 let widths: Vec<u16> = columns
1300 .iter()
1301 .map(|lines| lines.iter().map(Line::width).max().unwrap_or(0) as u16)
1302 .collect();
1303 let rows = columns.iter().map(Vec::len).max().unwrap_or(0) as u16;
1304 let area = centered(frame.area(), widths.iter().sum::<u16>() + 7, rows + 2);
1306 frame.render_widget(Clear, area);
1307 let block = pane(" Keys · any key closes ".to_string(), true);
1308 let inner = block.inner(area);
1309 frame.render_widget(block, area);
1310 let [left, right] =
1311 Layout::horizontal([Constraint::Length(widths[0] + 4), Constraint::Min(0)]).areas(inner);
1312 let [left_column, right_column] = [left, right].map(|area| Rect {
1313 x: area.x + 1,
1314 width: area.width.saturating_sub(1),
1315 ..area
1316 });
1317 let mut columns = columns.into_iter();
1318 for area in [left_column, right_column] {
1319 frame.render_widget(Paragraph::new(columns.next().unwrap_or_default()), area);
1320 }
1321}
1322
1323fn with_cursor(text: &str, cursor: usize) -> String {
1325 let at = text
1326 .char_indices()
1327 .nth(cursor)
1328 .map_or(text.len(), |(i, _)| i);
1329 format!("{}█{}", &text[..at], &text[at..])
1330}
1331
1332fn notes_status(notes: &NoteStore) -> String {
1334 let all = notes.notes();
1335 let plural = |n: usize| if n == 1 { "" } else { "s" };
1336 let drafts = all.iter().filter(|note| note.draft).count();
1337 let mut status = match (all.len(), drafts) {
1338 (0, _) => String::new(),
1339 (n, 0) => format!(" · {n} note{}", plural(n)),
1340 (n, d) => format!(" · {n} note{} ({d} draft{})", plural(n), plural(d)),
1341 };
1342 if notes.general().is_some_and(|note| note.draft) {
1343 status.push_str(" · P: draft on the whole change");
1344 }
1345 status
1346}
1347
1348fn centered(area: Rect, width: u16, height: u16) -> Rect {
1349 let [area] = Layout::horizontal([Constraint::Length(width.min(area.width))])
1350 .flex(Flex::Center)
1351 .areas(area);
1352 let [area] = Layout::vertical([Constraint::Length(height.min(area.height))])
1353 .flex(Flex::Center)
1354 .areas(area);
1355 area
1356}
1357
1358fn pane(title: String, focused: bool) -> Block<'static> {
1360 let block = Block::bordered().title(title);
1361 if focused {
1362 block
1363 .border_style(Style::new().fg(Color::Cyan))
1364 .title_style(Style::new().bold())
1365 } else {
1366 block.border_style(Style::new().add_modifier(Modifier::DIM))
1367 }
1368}
1369
1370fn system_clipboard(text: &str) -> bool {
1372 let tools: [(&str, &[&str]); 3] = [
1373 ("pbcopy", &[]),
1374 ("wl-copy", &[]),
1375 ("xclip", &["-selection", "clipboard"]),
1376 ];
1377 tools.iter().any(|(program, args)| {
1378 let Ok(mut child) = Command::new(program)
1379 .args(*args)
1380 .stdin(Stdio::piped())
1381 .stdout(Stdio::null())
1382 .stderr(Stdio::null())
1383 .spawn()
1384 else {
1385 return false;
1386 };
1387 let written = child
1388 .stdin
1389 .take()
1390 .is_some_and(|mut stdin| stdin.write_all(text.as_bytes()).is_ok());
1391 child.wait().is_ok_and(|status| status.success()) && written
1392 })
1393}
1394
1395pub fn run(
1396 files: Vec<FileReview>,
1397 layers: Layers,
1398 store: ReviewStore,
1399 notes: NoteStore,
1400 root: PathBuf,
1401 title: String,
1402 syntax: bool,
1403) -> Result<NoteStore> {
1404 let mut app = App::with_stores(files, layers, store, notes);
1405 app.root = root;
1406 app.title = title;
1407 app.palette = Palette::detect();
1408 app.syntax = syntax;
1409 ratatui::run(|terminal: &mut DefaultTerminal| -> Result<()> {
1410 while !app.quit {
1411 terminal.draw(|frame| draw(frame, &mut app))?;
1412 if let Event::Key(key) = event::read()?
1413 && key.kind == KeyEventKind::Press
1414 {
1415 app.handle_key(key);
1416 }
1417 if let Some((path, line)) = app.open_request.take() {
1418 app.message = open_in_editor(terminal, &path, line).err();
1419 }
1420 }
1421 Ok(())
1422 })?;
1423 Ok(app.notes)
1424}
1425
1426fn open_in_editor(
1429 terminal: &mut DefaultTerminal,
1430 path: &std::path::Path,
1431 line: usize,
1432) -> Result<(), String> {
1433 let editor = std::env::var("VISUAL")
1434 .or_else(|_| std::env::var("EDITOR"))
1435 .ok()
1436 .filter(|value| !value.trim().is_empty());
1437 let command = editor_command(editor.as_deref(), path, line);
1438 let mut process = Command::new(&command.program);
1439 process.args(&command.args);
1440 let failed = |error: std::io::Error| format!("could not start `{}`: {error}", command.program);
1441 if command.in_terminal {
1442 ratatui::restore();
1443 let status = process.status();
1444 *terminal = ratatui::init();
1445 status.map_err(failed)?;
1446 } else {
1447 process
1448 .stdin(Stdio::null())
1449 .stdout(Stdio::null())
1450 .stderr(Stdio::null())
1451 .spawn()
1452 .map_err(failed)?;
1453 }
1454 Ok(())
1455}