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                ("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/// The pane the arrow keys act on.
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum Focus {
87    Files,
88    Diff,
89}
90
91/// Text being typed: a note (on the cursor's line, or `General`ly on the change as a
92/// whole), or a search in the status bar.
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub enum Input {
95    Note(String),
96    General(String),
97    Search(String),
98}
99
100/// The layers, as keys for remembering what a layer was before *only*.
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
102enum Layer {
103    Comments,
104    Tests,
105    Imports,
106    Logging,
107    Formatting,
108}
109
110/// The line of a file a diff line stands for, so notes can be attached to it.
111#[derive(Debug, Clone, PartialEq, Eq)]
112struct Anchor {
113    side: NoteSide,
114    line: usize,
115    code: String,
116}
117
118/// The diff pane's content for one file.
119struct DiffView {
120    lines: Vec<Line<'static>>,
121    /// For each line, the file line it stands for.
122    anchors: Vec<Option<Anchor>>,
123    /// For each line that shows code, that code, for search.
124    texts: Vec<Option<String>>,
125    /// The first changed line of each hunk.
126    changes: Vec<usize>,
127}
128
129/// Where the cursor is in file terms, so it can be found again after the view changes.
130#[derive(Debug, Clone, Copy)]
131struct Place {
132    side: NoteSide,
133    line: usize,
134    /// The cursor's row on screen, kept so the view does not jump.
135    row: usize,
136}
137
138/// Copies text to the system clipboard; returns whether it worked.
139pub type Clipboard = fn(&str) -> bool;
140
141pub struct App {
142    pub files: Vec<FileReview>,
143    pub layers: Layers,
144    /// Indices into `files` of the files the test layer currently lists.
145    visible: Vec<usize>,
146    /// Position in the visible list.
147    pub selected: usize,
148    /// Line of the diff pane the cursor is on.
149    pub cursor: usize,
150    pub scroll: usize,
151    pub focus: Focus,
152    pub store: ReviewStore,
153    pub notes: NoteStore,
154    /// The prompt being typed, if one is open.
155    pub input: Option<Input>,
156    /// Where typing goes in the input, in characters from its start.
157    input_cursor: usize,
158    /// The last search, highlighted in the diff until cleared with Esc.
159    pub search: Option<String>,
160    pub show_help: bool,
161    /// A one-off message for the status bar, cleared by the next key.
162    pub message: Option<String>,
163    pub clipboard: Clipboard,
164    /// Moved blocks among the listed files, keyed by (visible position, hunk, row).
165    moves: Moves,
166    /// Show each moved block as its one-line marker only.
167    pub collapse_moves: bool,
168    /// What each layer was before it was switched to *only*, to switch back to.
169    before_only: HashMap<Layer, LayerMode>,
170    /// The layers as they were when `a` showed everything, for `a` to bring back.
171    saved_layers: Option<Layers>,
172    /// The working tree's top-level directory; changed paths are relative to it.
173    pub root: PathBuf,
174    /// A file and line to open in the editor, for the run loop to carry out.
175    pub open_request: Option<(PathBuf, usize)>,
176    /// What is being reviewed, shown over the file list: "origin/main...feature", "PR 7".
177    pub title: String,
178    pub palette: Palette,
179    /// Colour code by syntax; off leaves only the diff's own colouring.
180    pub syntax: bool,
181    pub quit: bool,
182    /// Height of the diff pane at the last draw, for paging and keeping the cursor visible.
183    diff_height: usize,
184    /// Width of the diff pane's text at the last draw, for wrapping notes.
185    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    /// The file under the cursor, if any file is listed.
240    pub fn current(&self) -> Option<&FileReview> {
241        self.visible
242            .get(self.selected)
243            .map(|&index| &self.files[index])
244    }
245
246    /// Rebuilds the visible list after the test layer changed, keeping the selection on
247    /// the same file when it is still listed.
248    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    /// Puts the cursor on the current file's first change.
263    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    /// Re-finds moved blocks: what counts as moved depends on the layers and the files listed.
269    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    /// Number of moved blocks among the listed files.
275    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            // Terminals send Enter for Shift+Enter; Alt+Enter is the one that comes through.
384            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    /// Opens `input` with the cursor at the end of its text.
424    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    /// Lower-case layer key: hidden ↔ shown; from *only*, back to shown.
442    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    /// Shifted layer key: the layer alone; pressed again, back to what it was.
453    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    /// `a`: with any layer filtering, shows everything and remembers how it was; pressed
467    /// again, puts it back. With nothing to put back, hides every layer.
468    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    /// The selected file and the cursor's place in it, taken before a layer changes.
496    fn where_am_i(&self) -> (Option<usize>, Option<Place>) {
497        (self.visible.get(self.selected).copied(), self.place())
498    }
499
500    /// Re-derives everything a layer change affects, keeping the cursor on the same line
501    /// of the same file where that line is still shown.
502    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    /// The cursor's position in file terms: its own line, or the nearest line above it.
513    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    /// Puts the cursor back on `place` — the same line if still shown, otherwise the
528    /// closest one — at the same height on screen.
529    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    /// Moves the cursor to the next change, on into the next file that has one.
554    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    /// Moves the cursor to the previous change, back into the previous file with one.
573    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    /// Moves the cursor to `line`, showing the line above it for context.
594    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    /// Jumps to the next (or previous) line matching the search, across the listed
605    /// files, wrapping around at the ends.
606    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    /// Opens the note editor on the cursor's line, pre-filled with any note already
651    /// there. From the file list it moves into the diff first.
652    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    /// Opens the editor on the note about the change as a whole.
668    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    /// Copies all notes as one prompt to the clipboard and saves it next to the notes.
702    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    /// Asks for the current file to be opened at the cursor's line, or, from the file
732    /// list, at its first change. A removed line opens at the nearest line still there.
733    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    /// Marks the current file reviewed and moves on to the next unreviewed one, or
774    /// clears the mark if it was already set.
775    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    /// Reviewed and total counts over the listed files.
792    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    /// The diff pane's content for the file at `position` in the visible list.
829    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
937/// How far a note is indented under its line.
938const NOTE_INDENT: usize = 14;
939
940/// A note under its line: its text wrapped to the pane, a draft marked as one.
941fn 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
960/// `text` broken into rows of at most `width` characters, at spaces where it can be,
961/// keeping its own line breaks.
962fn 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
987/// Byte ranges of `query` in `text`; case-insensitive unless the query has a capital.
988fn 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    // Lower-casing can change byte lengths outside ASCII; fall back to exact matching then.
999    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
1023/// One diff row: line numbers, sign, and the code coloured by syntax (when `syntax` is
1024/// on and the file has a grammar), changed words and search matches.
1025fn 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        // Moved lines get their own tint: nothing about them changed but their place.
1036        (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    // Without syntax colours, colour the changed text itself so the change stands out.
1063    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
1097/// Splits `text` into spans, each styled by `base` patched with every layer covering it.
1098/// Later layers win where they overlap.
1099fn 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    // Inside the borders, less the cursor's gutter.
1179    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
1278/// The `?` screen: every key, grouped in two columns, in a box over the viewer.
1279fn 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    // Both columns, a three-column gap, and the border with a column of padding.
1305    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
1323/// `text` with a block cursor drawn `cursor` characters in.
1324fn 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
1332/// " · 3 notes (2 drafts)", and a pointer to a draft note on the whole change.
1333fn 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
1358/// A bordered pane; the focused one is drawn in colour so it is clear where the arrows go.
1359fn 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
1370/// Pipes text into the platform's clipboard tool, whichever is installed.
1371fn 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
1426/// Opens `path` at `line` in `$VISUAL` / `$EDITOR`. A terminal editor takes over the
1427/// screen until it exits; anything else is launched alongside the viewer.
1428fn 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}