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 ("E", "copy all notes as one prompt"),
63 ("o", "open in $EDITOR at the line"),
64 ],
65 ),
66 (
67 "Layers: key hides / shows, Shift = only",
68 &[
69 ("c C", "comments"),
70 ("t T", "tests"),
71 ("i I", "imports"),
72 ("l L", "logging"),
73 ("f F", "formatting-only changes"),
74 ("a", "all filters off / back on"),
75 ("M", "collapse moved blocks"),
76 ("s", "syntax colouring on / off"),
77 ],
78 ),
79 ],
80];
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub enum Focus {
85 Files,
86 Diff,
87}
88
89#[derive(Debug, Clone, PartialEq, Eq)]
91pub enum Input {
92 Note(String),
93 Search(String),
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
98enum Layer {
99 Comments,
100 Tests,
101 Imports,
102 Logging,
103 Formatting,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq)]
108struct Anchor {
109 side: NoteSide,
110 line: usize,
111 code: String,
112}
113
114struct DiffView {
116 lines: Vec<Line<'static>>,
117 anchors: Vec<Option<Anchor>>,
119 texts: Vec<Option<String>>,
121 changes: Vec<usize>,
123}
124
125#[derive(Debug, Clone, Copy)]
127struct Place {
128 side: NoteSide,
129 line: usize,
130 row: usize,
132}
133
134pub type Clipboard = fn(&str) -> bool;
136
137pub struct App {
138 pub files: Vec<FileReview>,
139 pub layers: Layers,
140 visible: Vec<usize>,
142 pub selected: usize,
144 pub cursor: usize,
146 pub scroll: usize,
147 pub focus: Focus,
148 pub store: ReviewStore,
149 pub notes: NoteStore,
150 pub input: Option<Input>,
152 pub search: Option<String>,
154 pub show_help: bool,
155 pub message: Option<String>,
157 pub clipboard: Clipboard,
158 moves: Moves,
160 pub collapse_moves: bool,
162 before_only: HashMap<Layer, LayerMode>,
164 saved_layers: Option<Layers>,
166 pub root: PathBuf,
168 pub open_request: Option<(PathBuf, usize)>,
170 pub title: String,
172 pub palette: Palette,
173 pub syntax: bool,
175 pub quit: bool,
176 diff_height: usize,
178}
179
180impl App {
181 pub fn new(files: Vec<FileReview>, layers: Layers) -> App {
182 App::with_stores(
183 files,
184 layers,
185 ReviewStore::in_memory(),
186 NoteStore::in_memory(),
187 )
188 }
189
190 pub fn with_stores(
191 files: Vec<FileReview>,
192 layers: Layers,
193 store: ReviewStore,
194 notes: NoteStore,
195 ) -> App {
196 let mut app = App {
197 files,
198 layers,
199 visible: Vec::new(),
200 selected: 0,
201 cursor: 0,
202 scroll: 0,
203 focus: Focus::Files,
204 store,
205 notes,
206 input: None,
207 search: None,
208 show_help: false,
209 message: None,
210 clipboard: system_clipboard,
211 moves: Moves::new(),
212 collapse_moves: false,
213 before_only: HashMap::new(),
214 saved_layers: None,
215 root: PathBuf::from("."),
216 open_request: None,
217 title: String::new(),
218 palette: Palette::TRUE_COLOR,
219 syntax: true,
220 quit: false,
221 diff_height: 20,
222 };
223 app.refilter();
224 app.refresh_moves();
225 app.go_to_first_change();
226 app
227 }
228
229 pub fn current(&self) -> Option<&FileReview> {
231 self.visible
232 .get(self.selected)
233 .map(|&index| &self.files[index])
234 }
235
236 fn refilter(&mut self) {
239 let current = self.visible.get(self.selected).copied();
240 self.visible = (0..self.files.len())
241 .filter(|&index| self.files[index].is_visible(self.layers.tests))
242 .collect();
243 match current.and_then(|index| self.visible.iter().position(|&i| i == index)) {
244 Some(position) => self.selected = position,
245 None => {
246 self.selected = self.selected.min(self.visible.len().saturating_sub(1));
247 self.go_to_first_change();
248 }
249 }
250 }
251
252 fn go_to_first_change(&mut self) {
254 self.cursor = self.diff_view().changes.first().copied().unwrap_or(0);
255 self.scroll = self.cursor.saturating_sub(1);
256 }
257
258 fn refresh_moves(&mut self) {
260 let listed: Vec<&FileReview> = self.visible.iter().map(|&i| &self.files[i]).collect();
261 self.moves = detect(&listed, self.layers.into());
262 }
263
264 pub fn moved_blocks(&self) -> usize {
266 self.moves
267 .values()
268 .filter(|moved| moved.starts_block && moved.direction == Direction::To)
269 .count()
270 }
271
272 pub fn handle_key(&mut self, key: KeyEvent) {
273 if self.show_help {
274 self.show_help = false;
275 return;
276 }
277 if self.input.is_some() {
278 self.handle_input_key(key);
279 return;
280 }
281 self.message = None;
282 let half_page = (self.diff_height / 2).max(1);
283 let page = self.diff_height.saturating_sub(1).max(1);
284 match key.code {
285 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => self.quit = true,
286 KeyCode::Char('q') => self.quit = true,
287 KeyCode::Char('?') => self.show_help = true,
288 KeyCode::Esc => match self.focus {
289 Focus::Diff => self.focus = Focus::Files,
290 Focus::Files => self.search = None,
291 },
292
293 KeyCode::Right | KeyCode::Enter => self.focus = Focus::Diff,
294 KeyCode::Left => self.focus = Focus::Files,
295 KeyCode::Tab | KeyCode::BackTab => {
296 self.focus = match self.focus {
297 Focus::Files => Focus::Diff,
298 Focus::Diff => Focus::Files,
299 }
300 }
301 KeyCode::Down | KeyCode::Char('j') => match self.focus {
302 Focus::Files => self.select(self.selected.saturating_add(1)),
303 Focus::Diff => self.move_cursor(self.cursor.saturating_add(1)),
304 },
305 KeyCode::Up | KeyCode::Char('k') => match self.focus {
306 Focus::Files => self.select(self.selected.saturating_sub(1)),
307 Focus::Diff => self.move_cursor(self.cursor.saturating_sub(1)),
308 },
309 KeyCode::Char(']') => self.next_change(),
310 KeyCode::Char('[') => self.previous_change(),
311 KeyCode::Char('}') => self.select(self.selected.saturating_add(1)),
312 KeyCode::Char('{') => self.select(self.selected.saturating_sub(1)),
313 KeyCode::Char(' ') | KeyCode::PageDown => self.page(page as isize),
314 KeyCode::Char('b') | KeyCode::PageUp => self.page(-(page as isize)),
315 KeyCode::Char('d') => self.page(half_page as isize),
316 KeyCode::Char('u') => self.page(-(half_page as isize)),
317 KeyCode::Char('g') | KeyCode::Home => match self.focus {
318 Focus::Files => self.select(0),
319 Focus::Diff => self.move_cursor(0),
320 },
321 KeyCode::Char('G') | KeyCode::End => match self.focus {
322 Focus::Files => self.select(usize::MAX),
323 Focus::Diff => self.move_cursor(usize::MAX),
324 },
325 KeyCode::Char('/') => self.input = Some(Input::Search(String::new())),
326 KeyCode::Char('n') => self.find(true),
327 KeyCode::Char('N') => self.find(false),
328
329 KeyCode::Char('r') => self.toggle_reviewed(),
330 KeyCode::Char('m') => self.open_note(),
331 KeyCode::Char('E') => self.export_notes(),
332 KeyCode::Char('o') => self.request_open(),
333
334 KeyCode::Char('c') => self.toggle_hidden(Layer::Comments),
335 KeyCode::Char('C') => self.toggle_only(Layer::Comments),
336 KeyCode::Char('t') => self.toggle_hidden(Layer::Tests),
337 KeyCode::Char('T') => self.toggle_only(Layer::Tests),
338 KeyCode::Char('i') => self.toggle_hidden(Layer::Imports),
339 KeyCode::Char('I') => self.toggle_only(Layer::Imports),
340 KeyCode::Char('l') => self.toggle_hidden(Layer::Logging),
341 KeyCode::Char('L') => self.toggle_only(Layer::Logging),
342 KeyCode::Char('f') => self.toggle_hidden(Layer::Formatting),
343 KeyCode::Char('F') => self.toggle_only(Layer::Formatting),
344 KeyCode::Char('s') => self.syntax = !self.syntax,
345 KeyCode::Char('a') => self.toggle_all(),
346 KeyCode::Char('M') => {
347 let place = self.place();
348 self.collapse_moves = !self.collapse_moves;
349 self.restore(place);
350 }
351 _ => {}
352 }
353 }
354
355 fn handle_input_key(&mut self, key: KeyEvent) {
356 let Some(input) = self.input.as_mut() else {
357 return;
358 };
359 let text = match input {
360 Input::Note(text) | Input::Search(text) => text,
361 };
362 match key.code {
363 KeyCode::Esc => self.input = None,
364 KeyCode::Enter => match self.input.take() {
365 Some(Input::Note(text)) => self.save_note(text),
366 Some(Input::Search(query)) => {
367 if !query.is_empty() {
368 self.search = Some(query);
369 }
370 self.find(true);
371 }
372 None => {}
373 },
374 KeyCode::Backspace => {
375 text.pop();
376 }
377 KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => text.push(c),
378 _ => {}
379 }
380 }
381
382 fn layer_mut(&mut self, layer: Layer) -> &mut LayerMode {
383 match layer {
384 Layer::Comments => &mut self.layers.comments,
385 Layer::Tests => &mut self.layers.tests,
386 Layer::Imports => &mut self.layers.imports,
387 Layer::Logging => &mut self.layers.logging,
388 Layer::Formatting => &mut self.layers.formatting,
389 }
390 }
391
392 fn toggle_hidden(&mut self, layer: Layer) {
394 let before = self.where_am_i();
395 let mode = self.layer_mut(layer);
396 *mode = match *mode {
397 LayerMode::Shown => LayerMode::Hidden,
398 LayerMode::Hidden | LayerMode::Only => LayerMode::Shown,
399 };
400 self.layers_changed(layer, before);
401 }
402
403 fn toggle_only(&mut self, layer: Layer) {
405 let before = self.where_am_i();
406 let current = *self.layer_mut(layer);
407 let next = if current == LayerMode::Only {
408 self.before_only.remove(&layer).unwrap_or(LayerMode::Shown)
409 } else {
410 self.before_only.insert(layer, current);
411 LayerMode::Only
412 };
413 *self.layer_mut(layer) = next;
414 self.layers_changed(layer, before);
415 }
416
417 fn toggle_all(&mut self) {
420 let before = self.where_am_i();
421 let all = |mode| Layers {
422 comments: mode,
423 tests: mode,
424 imports: mode,
425 logging: mode,
426 formatting: mode,
427 };
428 let (next, message) = if self.layers != all(LayerMode::Shown) {
429 self.saved_layers = Some(self.layers);
430 (
431 all(LayerMode::Shown),
432 "all layers shown · a puts the filters back",
433 )
434 } else {
435 match self.saved_layers.take() {
436 Some(saved) => (saved, "filters back on"),
437 None => (all(LayerMode::Hidden), "every layer hidden"),
438 }
439 };
440 self.layers = next;
441 self.before_only.clear();
442 self.layers_changed(Layer::Tests, before);
443 self.message = Some(message.to_string());
444 }
445
446 fn where_am_i(&self) -> (Option<usize>, Option<Place>) {
448 (self.visible.get(self.selected).copied(), self.place())
449 }
450
451 fn layers_changed(&mut self, layer: Layer, (file, place): (Option<usize>, Option<Place>)) {
454 if layer == Layer::Tests {
455 self.refilter();
456 }
457 self.refresh_moves();
458 if self.visible.get(self.selected).copied() == file {
459 self.restore(place);
460 }
461 }
462
463 fn place(&self) -> Option<Place> {
465 let view = self.diff_view();
466 let at = self.cursor.min(view.anchors.len().saturating_sub(1));
467 let anchor = (0..=at)
468 .rev()
469 .chain(at + 1..view.anchors.len())
470 .find_map(|i| view.anchors.get(i)?.as_ref())?;
471 Some(Place {
472 side: anchor.side,
473 line: anchor.line,
474 row: self.cursor.saturating_sub(self.scroll),
475 })
476 }
477
478 fn restore(&mut self, place: Option<Place>) {
481 let Some(place) = place else {
482 self.go_to_first_change();
483 return;
484 };
485 let view = self.diff_view();
486 let closest = view
487 .anchors
488 .iter()
489 .enumerate()
490 .filter_map(|(i, anchor)| Some((i, anchor.as_ref()?)))
491 .min_by_key(|(_, anchor)| {
492 let other_side = usize::from(anchor.side != place.side);
493 (anchor.line.abs_diff(place.line), other_side)
494 });
495 match closest {
496 Some((index, _)) => {
497 self.cursor = index;
498 self.scroll = index.saturating_sub(place.row);
499 }
500 None => self.go_to_first_change(),
501 }
502 }
503
504 fn next_change(&mut self) {
506 if let Some(&line) = self
507 .diff_view()
508 .changes
509 .iter()
510 .find(|&&line| line > self.cursor)
511 {
512 self.jump_to(line);
513 return;
514 }
515 let next = (self.selected + 1..self.visible.len())
516 .find(|&position| !self.diff_view_of(position).changes.is_empty());
517 match next {
518 Some(position) => self.select(position),
519 None => self.message = Some("no more changes".to_string()),
520 }
521 }
522
523 fn previous_change(&mut self) {
525 let view = self.diff_view();
526 if let Some(&line) = view.changes.iter().rev().find(|&&line| line < self.cursor) {
527 self.jump_to(line);
528 return;
529 }
530 let previous = (0..self.selected)
531 .rev()
532 .find(|&position| !self.diff_view_of(position).changes.is_empty());
533 match previous {
534 Some(position) => {
535 self.select(position);
536 if let Some(&last) = self.diff_view().changes.last() {
537 self.jump_to(last);
538 }
539 }
540 None => self.message = Some("no earlier changes".to_string()),
541 }
542 }
543
544 fn jump_to(&mut self, line: usize) {
546 self.cursor = line;
547 self.scroll = line.saturating_sub(1);
548 }
549
550 fn page(&mut self, by: isize) {
551 self.scroll = self.scroll.saturating_add_signed(by);
552 self.move_cursor(self.cursor.saturating_add_signed(by));
553 }
554
555 fn find(&mut self, forward: bool) {
558 let Some(query) = self.search.clone() else {
559 self.message = Some("press / to search".to_string());
560 return;
561 };
562 let matches: Vec<(usize, usize)> = (0..self.visible.len())
563 .flat_map(|position| {
564 let view = self.diff_view_of(position);
565 view.texts
566 .iter()
567 .enumerate()
568 .filter(|(_, text)| {
569 text.as_deref()
570 .is_some_and(|t| !find_all(t, &query).is_empty())
571 })
572 .map(|(line, _)| (position, line))
573 .collect::<Vec<_>>()
574 })
575 .collect();
576 if matches.is_empty() {
577 self.message = Some(format!("no match for “{query}”"));
578 return;
579 }
580 let here = (self.selected, self.cursor);
581 let index = if forward {
582 matches.iter().position(|&m| m > here).unwrap_or(0)
583 } else {
584 matches
585 .iter()
586 .rposition(|&m| m < here)
587 .unwrap_or(matches.len() - 1)
588 };
589 let (position, line) = matches[index];
590 self.select(position);
591 self.focus = Focus::Diff;
592 self.cursor = line;
593 self.scroll = line.saturating_sub(self.diff_height / 2);
594 self.message = Some(format!(
595 "match {} of {} for “{query}”",
596 index + 1,
597 matches.len()
598 ));
599 }
600
601 fn open_note(&mut self) {
604 if self.focus == Focus::Files {
605 self.focus = Focus::Diff;
606 self.go_to_first_change();
607 }
608 let Some((path, anchor)) = self.cursor_anchor() else {
609 self.message = Some("put the cursor on a code line to leave a note".to_string());
610 return;
611 };
612 let existing = self.notes.find(&path, anchor.side, anchor.line);
613 self.input = Some(Input::Note(
614 existing.map(|note| note.text.clone()).unwrap_or_default(),
615 ));
616 }
617
618 fn save_note(&mut self, text: String) {
619 let Some((path, anchor)) = self.cursor_anchor() else {
620 return;
621 };
622 let note = Note {
623 path,
624 side: anchor.side,
625 line: anchor.line,
626 code: anchor.code,
627 text,
628 review: None,
629 };
630 if let Err(error) = self.notes.set(note) {
631 self.message = Some(format!("could not save the note: {error:#}"));
632 }
633 }
634
635 fn export_notes(&mut self) {
637 let count = self.notes.notes().len();
638 if count == 0 {
639 self.message = Some("no notes yet: press m on a diff line to add one".to_string());
640 return;
641 }
642 let prompt = self.notes.prompt();
643 let saved = self
644 .notes
645 .export_path()
646 .filter(|path| fs::write(path, &prompt).is_ok());
647 let copied = (self.clipboard)(&prompt);
648 let plural = if count == 1 { "" } else { "s" };
649 self.message = Some(match (copied, saved) {
650 (true, Some(path)) => format!(
651 "copied {count} note{plural} to the clipboard (also in {})",
652 path.display()
653 ),
654 (true, None) => format!("copied {count} note{plural} to the clipboard"),
655 (false, Some(path)) => format!("saved {count} note{plural} to {}", path.display()),
656 (false, None) => "could not copy or save the notes; run `declutter notes`".to_string(),
657 });
658 }
659
660 fn request_open(&mut self) {
663 let Some(file) = self.current() else {
664 return;
665 };
666 if file.status == ChangeStatus::Deleted {
667 self.message = Some("the file was deleted; there is nothing to open".to_string());
668 return;
669 }
670 let path = self.root.join(&file.path);
671 let view = self.diff_view();
672 let new_line = |anchor: &Option<Anchor>| {
673 anchor
674 .as_ref()
675 .filter(|anchor| anchor.side == NoteSide::New)
676 .map(|anchor| anchor.line)
677 };
678 let from = match self.focus {
679 Focus::Diff => self.cursor,
680 Focus::Files => view.changes.first().copied().unwrap_or(0),
681 }
682 .min(view.anchors.len());
683 let line = view.anchors[from..]
684 .iter()
685 .find_map(new_line)
686 .or_else(|| view.anchors[..from].iter().rev().find_map(new_line))
687 .unwrap_or(1);
688 self.open_request = Some((path, line));
689 }
690
691 fn cursor_anchor(&self) -> Option<(String, Anchor)> {
692 let path = self.current()?.path.clone();
693 let anchor = self
694 .diff_view()
695 .anchors
696 .into_iter()
697 .nth(self.cursor)
698 .flatten()?;
699 Some((path, anchor))
700 }
701
702 fn toggle_reviewed(&mut self) {
705 let Some(&index) = self.visible.get(self.selected) else {
706 return;
707 };
708 let reviewed = !self.store.is_reviewed(&self.files[index]);
709 if let Err(error) = self.store.set_reviewed(&self.files[index], reviewed) {
710 self.message = Some(format!("could not save review marks: {error:#}"));
711 }
712 if reviewed
713 && let Some(next) = (self.selected + 1..self.visible.len())
714 .find(|&position| !self.store.is_reviewed(&self.files[self.visible[position]]))
715 {
716 self.select(next);
717 }
718 }
719
720 pub fn review_progress(&self) -> (usize, usize) {
722 let reviewed = self
723 .visible
724 .iter()
725 .filter(|&&index| self.store.is_reviewed(&self.files[index]))
726 .count();
727 (reviewed, self.visible.len())
728 }
729
730 fn select(&mut self, index: usize) {
731 let index = index.min(self.visible.len().saturating_sub(1));
732 if index != self.selected {
733 self.selected = index;
734 self.go_to_first_change();
735 }
736 }
737
738 fn move_cursor(&mut self, cursor: usize) {
739 let last = self.diff_view().lines.len().saturating_sub(1);
740 self.cursor = cursor.min(last);
741 self.keep_cursor_visible();
742 }
743
744 fn keep_cursor_visible(&mut self) {
745 let height = self.diff_height.max(1);
746 if self.cursor < self.scroll {
747 self.scroll = self.cursor;
748 } else if self.cursor >= self.scroll + height {
749 self.scroll = self.cursor + 1 - height;
750 }
751 }
752
753 fn diff_view(&self) -> DiffView {
754 self.diff_view_of(self.selected)
755 }
756
757 fn diff_view_of(&self, position: usize) -> DiffView {
759 let message = |text: String| DiffView {
760 lines: vec![Line::from(text).dim()],
761 anchors: vec![None],
762 texts: vec![None],
763 changes: Vec::new(),
764 };
765 let Some(file) = self.visible.get(position).map(|&index| &self.files[index]) else {
766 return message(match (self.files.is_empty(), self.layers.tests) {
767 (true, _) => "No changes.".to_string(),
768 (false, LayerMode::Hidden) => {
769 "Every changed file is a test file. Press t to show tests.".to_string()
770 }
771 (false, _) => "No test files changed. Press T to show the other files.".to_string(),
772 });
773 };
774 let view = file.view(self.layers);
775 if file.detection == Detection::Binary {
776 return message("Binary file not shown.".to_string());
777 }
778 if view.hunks.is_empty() {
779 return message(format!(
780 "No visible changes: {}.",
781 empty_message(file, self.layers.into())
782 ));
783 }
784
785 let mut out = DiffView {
786 lines: Vec::new(),
787 anchors: Vec::new(),
788 texts: Vec::new(),
789 changes: Vec::new(),
790 };
791 let push = |out: &mut DiffView,
792 line: Line<'static>,
793 anchor: Option<Anchor>,
794 text: Option<String>| {
795 out.lines.push(line);
796 out.anchors.push(anchor);
797 out.texts.push(text);
798 };
799 for (hunk_index, hunk) in view.hunks.iter().enumerate() {
800 if hunk_index > 0 {
801 push(&mut out, Line::from(""), None, None);
802 }
803 push(
804 &mut out,
805 Line::from(hunk.header()).fg(Color::Cyan),
806 None,
807 None,
808 );
809 let mut first_change = None;
810 for (row_index, row) in hunk.rows.iter().enumerate() {
811 let moved = self.moves.get(&(position, hunk_index, row_index));
812 if let Some(moved) = moved
813 && moved.starts_block
814 {
815 let marker = moved.describe(&file.path);
816 let hint = if self.collapse_moves {
817 " (M to expand)"
818 } else {
819 ""
820 };
821 if self.collapse_moves && row.kind != RowKind::Context {
822 first_change.get_or_insert(out.lines.len());
823 }
824 push(
825 &mut out,
826 Line::from(format!(" {marker}{hint}"))
827 .fg(Color::Cyan)
828 .italic(),
829 None,
830 None,
831 );
832 }
833 if moved.is_some() && self.collapse_moves {
834 continue;
835 }
836 if row.kind != RowKind::Context {
837 first_change.get_or_insert(out.lines.len());
838 }
839 let anchor = anchor_of(row);
840 push(
841 &mut out,
842 row_line(
843 row,
844 moved.is_some(),
845 self.search.as_deref(),
846 &self.palette,
847 self.syntax,
848 ),
849 anchor.clone(),
850 Some(row.text.clone()),
851 );
852 if let Some(anchor) = anchor
853 && let Some(note) = self.notes.find(&file.path, anchor.side, anchor.line)
854 {
855 push(
856 &mut out,
857 Line::from(format!(" ✎ {}", note.text))
858 .fg(Color::Yellow)
859 .bold(),
860 Some(anchor),
861 None,
862 );
863 }
864 }
865 out.changes.extend(first_change);
866 }
867 out
868 }
869}
870
871fn find_all(text: &str, query: &str) -> Vec<Range<usize>> {
873 if query.is_empty() {
874 return Vec::new();
875 }
876 let sensitive = query.chars().any(char::is_uppercase);
877 let (haystack, needle) = if sensitive {
878 (text.to_string(), query.to_string())
879 } else {
880 (text.to_lowercase(), query.to_lowercase())
881 };
882 if haystack.len() != text.len() {
884 return text
885 .match_indices(query)
886 .map(|(at, found)| at..at + found.len())
887 .collect();
888 }
889 haystack
890 .match_indices(&needle)
891 .map(|(at, found)| at..at + found.len())
892 .collect()
893}
894
895fn anchor_of(row: &Row) -> Option<Anchor> {
896 let (side, line) = match row.kind {
897 RowKind::Removed => (NoteSide::Old, row.old_line?),
898 RowKind::Added | RowKind::Context => (NoteSide::New, row.new_line?),
899 };
900 Some(Anchor {
901 side,
902 line,
903 code: row.text.clone(),
904 })
905}
906
907fn row_line(
910 row: &Row,
911 moved: bool,
912 search: Option<&str>,
913 palette: &Palette,
914 syntax: bool,
915) -> Line<'static> {
916 let number = |n: Option<usize>| n.map_or(" ".to_string(), |n| format!("{n:>5}"));
917 let (sign, sign_colour, line, emphasis) = match (row.kind, moved) {
918 (RowKind::Context, _) => (' ', Color::Reset, Style::new(), Style::new()),
919 (RowKind::Removed, true) => (
921 '-',
922 palette.moved_sign,
923 palette.moved_away,
924 palette.moved_away,
925 ),
926 (RowKind::Added, true) => (
927 '+',
928 palette.moved_sign,
929 palette.moved_here,
930 palette.moved_here,
931 ),
932 (RowKind::Removed, false) => (
933 '-',
934 palette.removed_sign,
935 palette.removed,
936 palette.removed_emphasis,
937 ),
938 (RowKind::Added, false) => (
939 '+',
940 palette.added_sign,
941 palette.added,
942 palette.added_emphasis,
943 ),
944 };
945 let coloured = syntax && !row.syntax.is_empty();
946 let style = if coloured || row.kind == RowKind::Context {
948 line
949 } else {
950 line.fg(sign_colour)
951 };
952 let syntax_marks = row
953 .syntax
954 .iter()
955 .filter(|_| coloured)
956 .filter_map(|(range, class)| Some((range.clone(), palette.syntax(*class)?)));
957 let marks: Vec<(Range<usize>, Style)> = syntax_marks
958 .chain(row.emphasis.iter().map(|range| (range.clone(), emphasis)))
959 .chain(
960 search
961 .map(|query| find_all(&row.text, query))
962 .unwrap_or_default()
963 .into_iter()
964 .map(|range| (range, palette.search)),
965 )
966 .collect();
967 let mut spans = vec![
968 Span::styled(
969 format!("{} {} ", number(row.old_line), number(row.new_line)),
970 Style::new().add_modifier(Modifier::DIM),
971 ),
972 Span::styled(
973 format!("{sign} "),
974 line.fg(sign_colour).add_modifier(Modifier::BOLD),
975 ),
976 ];
977 spans.extend(styled_segments(&row.text, style, &marks));
978 Line::from(spans)
979}
980
981fn styled_segments(
984 text: &str,
985 base: Style,
986 layers: &[(Range<usize>, Style)],
987) -> Vec<Span<'static>> {
988 let mut cuts: Vec<usize> = layers
989 .iter()
990 .flat_map(|(range, _)| [range.start, range.end])
991 .filter(|&cut| cut < text.len() && text.is_char_boundary(cut))
992 .chain([0, text.len()])
993 .collect();
994 cuts.sort_unstable();
995 cuts.dedup();
996 cuts.windows(2)
997 .map(|pair| {
998 let (start, end) = (pair[0], pair[1]);
999 let style = layers
1000 .iter()
1001 .filter(|(range, _)| range.start <= start && end <= range.end)
1002 .fold(base, |style, (_, layer)| style.patch(*layer));
1003 Span::styled(text[start..end].replace('\t', " "), style)
1004 })
1005 .collect()
1006}
1007
1008pub fn draw(frame: &mut Frame, app: &mut App) {
1009 let [main, status, help] = Layout::vertical([
1010 Constraint::Min(3),
1011 Constraint::Length(1),
1012 Constraint::Length(1),
1013 ])
1014 .areas(frame.area());
1015 let [files_area, diff_area] =
1016 Layout::horizontal([Constraint::Percentage(30), Constraint::Min(20)]).areas(main);
1017
1018 let items: Vec<ListItem> = app
1019 .visible
1020 .iter()
1021 .map(|&index| {
1022 let file = &app.files[index];
1023 let visible = file.view(app.layers).hunks.len();
1024 let reviewed = app.store.is_reviewed(file);
1025 let mark = if reviewed { "✓ " } else { " " };
1026 let item = ListItem::new(format!("{mark}{} ({visible})", file_title(file)));
1027 if visible == 0 || reviewed {
1028 item.dim()
1029 } else {
1030 item
1031 }
1032 })
1033 .collect();
1034 let mut list_state =
1035 ListState::default().with_selected((!app.visible.is_empty()).then_some(app.selected));
1036 frame.render_stateful_widget(
1037 List::new(items)
1038 .block(pane(
1039 if app.title.is_empty() {
1040 " Files ".to_string()
1041 } else {
1042 format!(" {} ", app.title)
1043 },
1044 app.focus == Focus::Files,
1045 ))
1046 .highlight_style(Style::new().add_modifier(Modifier::REVERSED)),
1047 files_area,
1048 &mut list_state,
1049 );
1050
1051 app.diff_height = diff_area.height.saturating_sub(2) as usize;
1052 let lines = app.diff_view().lines;
1053 app.cursor = app.cursor.min(lines.len().saturating_sub(1));
1054 app.scroll = app.scroll.min(lines.len().saturating_sub(app.diff_height));
1055 app.keep_cursor_visible();
1056 let lines: Vec<Line> = lines
1057 .into_iter()
1058 .enumerate()
1059 .map(|(i, line)| {
1060 let on_cursor = app.focus == Focus::Diff && i == app.cursor;
1061 let gutter = if on_cursor {
1062 Span::styled("▌", Style::new().fg(Color::Cyan))
1063 } else {
1064 Span::raw(" ")
1065 };
1066 let mut spans = vec![gutter];
1067 spans.extend(line.spans);
1068 Line::from(spans).style(line.style)
1069 })
1070 .collect();
1071 let title = app
1072 .current()
1073 .map(|file| format!(" {} [{}] ", file.path, file.tag()))
1074 .unwrap_or_default();
1075 frame.render_widget(
1076 Paragraph::new(lines)
1077 .block(pane(title, app.focus == Focus::Diff))
1078 .scroll((app.scroll.min(u16::MAX as usize) as u16, 0)),
1079 diff_area,
1080 );
1081
1082 let (reviewed, listed) = app.review_progress();
1083 let summary = match (&app.input, &app.message) {
1084 (Some(Input::Note(text)), _) => {
1085 format!("note › {text}█ (Enter save · Esc cancel · empty removes)")
1086 }
1087 (Some(Input::Search(query)), _) => format!("/{query}█ (Enter search · Esc cancel)"),
1088 (None, Some(message)) => message.clone(),
1089 (None, None) => format!(
1090 "{} · {reviewed}/{listed} reviewed{}",
1091 Summary::new(&app.files, app.layers).status_line(app.layers),
1092 match app.notes.notes().len() {
1093 0 => String::new(),
1094 n => format!(" · {n} note{}", if n == 1 { "" } else { "s" }),
1095 } + &match (app.moved_blocks(), app.collapse_moves) {
1096 (0, _) => String::new(),
1097 (n, collapsed) => format!(
1098 " · {n} moved block{}{}",
1099 if n == 1 { "" } else { "s" },
1100 if collapsed { " (collapsed)" } else { "" }
1101 ),
1102 }
1103 ),
1104 };
1105 frame.render_widget(
1106 Paragraph::new(format!(" {summary}")).style(Style::new().add_modifier(Modifier::REVERSED)),
1107 status,
1108 );
1109 frame.render_widget(Paragraph::new(HELP).dim(), help);
1110 if app.show_help {
1111 draw_help(frame);
1112 }
1113}
1114
1115fn draw_help(frame: &mut Frame) {
1117 let columns: Vec<Vec<Line>> = KEYS
1118 .iter()
1119 .map(|groups| {
1120 let mut lines = Vec::new();
1121 for (group, keys) in groups.iter() {
1122 if !lines.is_empty() {
1123 lines.push(Line::from(""));
1124 }
1125 lines.push(Line::from(*group).bold().fg(Color::Cyan));
1126 for (key, action) in *keys {
1127 lines.push(Line::from(vec![
1128 Span::styled(format!(" {key:<12}"), Style::new().bold()),
1129 Span::raw(*action),
1130 ]));
1131 }
1132 }
1133 lines
1134 })
1135 .collect();
1136 let widths: Vec<u16> = columns
1137 .iter()
1138 .map(|lines| lines.iter().map(Line::width).max().unwrap_or(0) as u16)
1139 .collect();
1140 let rows = columns.iter().map(Vec::len).max().unwrap_or(0) as u16;
1141 let area = centered(frame.area(), widths.iter().sum::<u16>() + 7, rows + 2);
1143 frame.render_widget(Clear, area);
1144 let block = pane(" Keys · any key closes ".to_string(), true);
1145 let inner = block.inner(area);
1146 frame.render_widget(block, area);
1147 let [left, right] =
1148 Layout::horizontal([Constraint::Length(widths[0] + 4), Constraint::Min(0)]).areas(inner);
1149 let [left_column, right_column] = [left, right].map(|area| Rect {
1150 x: area.x + 1,
1151 width: area.width.saturating_sub(1),
1152 ..area
1153 });
1154 let mut columns = columns.into_iter();
1155 for area in [left_column, right_column] {
1156 frame.render_widget(Paragraph::new(columns.next().unwrap_or_default()), area);
1157 }
1158}
1159
1160fn centered(area: Rect, width: u16, height: u16) -> Rect {
1161 let [area] = Layout::horizontal([Constraint::Length(width.min(area.width))])
1162 .flex(Flex::Center)
1163 .areas(area);
1164 let [area] = Layout::vertical([Constraint::Length(height.min(area.height))])
1165 .flex(Flex::Center)
1166 .areas(area);
1167 area
1168}
1169
1170fn pane(title: String, focused: bool) -> Block<'static> {
1172 let block = Block::bordered().title(title);
1173 if focused {
1174 block
1175 .border_style(Style::new().fg(Color::Cyan))
1176 .title_style(Style::new().bold())
1177 } else {
1178 block.border_style(Style::new().add_modifier(Modifier::DIM))
1179 }
1180}
1181
1182fn system_clipboard(text: &str) -> bool {
1184 let tools: [(&str, &[&str]); 3] = [
1185 ("pbcopy", &[]),
1186 ("wl-copy", &[]),
1187 ("xclip", &["-selection", "clipboard"]),
1188 ];
1189 tools.iter().any(|(program, args)| {
1190 let Ok(mut child) = Command::new(program)
1191 .args(*args)
1192 .stdin(Stdio::piped())
1193 .stdout(Stdio::null())
1194 .stderr(Stdio::null())
1195 .spawn()
1196 else {
1197 return false;
1198 };
1199 let written = child
1200 .stdin
1201 .take()
1202 .is_some_and(|mut stdin| stdin.write_all(text.as_bytes()).is_ok());
1203 child.wait().is_ok_and(|status| status.success()) && written
1204 })
1205}
1206
1207pub fn run(
1208 files: Vec<FileReview>,
1209 layers: Layers,
1210 store: ReviewStore,
1211 notes: NoteStore,
1212 root: PathBuf,
1213 title: String,
1214 syntax: bool,
1215) -> Result<NoteStore> {
1216 let mut app = App::with_stores(files, layers, store, notes);
1217 app.root = root;
1218 app.title = title;
1219 app.palette = Palette::detect();
1220 app.syntax = syntax;
1221 ratatui::run(|terminal: &mut DefaultTerminal| -> Result<()> {
1222 while !app.quit {
1223 terminal.draw(|frame| draw(frame, &mut app))?;
1224 if let Event::Key(key) = event::read()?
1225 && key.kind == KeyEventKind::Press
1226 {
1227 app.handle_key(key);
1228 }
1229 if let Some((path, line)) = app.open_request.take() {
1230 app.message = open_in_editor(terminal, &path, line).err();
1231 }
1232 }
1233 Ok(())
1234 })?;
1235 Ok(app.notes)
1236}
1237
1238fn open_in_editor(
1241 terminal: &mut DefaultTerminal,
1242 path: &std::path::Path,
1243 line: usize,
1244) -> Result<(), String> {
1245 let editor = std::env::var("VISUAL")
1246 .or_else(|_| std::env::var("EDITOR"))
1247 .ok()
1248 .filter(|value| !value.trim().is_empty());
1249 let command = editor_command(editor.as_deref(), path, line);
1250 let mut process = Command::new(&command.program);
1251 process.args(&command.args);
1252 let failed = |error: std::io::Error| format!("could not start `{}`: {error}", command.program);
1253 if command.in_terminal {
1254 ratatui::restore();
1255 let status = process.status();
1256 *terminal = ratatui::init();
1257 status.map_err(failed)?;
1258 } else {
1259 process
1260 .stdin(Stdio::null())
1261 .stdout(Stdio::null())
1262 .stderr(Stdio::null())
1263 .spawn()
1264 .map_err(failed)?;
1265 }
1266 Ok(())
1267}