Skip to main content

declutter/
tui.rs

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
25/// Key help, most-used first: a narrow terminal cuts the end off. `?` shows them all.
26const HELP: &str = " ↑↓ move  ←→ pane  ]/[ change  }/{ file  / search  r reviewed  m note  c t i l f layers  a all  ? help  q quit";
27
28/// A titled group of (keys, action) pairs on the `?` screen.
29type KeyGroup = (&'static str, &'static [(&'static str, &'static str)]);
30
31/// Every key, for the `?` screen, as two columns of groups.
32const 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/// The pane the arrow keys act on.
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub enum Focus {
85    Files,
86    Diff,
87}
88
89/// A one-line prompt in the status bar.
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub enum Input {
92    Note(String),
93    Search(String),
94}
95
96/// The layers, as keys for remembering what a layer was before *only*.
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
98enum Layer {
99    Comments,
100    Tests,
101    Imports,
102    Logging,
103    Formatting,
104}
105
106/// The line of a file a diff line stands for, so notes can be attached to it.
107#[derive(Debug, Clone, PartialEq, Eq)]
108struct Anchor {
109    side: NoteSide,
110    line: usize,
111    code: String,
112}
113
114/// The diff pane's content for one file.
115struct DiffView {
116    lines: Vec<Line<'static>>,
117    /// For each line, the file line it stands for.
118    anchors: Vec<Option<Anchor>>,
119    /// For each line that shows code, that code, for search.
120    texts: Vec<Option<String>>,
121    /// The first changed line of each hunk.
122    changes: Vec<usize>,
123}
124
125/// Where the cursor is in file terms, so it can be found again after the view changes.
126#[derive(Debug, Clone, Copy)]
127struct Place {
128    side: NoteSide,
129    line: usize,
130    /// The cursor's row on screen, kept so the view does not jump.
131    row: usize,
132}
133
134/// Copies text to the system clipboard; returns whether it worked.
135pub type Clipboard = fn(&str) -> bool;
136
137pub struct App {
138    pub files: Vec<FileReview>,
139    pub layers: Layers,
140    /// Indices into `files` of the files the test layer currently lists.
141    visible: Vec<usize>,
142    /// Position in the visible list.
143    pub selected: usize,
144    /// Line of the diff pane the cursor is on.
145    pub cursor: usize,
146    pub scroll: usize,
147    pub focus: Focus,
148    pub store: ReviewStore,
149    pub notes: NoteStore,
150    /// The prompt being typed, if one is open.
151    pub input: Option<Input>,
152    /// The last search, highlighted in the diff until cleared with Esc.
153    pub search: Option<String>,
154    pub show_help: bool,
155    /// A one-off message for the status bar, cleared by the next key.
156    pub message: Option<String>,
157    pub clipboard: Clipboard,
158    /// Moved blocks among the listed files, keyed by (visible position, hunk, row).
159    moves: Moves,
160    /// Show each moved block as its one-line marker only.
161    pub collapse_moves: bool,
162    /// What each layer was before it was switched to *only*, to switch back to.
163    before_only: HashMap<Layer, LayerMode>,
164    /// The layers as they were when `a` showed everything, for `a` to bring back.
165    saved_layers: Option<Layers>,
166    /// The working tree's top-level directory; changed paths are relative to it.
167    pub root: PathBuf,
168    /// A file and line to open in the editor, for the run loop to carry out.
169    pub open_request: Option<(PathBuf, usize)>,
170    /// What is being reviewed, shown over the file list: "origin/main...feature", "PR 7".
171    pub title: String,
172    pub palette: Palette,
173    /// Colour code by syntax; off leaves only the diff's own colouring.
174    pub syntax: bool,
175    pub quit: bool,
176    /// Height of the diff pane at the last draw, for paging and keeping the cursor visible.
177    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    /// The file under the cursor, if any file is listed.
230    pub fn current(&self) -> Option<&FileReview> {
231        self.visible
232            .get(self.selected)
233            .map(|&index| &self.files[index])
234    }
235
236    /// Rebuilds the visible list after the test layer changed, keeping the selection on
237    /// the same file when it is still listed.
238    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    /// Puts the cursor on the current file's first change.
253    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    /// Re-finds moved blocks: what counts as moved depends on the layers and the files listed.
259    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    /// Number of moved blocks among the listed files.
265    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    /// Lower-case layer key: hidden ↔ shown; from *only*, back to shown.
393    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    /// Shifted layer key: the layer alone; pressed again, back to what it was.
404    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    /// `a`: with any layer filtering, shows everything and remembers how it was; pressed
418    /// again, puts it back. With nothing to put back, hides every layer.
419    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    /// The selected file and the cursor's place in it, taken before a layer changes.
447    fn where_am_i(&self) -> (Option<usize>, Option<Place>) {
448        (self.visible.get(self.selected).copied(), self.place())
449    }
450
451    /// Re-derives everything a layer change affects, keeping the cursor on the same line
452    /// of the same file where that line is still shown.
453    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    /// The cursor's position in file terms: its own line, or the nearest line above it.
464    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    /// Puts the cursor back on `place` — the same line if still shown, otherwise the
479    /// closest one — at the same height on screen.
480    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    /// Moves the cursor to the next change, on into the next file that has one.
505    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    /// Moves the cursor to the previous change, back into the previous file with one.
524    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    /// Moves the cursor to `line`, showing the line above it for context.
545    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    /// Jumps to the next (or previous) line matching the search, across the listed
556    /// files, wrapping around at the ends.
557    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    /// Opens the note editor on the cursor's line, pre-filled with any note already
602    /// there. From the file list it moves into the diff first.
603    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    /// Copies all notes as one prompt to the clipboard and saves it next to the notes.
636    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    /// Asks for the current file to be opened at the cursor's line, or, from the file
661    /// list, at its first change. A removed line opens at the nearest line still there.
662    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    /// Marks the current file reviewed and moves on to the next unreviewed one, or
703    /// clears the mark if it was already set.
704    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    /// Reviewed and total counts over the listed files.
721    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    /// The diff pane's content for the file at `position` in the visible list.
758    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
871/// Byte ranges of `query` in `text`; case-insensitive unless the query has a capital.
872fn 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    // Lower-casing can change byte lengths outside ASCII; fall back to exact matching then.
883    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
907/// One diff row: line numbers, sign, and the code coloured by syntax (when `syntax` is
908/// on and the file has a grammar), changed words and search matches.
909fn 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        // Moved lines get their own tint: nothing about them changed but their place.
920        (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    // Without syntax colours, colour the changed text itself so the change stands out.
947    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
981/// Splits `text` into spans, each styled by `base` patched with every layer covering it.
982/// Later layers win where they overlap.
983fn 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
1115/// The `?` screen: every key, grouped in two columns, in a box over the viewer.
1116fn 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    // Both columns, a three-column gap, and the border with a column of padding.
1142    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
1170/// A bordered pane; the focused one is drawn in colour so it is clear where the arrows go.
1171fn 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
1182/// Pipes text into the platform's clipboard tool, whichever is installed.
1183fn 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
1238/// Opens `path` at `line` in `$VISUAL` / `$EDITOR`. A terminal editor takes over the
1239/// screen until it exits; anything else is launched alongside the viewer.
1240fn 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}