use std::collections::HashMap;
use std::fs;
use std::io::Write;
use std::ops::Range;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use anyhow::Result;
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::layout::{Constraint, Flex, Layout, Rect};
use ratatui::style::{Color, Modifier, Style, Stylize};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Clear, List, ListItem, ListState, Paragraph};
use ratatui::{DefaultTerminal, Frame};
use crate::diff::{Row, RowKind};
use crate::editor::editor_command;
use crate::moves::{Direction, Moves, detect};
use crate::palette::Palette;
use crate::project::LayerMode;
use crate::render::{empty_message, file_title};
use crate::review::{ChangeStatus, Detection, FileReview, Layers, Summary};
use crate::store::{Note, NoteSide, NoteStore, ReviewStore};
const HELP: &str = " ↑↓ move ←→ pane ]/[ change }/{ file / search r reviewed m note c t i l f layers a all ? help q quit";
type KeyGroup = (&'static str, &'static [(&'static str, &'static str)]);
const KEYS: [&[KeyGroup]; 2] = [
&[
(
"Move",
&[
("↑ ↓ j k", "move: files, or the diff cursor"),
("← → Tab ⏎", "switch pane"),
("] [", "next / previous change (on across files)"),
("} {", "next / previous file"),
("Space b", "page down / up"),
("d u", "half a page down / up"),
("g G", "top / bottom (in files: first / last)"),
("/ n N", "search; next / previous match"),
],
),
(
"Other",
&[
("Esc", "back: leave the diff, cancel, clear search"),
("?", "this help"),
("q", "quit"),
],
),
],
&[
(
"Review",
&[
("r", "mark reviewed, go to the next file"),
("m", "note the line under the cursor"),
("E", "copy all notes as one prompt"),
("o", "open in $EDITOR at the line"),
],
),
(
"Layers: key hides / shows, Shift = only",
&[
("c C", "comments"),
("t T", "tests"),
("i I", "imports"),
("l L", "logging"),
("f F", "formatting-only changes"),
("a", "all filters off / back on"),
("M", "collapse moved blocks"),
("s", "syntax colouring on / off"),
],
),
],
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Focus {
Files,
Diff,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Input {
Note(String),
Search(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum Layer {
Comments,
Tests,
Imports,
Logging,
Formatting,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Anchor {
side: NoteSide,
line: usize,
code: String,
}
struct DiffView {
lines: Vec<Line<'static>>,
anchors: Vec<Option<Anchor>>,
texts: Vec<Option<String>>,
changes: Vec<usize>,
}
#[derive(Debug, Clone, Copy)]
struct Place {
side: NoteSide,
line: usize,
row: usize,
}
pub type Clipboard = fn(&str) -> bool;
pub struct App {
pub files: Vec<FileReview>,
pub layers: Layers,
visible: Vec<usize>,
pub selected: usize,
pub cursor: usize,
pub scroll: usize,
pub focus: Focus,
pub store: ReviewStore,
pub notes: NoteStore,
pub input: Option<Input>,
pub search: Option<String>,
pub show_help: bool,
pub message: Option<String>,
pub clipboard: Clipboard,
moves: Moves,
pub collapse_moves: bool,
before_only: HashMap<Layer, LayerMode>,
saved_layers: Option<Layers>,
pub root: PathBuf,
pub open_request: Option<(PathBuf, usize)>,
pub title: String,
pub palette: Palette,
pub syntax: bool,
pub quit: bool,
diff_height: usize,
}
impl App {
pub fn new(files: Vec<FileReview>, layers: Layers) -> App {
App::with_stores(
files,
layers,
ReviewStore::in_memory(),
NoteStore::in_memory(),
)
}
pub fn with_stores(
files: Vec<FileReview>,
layers: Layers,
store: ReviewStore,
notes: NoteStore,
) -> App {
let mut app = App {
files,
layers,
visible: Vec::new(),
selected: 0,
cursor: 0,
scroll: 0,
focus: Focus::Files,
store,
notes,
input: None,
search: None,
show_help: false,
message: None,
clipboard: system_clipboard,
moves: Moves::new(),
collapse_moves: false,
before_only: HashMap::new(),
saved_layers: None,
root: PathBuf::from("."),
open_request: None,
title: String::new(),
palette: Palette::TRUE_COLOR,
syntax: true,
quit: false,
diff_height: 20,
};
app.refilter();
app.refresh_moves();
app.go_to_first_change();
app
}
pub fn current(&self) -> Option<&FileReview> {
self.visible
.get(self.selected)
.map(|&index| &self.files[index])
}
fn refilter(&mut self) {
let current = self.visible.get(self.selected).copied();
self.visible = (0..self.files.len())
.filter(|&index| self.files[index].is_visible(self.layers.tests))
.collect();
match current.and_then(|index| self.visible.iter().position(|&i| i == index)) {
Some(position) => self.selected = position,
None => {
self.selected = self.selected.min(self.visible.len().saturating_sub(1));
self.go_to_first_change();
}
}
}
fn go_to_first_change(&mut self) {
self.cursor = self.diff_view().changes.first().copied().unwrap_or(0);
self.scroll = self.cursor.saturating_sub(1);
}
fn refresh_moves(&mut self) {
let listed: Vec<&FileReview> = self.visible.iter().map(|&i| &self.files[i]).collect();
self.moves = detect(&listed, self.layers.into());
}
pub fn moved_blocks(&self) -> usize {
self.moves
.values()
.filter(|moved| moved.starts_block && moved.direction == Direction::To)
.count()
}
pub fn handle_key(&mut self, key: KeyEvent) {
if self.show_help {
self.show_help = false;
return;
}
if self.input.is_some() {
self.handle_input_key(key);
return;
}
self.message = None;
let half_page = (self.diff_height / 2).max(1);
let page = self.diff_height.saturating_sub(1).max(1);
match key.code {
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => self.quit = true,
KeyCode::Char('q') => self.quit = true,
KeyCode::Char('?') => self.show_help = true,
KeyCode::Esc => match self.focus {
Focus::Diff => self.focus = Focus::Files,
Focus::Files => self.search = None,
},
KeyCode::Right | KeyCode::Enter => self.focus = Focus::Diff,
KeyCode::Left => self.focus = Focus::Files,
KeyCode::Tab | KeyCode::BackTab => {
self.focus = match self.focus {
Focus::Files => Focus::Diff,
Focus::Diff => Focus::Files,
}
}
KeyCode::Down | KeyCode::Char('j') => match self.focus {
Focus::Files => self.select(self.selected.saturating_add(1)),
Focus::Diff => self.move_cursor(self.cursor.saturating_add(1)),
},
KeyCode::Up | KeyCode::Char('k') => match self.focus {
Focus::Files => self.select(self.selected.saturating_sub(1)),
Focus::Diff => self.move_cursor(self.cursor.saturating_sub(1)),
},
KeyCode::Char(']') => self.next_change(),
KeyCode::Char('[') => self.previous_change(),
KeyCode::Char('}') => self.select(self.selected.saturating_add(1)),
KeyCode::Char('{') => self.select(self.selected.saturating_sub(1)),
KeyCode::Char(' ') | KeyCode::PageDown => self.page(page as isize),
KeyCode::Char('b') | KeyCode::PageUp => self.page(-(page as isize)),
KeyCode::Char('d') => self.page(half_page as isize),
KeyCode::Char('u') => self.page(-(half_page as isize)),
KeyCode::Char('g') | KeyCode::Home => match self.focus {
Focus::Files => self.select(0),
Focus::Diff => self.move_cursor(0),
},
KeyCode::Char('G') | KeyCode::End => match self.focus {
Focus::Files => self.select(usize::MAX),
Focus::Diff => self.move_cursor(usize::MAX),
},
KeyCode::Char('/') => self.input = Some(Input::Search(String::new())),
KeyCode::Char('n') => self.find(true),
KeyCode::Char('N') => self.find(false),
KeyCode::Char('r') => self.toggle_reviewed(),
KeyCode::Char('m') => self.open_note(),
KeyCode::Char('E') => self.export_notes(),
KeyCode::Char('o') => self.request_open(),
KeyCode::Char('c') => self.toggle_hidden(Layer::Comments),
KeyCode::Char('C') => self.toggle_only(Layer::Comments),
KeyCode::Char('t') => self.toggle_hidden(Layer::Tests),
KeyCode::Char('T') => self.toggle_only(Layer::Tests),
KeyCode::Char('i') => self.toggle_hidden(Layer::Imports),
KeyCode::Char('I') => self.toggle_only(Layer::Imports),
KeyCode::Char('l') => self.toggle_hidden(Layer::Logging),
KeyCode::Char('L') => self.toggle_only(Layer::Logging),
KeyCode::Char('f') => self.toggle_hidden(Layer::Formatting),
KeyCode::Char('F') => self.toggle_only(Layer::Formatting),
KeyCode::Char('s') => self.syntax = !self.syntax,
KeyCode::Char('a') => self.toggle_all(),
KeyCode::Char('M') => {
let place = self.place();
self.collapse_moves = !self.collapse_moves;
self.restore(place);
}
_ => {}
}
}
fn handle_input_key(&mut self, key: KeyEvent) {
let Some(input) = self.input.as_mut() else {
return;
};
let text = match input {
Input::Note(text) | Input::Search(text) => text,
};
match key.code {
KeyCode::Esc => self.input = None,
KeyCode::Enter => match self.input.take() {
Some(Input::Note(text)) => self.save_note(text),
Some(Input::Search(query)) => {
if !query.is_empty() {
self.search = Some(query);
}
self.find(true);
}
None => {}
},
KeyCode::Backspace => {
text.pop();
}
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => text.push(c),
_ => {}
}
}
fn layer_mut(&mut self, layer: Layer) -> &mut LayerMode {
match layer {
Layer::Comments => &mut self.layers.comments,
Layer::Tests => &mut self.layers.tests,
Layer::Imports => &mut self.layers.imports,
Layer::Logging => &mut self.layers.logging,
Layer::Formatting => &mut self.layers.formatting,
}
}
fn toggle_hidden(&mut self, layer: Layer) {
let before = self.where_am_i();
let mode = self.layer_mut(layer);
*mode = match *mode {
LayerMode::Shown => LayerMode::Hidden,
LayerMode::Hidden | LayerMode::Only => LayerMode::Shown,
};
self.layers_changed(layer, before);
}
fn toggle_only(&mut self, layer: Layer) {
let before = self.where_am_i();
let current = *self.layer_mut(layer);
let next = if current == LayerMode::Only {
self.before_only.remove(&layer).unwrap_or(LayerMode::Shown)
} else {
self.before_only.insert(layer, current);
LayerMode::Only
};
*self.layer_mut(layer) = next;
self.layers_changed(layer, before);
}
fn toggle_all(&mut self) {
let before = self.where_am_i();
let all = |mode| Layers {
comments: mode,
tests: mode,
imports: mode,
logging: mode,
formatting: mode,
};
let (next, message) = if self.layers != all(LayerMode::Shown) {
self.saved_layers = Some(self.layers);
(
all(LayerMode::Shown),
"all layers shown · a puts the filters back",
)
} else {
match self.saved_layers.take() {
Some(saved) => (saved, "filters back on"),
None => (all(LayerMode::Hidden), "every layer hidden"),
}
};
self.layers = next;
self.before_only.clear();
self.layers_changed(Layer::Tests, before);
self.message = Some(message.to_string());
}
fn where_am_i(&self) -> (Option<usize>, Option<Place>) {
(self.visible.get(self.selected).copied(), self.place())
}
fn layers_changed(&mut self, layer: Layer, (file, place): (Option<usize>, Option<Place>)) {
if layer == Layer::Tests {
self.refilter();
}
self.refresh_moves();
if self.visible.get(self.selected).copied() == file {
self.restore(place);
}
}
fn place(&self) -> Option<Place> {
let view = self.diff_view();
let at = self.cursor.min(view.anchors.len().saturating_sub(1));
let anchor = (0..=at)
.rev()
.chain(at + 1..view.anchors.len())
.find_map(|i| view.anchors.get(i)?.as_ref())?;
Some(Place {
side: anchor.side,
line: anchor.line,
row: self.cursor.saturating_sub(self.scroll),
})
}
fn restore(&mut self, place: Option<Place>) {
let Some(place) = place else {
self.go_to_first_change();
return;
};
let view = self.diff_view();
let closest = view
.anchors
.iter()
.enumerate()
.filter_map(|(i, anchor)| Some((i, anchor.as_ref()?)))
.min_by_key(|(_, anchor)| {
let other_side = usize::from(anchor.side != place.side);
(anchor.line.abs_diff(place.line), other_side)
});
match closest {
Some((index, _)) => {
self.cursor = index;
self.scroll = index.saturating_sub(place.row);
}
None => self.go_to_first_change(),
}
}
fn next_change(&mut self) {
if let Some(&line) = self
.diff_view()
.changes
.iter()
.find(|&&line| line > self.cursor)
{
self.jump_to(line);
return;
}
let next = (self.selected + 1..self.visible.len())
.find(|&position| !self.diff_view_of(position).changes.is_empty());
match next {
Some(position) => self.select(position),
None => self.message = Some("no more changes".to_string()),
}
}
fn previous_change(&mut self) {
let view = self.diff_view();
if let Some(&line) = view.changes.iter().rev().find(|&&line| line < self.cursor) {
self.jump_to(line);
return;
}
let previous = (0..self.selected)
.rev()
.find(|&position| !self.diff_view_of(position).changes.is_empty());
match previous {
Some(position) => {
self.select(position);
if let Some(&last) = self.diff_view().changes.last() {
self.jump_to(last);
}
}
None => self.message = Some("no earlier changes".to_string()),
}
}
fn jump_to(&mut self, line: usize) {
self.cursor = line;
self.scroll = line.saturating_sub(1);
}
fn page(&mut self, by: isize) {
self.scroll = self.scroll.saturating_add_signed(by);
self.move_cursor(self.cursor.saturating_add_signed(by));
}
fn find(&mut self, forward: bool) {
let Some(query) = self.search.clone() else {
self.message = Some("press / to search".to_string());
return;
};
let matches: Vec<(usize, usize)> = (0..self.visible.len())
.flat_map(|position| {
let view = self.diff_view_of(position);
view.texts
.iter()
.enumerate()
.filter(|(_, text)| {
text.as_deref()
.is_some_and(|t| !find_all(t, &query).is_empty())
})
.map(|(line, _)| (position, line))
.collect::<Vec<_>>()
})
.collect();
if matches.is_empty() {
self.message = Some(format!("no match for “{query}”"));
return;
}
let here = (self.selected, self.cursor);
let index = if forward {
matches.iter().position(|&m| m > here).unwrap_or(0)
} else {
matches
.iter()
.rposition(|&m| m < here)
.unwrap_or(matches.len() - 1)
};
let (position, line) = matches[index];
self.select(position);
self.focus = Focus::Diff;
self.cursor = line;
self.scroll = line.saturating_sub(self.diff_height / 2);
self.message = Some(format!(
"match {} of {} for “{query}”",
index + 1,
matches.len()
));
}
fn open_note(&mut self) {
if self.focus == Focus::Files {
self.focus = Focus::Diff;
self.go_to_first_change();
}
let Some((path, anchor)) = self.cursor_anchor() else {
self.message = Some("put the cursor on a code line to leave a note".to_string());
return;
};
let existing = self.notes.find(&path, anchor.side, anchor.line);
self.input = Some(Input::Note(
existing.map(|note| note.text.clone()).unwrap_or_default(),
));
}
fn save_note(&mut self, text: String) {
let Some((path, anchor)) = self.cursor_anchor() else {
return;
};
let note = Note {
path,
side: anchor.side,
line: anchor.line,
code: anchor.code,
text,
review: None,
};
if let Err(error) = self.notes.set(note) {
self.message = Some(format!("could not save the note: {error:#}"));
}
}
fn export_notes(&mut self) {
let count = self.notes.notes().len();
if count == 0 {
self.message = Some("no notes yet: press m on a diff line to add one".to_string());
return;
}
let prompt = self.notes.prompt();
let saved = self
.notes
.export_path()
.filter(|path| fs::write(path, &prompt).is_ok());
let copied = (self.clipboard)(&prompt);
let plural = if count == 1 { "" } else { "s" };
self.message = Some(match (copied, saved) {
(true, Some(path)) => format!(
"copied {count} note{plural} to the clipboard (also in {})",
path.display()
),
(true, None) => format!("copied {count} note{plural} to the clipboard"),
(false, Some(path)) => format!("saved {count} note{plural} to {}", path.display()),
(false, None) => "could not copy or save the notes; run `declutter notes`".to_string(),
});
}
fn request_open(&mut self) {
let Some(file) = self.current() else {
return;
};
if file.status == ChangeStatus::Deleted {
self.message = Some("the file was deleted; there is nothing to open".to_string());
return;
}
let path = self.root.join(&file.path);
let view = self.diff_view();
let new_line = |anchor: &Option<Anchor>| {
anchor
.as_ref()
.filter(|anchor| anchor.side == NoteSide::New)
.map(|anchor| anchor.line)
};
let from = match self.focus {
Focus::Diff => self.cursor,
Focus::Files => view.changes.first().copied().unwrap_or(0),
}
.min(view.anchors.len());
let line = view.anchors[from..]
.iter()
.find_map(new_line)
.or_else(|| view.anchors[..from].iter().rev().find_map(new_line))
.unwrap_or(1);
self.open_request = Some((path, line));
}
fn cursor_anchor(&self) -> Option<(String, Anchor)> {
let path = self.current()?.path.clone();
let anchor = self
.diff_view()
.anchors
.into_iter()
.nth(self.cursor)
.flatten()?;
Some((path, anchor))
}
fn toggle_reviewed(&mut self) {
let Some(&index) = self.visible.get(self.selected) else {
return;
};
let reviewed = !self.store.is_reviewed(&self.files[index]);
if let Err(error) = self.store.set_reviewed(&self.files[index], reviewed) {
self.message = Some(format!("could not save review marks: {error:#}"));
}
if reviewed
&& let Some(next) = (self.selected + 1..self.visible.len())
.find(|&position| !self.store.is_reviewed(&self.files[self.visible[position]]))
{
self.select(next);
}
}
pub fn review_progress(&self) -> (usize, usize) {
let reviewed = self
.visible
.iter()
.filter(|&&index| self.store.is_reviewed(&self.files[index]))
.count();
(reviewed, self.visible.len())
}
fn select(&mut self, index: usize) {
let index = index.min(self.visible.len().saturating_sub(1));
if index != self.selected {
self.selected = index;
self.go_to_first_change();
}
}
fn move_cursor(&mut self, cursor: usize) {
let last = self.diff_view().lines.len().saturating_sub(1);
self.cursor = cursor.min(last);
self.keep_cursor_visible();
}
fn keep_cursor_visible(&mut self) {
let height = self.diff_height.max(1);
if self.cursor < self.scroll {
self.scroll = self.cursor;
} else if self.cursor >= self.scroll + height {
self.scroll = self.cursor + 1 - height;
}
}
fn diff_view(&self) -> DiffView {
self.diff_view_of(self.selected)
}
fn diff_view_of(&self, position: usize) -> DiffView {
let message = |text: String| DiffView {
lines: vec![Line::from(text).dim()],
anchors: vec![None],
texts: vec![None],
changes: Vec::new(),
};
let Some(file) = self.visible.get(position).map(|&index| &self.files[index]) else {
return message(match (self.files.is_empty(), self.layers.tests) {
(true, _) => "No changes.".to_string(),
(false, LayerMode::Hidden) => {
"Every changed file is a test file. Press t to show tests.".to_string()
}
(false, _) => "No test files changed. Press T to show the other files.".to_string(),
});
};
let view = file.view(self.layers);
if file.detection == Detection::Binary {
return message("Binary file not shown.".to_string());
}
if view.hunks.is_empty() {
return message(format!(
"No visible changes: {}.",
empty_message(file, self.layers.into())
));
}
let mut out = DiffView {
lines: Vec::new(),
anchors: Vec::new(),
texts: Vec::new(),
changes: Vec::new(),
};
let push = |out: &mut DiffView,
line: Line<'static>,
anchor: Option<Anchor>,
text: Option<String>| {
out.lines.push(line);
out.anchors.push(anchor);
out.texts.push(text);
};
for (hunk_index, hunk) in view.hunks.iter().enumerate() {
if hunk_index > 0 {
push(&mut out, Line::from(""), None, None);
}
push(
&mut out,
Line::from(hunk.header()).fg(Color::Cyan),
None,
None,
);
let mut first_change = None;
for (row_index, row) in hunk.rows.iter().enumerate() {
let moved = self.moves.get(&(position, hunk_index, row_index));
if let Some(moved) = moved
&& moved.starts_block
{
let marker = moved.describe(&file.path);
let hint = if self.collapse_moves {
" (M to expand)"
} else {
""
};
if self.collapse_moves && row.kind != RowKind::Context {
first_change.get_or_insert(out.lines.len());
}
push(
&mut out,
Line::from(format!(" {marker}{hint}"))
.fg(Color::Cyan)
.italic(),
None,
None,
);
}
if moved.is_some() && self.collapse_moves {
continue;
}
if row.kind != RowKind::Context {
first_change.get_or_insert(out.lines.len());
}
let anchor = anchor_of(row);
push(
&mut out,
row_line(
row,
moved.is_some(),
self.search.as_deref(),
&self.palette,
self.syntax,
),
anchor.clone(),
Some(row.text.clone()),
);
if let Some(anchor) = anchor
&& let Some(note) = self.notes.find(&file.path, anchor.side, anchor.line)
{
push(
&mut out,
Line::from(format!(" ✎ {}", note.text))
.fg(Color::Yellow)
.bold(),
Some(anchor),
None,
);
}
}
out.changes.extend(first_change);
}
out
}
}
fn find_all(text: &str, query: &str) -> Vec<Range<usize>> {
if query.is_empty() {
return Vec::new();
}
let sensitive = query.chars().any(char::is_uppercase);
let (haystack, needle) = if sensitive {
(text.to_string(), query.to_string())
} else {
(text.to_lowercase(), query.to_lowercase())
};
if haystack.len() != text.len() {
return text
.match_indices(query)
.map(|(at, found)| at..at + found.len())
.collect();
}
haystack
.match_indices(&needle)
.map(|(at, found)| at..at + found.len())
.collect()
}
fn anchor_of(row: &Row) -> Option<Anchor> {
let (side, line) = match row.kind {
RowKind::Removed => (NoteSide::Old, row.old_line?),
RowKind::Added | RowKind::Context => (NoteSide::New, row.new_line?),
};
Some(Anchor {
side,
line,
code: row.text.clone(),
})
}
fn row_line(
row: &Row,
moved: bool,
search: Option<&str>,
palette: &Palette,
syntax: bool,
) -> Line<'static> {
let number = |n: Option<usize>| n.map_or(" ".to_string(), |n| format!("{n:>5}"));
let (sign, sign_colour, line, emphasis) = match (row.kind, moved) {
(RowKind::Context, _) => (' ', Color::Reset, Style::new(), Style::new()),
(RowKind::Removed, true) => (
'-',
palette.moved_sign,
palette.moved_away,
palette.moved_away,
),
(RowKind::Added, true) => (
'+',
palette.moved_sign,
palette.moved_here,
palette.moved_here,
),
(RowKind::Removed, false) => (
'-',
palette.removed_sign,
palette.removed,
palette.removed_emphasis,
),
(RowKind::Added, false) => (
'+',
palette.added_sign,
palette.added,
palette.added_emphasis,
),
};
let coloured = syntax && !row.syntax.is_empty();
let style = if coloured || row.kind == RowKind::Context {
line
} else {
line.fg(sign_colour)
};
let syntax_marks = row
.syntax
.iter()
.filter(|_| coloured)
.filter_map(|(range, class)| Some((range.clone(), palette.syntax(*class)?)));
let marks: Vec<(Range<usize>, Style)> = syntax_marks
.chain(row.emphasis.iter().map(|range| (range.clone(), emphasis)))
.chain(
search
.map(|query| find_all(&row.text, query))
.unwrap_or_default()
.into_iter()
.map(|range| (range, palette.search)),
)
.collect();
let mut spans = vec![
Span::styled(
format!("{} {} ", number(row.old_line), number(row.new_line)),
Style::new().add_modifier(Modifier::DIM),
),
Span::styled(
format!("{sign} "),
line.fg(sign_colour).add_modifier(Modifier::BOLD),
),
];
spans.extend(styled_segments(&row.text, style, &marks));
Line::from(spans)
}
fn styled_segments(
text: &str,
base: Style,
layers: &[(Range<usize>, Style)],
) -> Vec<Span<'static>> {
let mut cuts: Vec<usize> = layers
.iter()
.flat_map(|(range, _)| [range.start, range.end])
.filter(|&cut| cut < text.len() && text.is_char_boundary(cut))
.chain([0, text.len()])
.collect();
cuts.sort_unstable();
cuts.dedup();
cuts.windows(2)
.map(|pair| {
let (start, end) = (pair[0], pair[1]);
let style = layers
.iter()
.filter(|(range, _)| range.start <= start && end <= range.end)
.fold(base, |style, (_, layer)| style.patch(*layer));
Span::styled(text[start..end].replace('\t', " "), style)
})
.collect()
}
pub fn draw(frame: &mut Frame, app: &mut App) {
let [main, status, help] = Layout::vertical([
Constraint::Min(3),
Constraint::Length(1),
Constraint::Length(1),
])
.areas(frame.area());
let [files_area, diff_area] =
Layout::horizontal([Constraint::Percentage(30), Constraint::Min(20)]).areas(main);
let items: Vec<ListItem> = app
.visible
.iter()
.map(|&index| {
let file = &app.files[index];
let visible = file.view(app.layers).hunks.len();
let reviewed = app.store.is_reviewed(file);
let mark = if reviewed { "✓ " } else { " " };
let item = ListItem::new(format!("{mark}{} ({visible})", file_title(file)));
if visible == 0 || reviewed {
item.dim()
} else {
item
}
})
.collect();
let mut list_state =
ListState::default().with_selected((!app.visible.is_empty()).then_some(app.selected));
frame.render_stateful_widget(
List::new(items)
.block(pane(
if app.title.is_empty() {
" Files ".to_string()
} else {
format!(" {} ", app.title)
},
app.focus == Focus::Files,
))
.highlight_style(Style::new().add_modifier(Modifier::REVERSED)),
files_area,
&mut list_state,
);
app.diff_height = diff_area.height.saturating_sub(2) as usize;
let lines = app.diff_view().lines;
app.cursor = app.cursor.min(lines.len().saturating_sub(1));
app.scroll = app.scroll.min(lines.len().saturating_sub(app.diff_height));
app.keep_cursor_visible();
let lines: Vec<Line> = lines
.into_iter()
.enumerate()
.map(|(i, line)| {
let on_cursor = app.focus == Focus::Diff && i == app.cursor;
let gutter = if on_cursor {
Span::styled("▌", Style::new().fg(Color::Cyan))
} else {
Span::raw(" ")
};
let mut spans = vec![gutter];
spans.extend(line.spans);
Line::from(spans).style(line.style)
})
.collect();
let title = app
.current()
.map(|file| format!(" {} [{}] ", file.path, file.tag()))
.unwrap_or_default();
frame.render_widget(
Paragraph::new(lines)
.block(pane(title, app.focus == Focus::Diff))
.scroll((app.scroll.min(u16::MAX as usize) as u16, 0)),
diff_area,
);
let (reviewed, listed) = app.review_progress();
let summary = match (&app.input, &app.message) {
(Some(Input::Note(text)), _) => {
format!("note › {text}█ (Enter save · Esc cancel · empty removes)")
}
(Some(Input::Search(query)), _) => format!("/{query}█ (Enter search · Esc cancel)"),
(None, Some(message)) => message.clone(),
(None, None) => format!(
"{} · {reviewed}/{listed} reviewed{}",
Summary::new(&app.files, app.layers).status_line(app.layers),
match app.notes.notes().len() {
0 => String::new(),
n => format!(" · {n} note{}", if n == 1 { "" } else { "s" }),
} + &match (app.moved_blocks(), app.collapse_moves) {
(0, _) => String::new(),
(n, collapsed) => format!(
" · {n} moved block{}{}",
if n == 1 { "" } else { "s" },
if collapsed { " (collapsed)" } else { "" }
),
}
),
};
frame.render_widget(
Paragraph::new(format!(" {summary}")).style(Style::new().add_modifier(Modifier::REVERSED)),
status,
);
frame.render_widget(Paragraph::new(HELP).dim(), help);
if app.show_help {
draw_help(frame);
}
}
fn draw_help(frame: &mut Frame) {
let columns: Vec<Vec<Line>> = KEYS
.iter()
.map(|groups| {
let mut lines = Vec::new();
for (group, keys) in groups.iter() {
if !lines.is_empty() {
lines.push(Line::from(""));
}
lines.push(Line::from(*group).bold().fg(Color::Cyan));
for (key, action) in *keys {
lines.push(Line::from(vec![
Span::styled(format!(" {key:<12}"), Style::new().bold()),
Span::raw(*action),
]));
}
}
lines
})
.collect();
let widths: Vec<u16> = columns
.iter()
.map(|lines| lines.iter().map(Line::width).max().unwrap_or(0) as u16)
.collect();
let rows = columns.iter().map(Vec::len).max().unwrap_or(0) as u16;
let area = centered(frame.area(), widths.iter().sum::<u16>() + 7, rows + 2);
frame.render_widget(Clear, area);
let block = pane(" Keys · any key closes ".to_string(), true);
let inner = block.inner(area);
frame.render_widget(block, area);
let [left, right] =
Layout::horizontal([Constraint::Length(widths[0] + 4), Constraint::Min(0)]).areas(inner);
let [left_column, right_column] = [left, right].map(|area| Rect {
x: area.x + 1,
width: area.width.saturating_sub(1),
..area
});
let mut columns = columns.into_iter();
for area in [left_column, right_column] {
frame.render_widget(Paragraph::new(columns.next().unwrap_or_default()), area);
}
}
fn centered(area: Rect, width: u16, height: u16) -> Rect {
let [area] = Layout::horizontal([Constraint::Length(width.min(area.width))])
.flex(Flex::Center)
.areas(area);
let [area] = Layout::vertical([Constraint::Length(height.min(area.height))])
.flex(Flex::Center)
.areas(area);
area
}
fn pane(title: String, focused: bool) -> Block<'static> {
let block = Block::bordered().title(title);
if focused {
block
.border_style(Style::new().fg(Color::Cyan))
.title_style(Style::new().bold())
} else {
block.border_style(Style::new().add_modifier(Modifier::DIM))
}
}
fn system_clipboard(text: &str) -> bool {
let tools: [(&str, &[&str]); 3] = [
("pbcopy", &[]),
("wl-copy", &[]),
("xclip", &["-selection", "clipboard"]),
];
tools.iter().any(|(program, args)| {
let Ok(mut child) = Command::new(program)
.args(*args)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
else {
return false;
};
let written = child
.stdin
.take()
.is_some_and(|mut stdin| stdin.write_all(text.as_bytes()).is_ok());
child.wait().is_ok_and(|status| status.success()) && written
})
}
pub fn run(
files: Vec<FileReview>,
layers: Layers,
store: ReviewStore,
notes: NoteStore,
root: PathBuf,
title: String,
syntax: bool,
) -> Result<NoteStore> {
let mut app = App::with_stores(files, layers, store, notes);
app.root = root;
app.title = title;
app.palette = Palette::detect();
app.syntax = syntax;
ratatui::run(|terminal: &mut DefaultTerminal| -> Result<()> {
while !app.quit {
terminal.draw(|frame| draw(frame, &mut app))?;
if let Event::Key(key) = event::read()?
&& key.kind == KeyEventKind::Press
{
app.handle_key(key);
}
if let Some((path, line)) = app.open_request.take() {
app.message = open_in_editor(terminal, &path, line).err();
}
}
Ok(())
})?;
Ok(app.notes)
}
fn open_in_editor(
terminal: &mut DefaultTerminal,
path: &std::path::Path,
line: usize,
) -> Result<(), String> {
let editor = std::env::var("VISUAL")
.or_else(|_| std::env::var("EDITOR"))
.ok()
.filter(|value| !value.trim().is_empty());
let command = editor_command(editor.as_deref(), path, line);
let mut process = Command::new(&command.program);
process.args(&command.args);
let failed = |error: std::io::Error| format!("could not start `{}`: {error}", command.program);
if command.in_terminal {
ratatui::restore();
let status = process.status();
*terminal = ratatui::init();
status.map_err(failed)?;
} else {
process
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(failed)?;
}
Ok(())
}