Radish alpha
r
Radicle terminal user interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
bin: Add hunk list and diff to patch review
Erik Kundt committed 1 year ago
commit 04343816ecbcd9f0f28f972d390a7e94d8050de4
parent d4e1c3a0b58fd75a09ef79e041af1415c9213fa5
8 files changed +1019 -224
modified Cargo.lock
@@ -139,6 +139,19 @@ dependencies = [
]

[[package]]
+
name = "ansi-to-tui"
+
version = "5.0.0-rc.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "428c2992b874104caf39204b05bf89eab4ceefdd4fcb26caa6759906f547f8e8"
+
dependencies = [
+
 "nom",
+
 "ratatui",
+
 "simdutf8",
+
 "smallvec",
+
 "thiserror",
+
]
+

+
[[package]]
name = "anstream"
version = "0.6.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -326,9 +339,9 @@ checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"

[[package]]
name = "castaway"
-
version = "0.2.2"
+
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "8a17ed5635fc8536268e5d4de1e22e81ac34419e5f052d4d51f4e01dcc263fcc"
+
checksum = "0abae9be0aaf9ea96a3b1b8b1b55c602ca751eba1b1500220cea4ecbafe7c0d5"
dependencies = [
 "rustversion",
]
@@ -1844,6 +1857,7 @@ dependencies = [
name = "radicle-tui"
version = "0.5.1"
dependencies = [
+
 "ansi-to-tui",
 "anyhow",
 "fuzzy-matcher",
 "homedir",
@@ -2181,6 +2195,12 @@ dependencies = [
]

[[package]]
+
name = "simdutf8"
+
version = "0.1.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
+

+
[[package]]
name = "similar"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
modified Cargo.toml
@@ -22,6 +22,7 @@ path = "bin/main.rs"
required-features = ["bin"]

[dependencies]
+
ansi-to-tui = { version = "5.0.0-rc.1" }
anyhow = { version = "1" }
inquire = { version = "0.7.4", default-features = false, features = ["termion", "editor"] }
lexopt = { version = "0.3.0" }
modified bin/cob.rs
@@ -5,10 +5,25 @@ use anyhow::Result;
use radicle::cob::Label;
use radicle::prelude::Did;

+
use std::path::{Path, PathBuf};
+

+
use radicle::git::Oid;
+

+
use radicle_surf::diff::*;
+

+
use radicle_cli::git::unified_diff::FileHeader;
+
use radicle_cli::git::unified_diff::HunkHeader;
+

+
use crate::git::Blob;
+
use crate::git::Repo;
+
use crate::ui::items::Blobs;
+

pub mod inbox;
pub mod issue;
pub mod patch;

+
pub type IndexedReviewItem = (usize, crate::cob::ReviewItem);
+

#[allow(dead_code)]
pub fn parse_labels(input: String) -> Result<Vec<Label>> {
    let mut labels = vec![];
@@ -38,3 +53,112 @@ pub fn parse_assignees(input: String) -> Result<Vec<Did>> {

    Ok(assignees)
}
+

+
/// A single review item. Can be a hunk or eg. a file move.
+
/// Files are usually split into multiple review items.
+
#[derive(Clone, Debug)]
+
pub enum ReviewItem {
+
    FileAdded {
+
        path: PathBuf,
+
        header: FileHeader,
+
        new: DiffFile,
+
        hunk: Option<Hunk<Modification>>,
+
        stats: Option<FileStats>,
+
    },
+
    FileDeleted {
+
        path: PathBuf,
+
        header: FileHeader,
+
        old: DiffFile,
+
        hunk: Option<Hunk<Modification>>,
+
        stats: Option<FileStats>,
+
    },
+
    FileModified {
+
        path: PathBuf,
+
        header: FileHeader,
+
        old: DiffFile,
+
        new: DiffFile,
+
        hunk: Option<Hunk<Modification>>,
+
        stats: Option<FileStats>,
+
    },
+
    FileMoved {
+
        moved: Moved,
+
    },
+
    FileCopied {
+
        copied: Copied,
+
    },
+
    FileEofChanged {
+
        path: PathBuf,
+
        header: FileHeader,
+
        old: DiffFile,
+
        new: DiffFile,
+
        eof: EofNewLine,
+
    },
+
    FileModeChanged {
+
        path: PathBuf,
+
        header: FileHeader,
+
        old: DiffFile,
+
        new: DiffFile,
+
    },
+
}
+

+
impl ReviewItem {
+
    pub fn hunk(&self) -> Option<&Hunk<Modification>> {
+
        match self {
+
            Self::FileAdded { hunk, .. } => hunk.as_ref(),
+
            Self::FileDeleted { hunk, .. } => hunk.as_ref(),
+
            Self::FileModified { hunk, .. } => hunk.as_ref(),
+
            _ => None,
+
        }
+
    }
+

+
    pub fn hunk_header(&self) -> Option<HunkHeader> {
+
        self.hunk().and_then(|h| HunkHeader::try_from(h).ok())
+
    }
+

+
    pub fn paths(&self) -> (Option<(&Path, Oid)>, Option<(&Path, Oid)>) {
+
        match self {
+
            Self::FileAdded { path, new, .. } => (None, Some((path, new.oid))),
+
            Self::FileDeleted { path, old, .. } => (Some((path, old.oid)), None),
+
            Self::FileMoved { moved } => (
+
                Some((&moved.old_path, moved.old.oid)),
+
                Some((&moved.new_path, moved.new.oid)),
+
            ),
+
            Self::FileCopied { copied } => (
+
                Some((&copied.old_path, copied.old.oid)),
+
                Some((&copied.new_path, copied.new.oid)),
+
            ),
+
            Self::FileModified { path, old, new, .. } => {
+
                (Some((path, old.oid)), Some((path, new.oid)))
+
            }
+
            Self::FileEofChanged { path, old, new, .. } => {
+
                (Some((path, old.oid)), Some((path, new.oid)))
+
            }
+
            Self::FileModeChanged { path, old, new, .. } => {
+
                (Some((path, old.oid)), Some((path, new.oid)))
+
            }
+
        }
+
    }
+

+
    pub fn file_header(&self) -> FileHeader {
+
        match self {
+
            Self::FileAdded { header, .. } => header.clone(),
+
            Self::FileDeleted { header, .. } => header.clone(),
+
            Self::FileMoved { moved } => FileHeader::Moved {
+
                old_path: moved.old_path.clone(),
+
                new_path: moved.new_path.clone(),
+
            },
+
            Self::FileCopied { copied } => FileHeader::Copied {
+
                old_path: copied.old_path.clone(),
+
                new_path: copied.new_path.clone(),
+
            },
+
            Self::FileModified { header, .. } => header.clone(),
+
            Self::FileEofChanged { header, .. } => header.clone(),
+
            Self::FileModeChanged { header, .. } => header.clone(),
+
        }
+
    }
+

+
    pub fn blobs<R: Repo>(&self, repo: &R) -> Blobs<(PathBuf, Blob)> {
+
        let (old, new) = self.paths();
+
        Blobs::from_paths(old, new, repo)
+
    }
+
}
modified bin/commands/patch.rs
@@ -258,7 +258,9 @@ pub async fn run(options: Options, ctx: impl terminal::Context) -> anyhow::Resul
            let queue = ReviewBuilder::new(patch_id, signer, &repo).queue(&brain, &revision)?;

            while !queue.is_empty() {
-
                let selection = review::Tui::new(&profile, &repo, &queue).run().await?;
+
                let selection = review::Tui::new(profile.clone(), rid, queue.clone())
+
                    .run()
+
                    .await?;
                log::info!("Received selection from TUI: {:?}", selection);

                if let Some(selection) = selection.as_ref() {
modified bin/commands/patch/review.rs
@@ -1,23 +1,43 @@
#[path = "review/builder.rs"]
pub mod builder;

+
use std::fmt::Debug;
+
use std::sync::Arc;
+
use std::sync::Mutex;
+

use anyhow::Result;

+
use ratatui::text::Line;
use termion::event::Key;

use ratatui::layout::{Constraint, Layout};
+
use ratatui::style::Stylize;
+
use ratatui::text::Text;
use ratatui::{Frame, Viewport};

+
use radicle::identity::RepoId;
use radicle::storage::git::Repository;
+
use radicle::storage::{ReadStorage, WriteRepository};
use radicle::Profile;

+
use radicle_cli as cli;
+

+
use cli::terminal::highlight::Highlighter;
+

use radicle_tui as tui;

use tui::store;
-
use tui::ui::im::widget::{TextViewState, Window};
+
use tui::ui::im::widget::GroupState;
+
use tui::ui::im::widget::{TableState, TextViewState, Window};
+
use tui::ui::im::Ui;
use tui::ui::im::{Borders, Context, Show};
+
use tui::ui::span;
+
use tui::ui::Column;
use tui::{Channel, Exit};

+
use crate::ui::items::ReviewItem;
+
use crate::ui::items::ToText;
+

use self::builder::ReviewQueue;

/// The actions that a user can carry out on a review item.
@@ -61,17 +81,17 @@ pub struct Selection {
    pub args: Option<Args>,
}

-
pub struct Tui<'a> {
-
    pub _profile: &'a Profile,
-
    pub _repository: &'a Repository,
-
    pub queue: &'a ReviewQueue,
+
pub struct Tui {
+
    pub profile: Profile,
+
    pub rid: RepoId,
+
    pub queue: ReviewQueue,
}

-
impl<'a> Tui<'a> {
-
    pub fn new(profile: &'a Profile, repository: &'a Repository, queue: &'a ReviewQueue) -> Self {
+
impl Tui {
+
    pub fn new(profile: Profile, rid: RepoId, queue: ReviewQueue) -> Self {
        Self {
-
            _profile: profile,
-
            _repository: repository,
+
            rid,
+
            profile,
            queue,
        }
    }
@@ -80,7 +100,7 @@ impl<'a> Tui<'a> {
        let viewport = Viewport::Fullscreen;

        let channel = Channel::default();
-
        let state = App::try_from(self)?;
+
        let state = App::new(self.profile.clone(), self.rid, self.queue.clone())?;

        tui::im(state, viewport, channel).await
    }
@@ -88,6 +108,8 @@ impl<'a> Tui<'a> {

#[derive(Clone, Debug)]
pub enum Message {
+
    WindowsChanged { state: GroupState },
+
    ItemChanged { state: TableState },
    Quit,
    Accept,
    Comment,
@@ -102,78 +124,304 @@ pub enum AppPage {
}

#[derive(Clone, Debug)]
-
pub struct HelpState {
-
    text: TextViewState,
+
pub struct HelpState<'a> {
+
    text: TextViewState<'a>,
}

-
#[derive(Clone, Debug)]
-
pub struct App {
+
#[derive(Clone)]
+
pub struct App<'a> {
+
    repository: Arc<Mutex<Repository>>,
+
    queue: (Vec<ReviewItem<'a>>, TableState),
    page: AppPage,
-
    help: HelpState,
+
    windows: GroupState,
+
    help: HelpState<'a>,
}

-
impl<'a> TryFrom<&Tui<'a>> for App {
+
impl<'a> TryFrom<&Tui> for App<'a> {
    type Error = anyhow::Error;

-
    fn try_from(_tui: &Tui) -> Result<Self, Self::Error> {
+
    fn try_from(tui: &Tui) -> Result<Self, Self::Error> {
+
        App::new(tui.profile.clone(), tui.rid, tui.queue.clone())
+
    }
+
}
+

+
impl<'a> App<'a> {
+
    pub fn new(profile: Profile, rid: RepoId, queue: ReviewQueue) -> Result<Self, anyhow::Error> {
+
        let repository = profile.storage.repository(rid)?;
+

+
        let queue = queue
+
            .iter()
+
            .map(|item| ReviewItem::from((&repository, item)))
+
            .collect::<Vec<_>>();
+

        Ok(Self {
+
            repository: Arc::new(Mutex::new(repository)),
            page: AppPage::Main,
+
            windows: GroupState::new(2, Some(0)),
            help: HelpState {
                text: TextViewState::new(help_text(), (0, 0)),
            },
+
            queue: (queue, TableState::new(Some(0))),
        })
    }
}

-
impl store::Update<Message> for App {
-
    type Return = Selection;
+
impl<'a> App<'a> {
+
    fn show_hunk_list(&self, ui: &mut Ui<Message>, frame: &mut Frame) {
+
        let columns = [
+
            Column::new(" ", Constraint::Length(1)),
+
            Column::new(" ", Constraint::Fill(1)),
+
            Column::new(" ", Constraint::Fill(1)),
+
        ]
+
        .to_vec();
+
        let mut selected = self.queue.1.selected();
+

+
        let table = ui.table(
+
            frame,
+
            &mut selected,
+
            &self.queue.0,
+
            columns,
+
            Some(Borders::All),
+
        );
+
        if table.changed {
+
            ui.send_message(Message::ItemChanged {
+
                state: TableState::new(selected),
+
            })
+
        }
+
    }

-
    fn update(&mut self, message: Message) -> Option<Exit<Self::Return>> {
-
        match message {
-
            Message::Quit => Some(Exit { value: None }),
-
            Message::Accept => Some(Exit {
-
                value: Some(Selection {
-
                    action: ReviewAction::Accept,
-
                    hunk: 0,
-
                    args: None,
-
                }),
-
            }),
-
            Message::Comment => Some(Exit {
-
                value: Some(Selection {
-
                    action: ReviewAction::Comment,
-
                    hunk: 0,
-
                    args: None,
-
                }),
-
            }),
-
            Message::ShowMain => {
-
                self.page = AppPage::Main;
-
                None
-
            }
-
            Message::ShowHelp => {
-
                self.page = AppPage::Help;
-
                None
-
            }
+
    fn show_review_item(&self, ui: &mut Ui<Message>, frame: &mut Frame) {
+
        let repo = self.repository.lock().unwrap();
+
        let mut hi = Highlighter::default();
+

+
        let selected = self.queue.1.selected();
+
        let item = selected.and_then(|selected| self.queue.0.get(selected));
+

+
        if let Some(item) = item {
+
            ui.composite(
+
                Layout::vertical([Constraint::Length(3), Constraint::Min(1)]),
+
                1,
+
                |ui| match &item.inner {
+
                    (
+
                        _,
+
                        crate::cob::ReviewItem::FileAdded {
+
                            path,
+
                            header: _,
+
                            new: _,
+
                            hunk,
+
                            stats: _,
+
                        },
+
                    ) => {
+
                        let path = ReviewItem::pretty_path(path, false);
+
                        let header = [
+
                            Column::new("", Constraint::Length(0)),
+
                            Column::new(path.clone(), Constraint::Length(path.width() as u16)),
+
                            Column::new(
+
                                span::default(" added ")
+
                                    .light_green()
+
                                    .dim()
+
                                    .reversed()
+
                                    .into_right_aligned_line(),
+
                                Constraint::Fill(1),
+
                            ),
+
                        ];
+
                        let hunk = hunk.clone().unwrap();
+
                        let hunk: Text<'_> =
+
                            hunk.to_text(&mut hi, &item.highlighted, repo.raw()).into();
+

+
                        ui.columns(frame, header.clone().to_vec(), Some(Borders::Top));
+
                        ui.text_view(frame, hunk, &mut (0, 0), Some(Borders::BottomSides));
+
                    }
+
                    (
+
                        _,
+
                        crate::cob::ReviewItem::FileModified {
+
                            path,
+
                            header: _,
+
                            old: _,
+
                            new: _,
+
                            hunk,
+
                            stats: _,
+
                        },
+
                    ) => {
+
                        let path = ReviewItem::pretty_path(path, false);
+
                        let header = [
+
                            Column::new("", Constraint::Length(0)),
+
                            Column::new(path.clone(), Constraint::Length(path.width() as u16)),
+
                            Column::new(
+
                                span::default(" modified ")
+
                                    .light_yellow()
+
                                    .dim()
+
                                    .reversed()
+
                                    .into_right_aligned_line(),
+
                                Constraint::Fill(1),
+
                            ),
+
                        ];
+
                        let hunk = hunk.clone().unwrap();
+
                        let hunk: Text<'_> =
+
                            hunk.to_text(&mut hi, &item.highlighted, repo.raw()).into();
+

+
                        ui.columns(frame, header.clone().to_vec(), Some(Borders::Top));
+
                        ui.text_view(frame, hunk, &mut (0, 0), Some(Borders::BottomSides));
+
                    }
+
                    (
+
                        _,
+
                        crate::cob::ReviewItem::FileDeleted {
+
                            path,
+
                            header: _,
+
                            old: _,
+
                            hunk,
+
                            stats: _,
+
                        },
+
                    ) => {
+
                        let path = ReviewItem::pretty_path(path, true);
+
                        let header = [
+
                            Column::new("", Constraint::Length(0)),
+
                            Column::new(path.clone(), Constraint::Length(path.width() as u16)),
+
                            Column::new(
+
                                span::default(" deleted ")
+
                                    .light_red()
+
                                    .dim()
+
                                    .reversed()
+
                                    .into_right_aligned_line(),
+
                                Constraint::Fill(1),
+
                            ),
+
                        ];
+
                        let hunk = hunk.clone().unwrap();
+
                        let hunk: Text<'_> =
+
                            hunk.to_text(&mut hi, &item.highlighted, repo.raw()).into();
+

+
                        ui.columns(frame, header.clone().to_vec(), Some(Borders::Top));
+
                        ui.text_view(frame, hunk, &mut (0, 0), Some(Borders::BottomSides));
+
                    }
+
                    (_, crate::cob::ReviewItem::FileCopied { copied }) => {
+
                        let path = Line::from(
+
                            [
+
                                ReviewItem::pretty_path(&copied.old_path, false).spans,
+
                                [span::default(" -> ")].to_vec(),
+
                                ReviewItem::pretty_path(&copied.new_path, false).spans,
+
                            ]
+
                            .concat()
+
                            .to_vec(),
+
                        );
+
                        let header = [
+
                            Column::new("", Constraint::Length(0)),
+
                            Column::new(path.clone(), Constraint::Length(path.width() as u16)),
+
                            Column::new(
+
                                span::default(" copied ")
+
                                    .light_red()
+
                                    .dim()
+
                                    .reversed()
+
                                    .into_right_aligned_line(),
+
                                Constraint::Fill(1),
+
                            ),
+
                        ];
+
                        ui.columns(frame, header.clone().to_vec(), Some(Borders::Top));
+
                    }
+
                    (_, crate::cob::ReviewItem::FileMoved { moved }) => {
+
                        let path = Line::from(
+
                            [
+
                                ReviewItem::pretty_path(&moved.new_path, false).spans,
+
                                [span::default(" -> ")].to_vec(),
+
                                ReviewItem::pretty_path(&moved.new_path, false).spans,
+
                            ]
+
                            .concat()
+
                            .to_vec(),
+
                        );
+
                        let header = [
+
                            Column::new("", Constraint::Length(0)),
+
                            Column::new(path.clone(), Constraint::Length(path.width() as u16)),
+
                            Column::new(
+
                                span::default(" moved ")
+
                                    .light_red()
+
                                    .dim()
+
                                    .reversed()
+
                                    .into_right_aligned_line(),
+
                                Constraint::Fill(1),
+
                            ),
+
                        ];
+
                        ui.columns(frame, header.clone().to_vec(), Some(Borders::All));
+
                    }
+
                    (
+
                        _,
+
                        crate::cob::ReviewItem::FileEofChanged {
+
                            path,
+
                            header: _,
+
                            old: _,
+
                            new: _,
+
                            eof: _,
+
                        },
+
                    ) => {
+
                        let path = ReviewItem::pretty_path(&path, false);
+
                        let header = [
+
                            Column::new("", Constraint::Length(0)),
+
                            Column::new(path.clone(), Constraint::Length(path.width() as u16)),
+
                            Column::new(
+
                                span::default(" eof ")
+
                                    .dim()
+
                                    .reversed()
+
                                    .into_right_aligned_line(),
+
                                Constraint::Fill(1),
+
                            ),
+
                        ];
+
                        ui.columns(frame, header.clone().to_vec(), Some(Borders::All));
+
                    }
+
                    (
+
                        _,
+
                        crate::cob::ReviewItem::FileModeChanged {
+
                            path,
+
                            header: _,
+
                            old: _,
+
                            new: _,
+
                        },
+
                    ) => {
+
                        let path = ReviewItem::pretty_path(&path, false);
+
                        let header = [
+
                            Column::new("", Constraint::Length(0)),
+
                            Column::new(path.clone(), Constraint::Length(path.width() as u16)),
+
                            Column::new(
+
                                span::default(" mode ")
+
                                    .dim()
+
                                    .reversed()
+
                                    .into_right_aligned_line(),
+
                                Constraint::Length(6),
+
                            ),
+
                        ];
+
                        ui.columns(frame, header.clone().to_vec(), Some(Borders::All));
+
                    }
+
                },
+
            );
        }
    }
}

-
impl Show<Message> for App {
-
    fn show(&self, ctx: &Context<Message>, frame: &mut Frame) -> Result<()> {
+
impl<'a> Show<Message> for App<'a> {
+
    fn show(&self, ctx: &Context<Message>, frame: &mut Frame) -> Result<(), anyhow::Error> {
        Window::default().show(ctx, |ui| {
-
            let mut page_focus = Some(0);
+
            let mut page_focus = self.windows.focus();

            match self.page {
                AppPage::Main => {
-
                    ui.group(
+
                    ui.layout(
                        Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]),
-
                        &mut page_focus,
+
                        Some(0),
                        |ui| {
-
                            ui.text_view(
-
                                frame,
-
                                String::from("Review"),
-
                                &mut (0, 0),
-
                                Some(Borders::All),
+
                            let group = ui.group(
+
                                Layout::horizontal([
+
                                    Constraint::Ratio(1, 3),
+
                                    Constraint::Ratio(2, 3),
+
                                ]),
+
                                &mut page_focus,
+
                                |ui| {
+
                                    self.show_hunk_list(ui, frame);
+
                                    self.show_review_item(ui, frame);
+
                                },
                            );
+
                            if group.response.changed {
+
                                ui.send_message(Message::WindowsChanged {
+
                                    state: GroupState::new(self.windows.len(), page_focus),
+
                                });
+
                            }
+

                            ui.shortcuts(
                                frame,
                                &[
@@ -184,18 +432,18 @@ impl Show<Message> for App {
                                ],
                                '∙',
                            );
+

+
                            if ui.input_global(|key| key == Key::Char('?')) {
+
                                ui.send_message(Message::ShowHelp);
+
                            }
+
                            if ui.input_global(|key| key == Key::Char('a')) {
+
                                ui.send_message(Message::Accept);
+
                            }
+
                            if ui.input_global(|key| key == Key::Char('c')) {
+
                                ui.send_message(Message::Comment);
+
                            }
                        },
                    );
-

-
                    if ui.input_global(|key| key == Key::Char('?')) {
-
                        ui.send_message(Message::ShowHelp);
-
                    }
-
                    if ui.input_global(|key| key == Key::Char('a')) {
-
                        ui.send_message(Message::Accept);
-
                    }
-
                    if ui.input_global(|key| key == Key::Char('c')) {
-
                        ui.send_message(Message::Comment);
-
                    }
                }
                AppPage::Help => {
                    ui.group(
@@ -226,6 +474,47 @@ impl Show<Message> for App {
    }
}

+
impl<'a> store::Update<Message> for App<'a> {
+
    type Return = Selection;
+

+
    fn update(&mut self, message: Message) -> Option<Exit<Self::Return>> {
+
        log::info!("Received message: {:?}", message);
+
        match message {
+
            Message::WindowsChanged { state } => {
+
                self.windows = state;
+
                None
+
            }
+
            Message::ItemChanged { state } => {
+
                self.queue.1 = state;
+
                None
+
            }
+
            Message::Quit => Some(Exit { value: None }),
+
            Message::Accept => Some(Exit {
+
                value: Some(Selection {
+
                    action: ReviewAction::Accept,
+
                    hunk: 0,
+
                    args: None,
+
                }),
+
            }),
+
            Message::Comment => Some(Exit {
+
                value: Some(Selection {
+
                    action: ReviewAction::Comment,
+
                    hunk: 0,
+
                    args: None,
+
                }),
+
            }),
+
            Message::ShowMain => {
+
                self.page = AppPage::Main;
+
                None
+
            }
+
            Message::ShowHelp => {
+
                self.page = AppPage::Help;
+
                None
+
            }
+
        }
+
    }
+
}
+

fn help_text() -> String {
    r#"# Generic keybindings

modified bin/commands/patch/review/builder.rs
@@ -35,6 +35,8 @@ use radicle_cli::git::unified_diff::{Encode, HunkHeader};
use radicle_cli::terminal as term;
use radicle_cli::terminal::highlight::Highlighter;

+
use crate::cob::ReviewItem;
+

/// Help message shown to user.
const HELP: &str = "\
y - accept this hunk
@@ -134,154 +136,8 @@ impl FromStr for ReviewAction {
    }
}

-
/// A single review item. Can be a hunk or eg. a file move.
-
/// Files are usually split into multiple review items.
-
#[derive(Debug)]
-
pub enum ReviewItem {
-
    FileAdded {
-
        path: PathBuf,
-
        header: FileHeader,
-
        new: DiffFile,
-
        hunk: Option<Hunk<Modification>>,
-
    },
-
    FileDeleted {
-
        path: PathBuf,
-
        header: FileHeader,
-
        old: DiffFile,
-
        hunk: Option<Hunk<Modification>>,
-
    },
-
    FileModified {
-
        path: PathBuf,
-
        header: FileHeader,
-
        old: DiffFile,
-
        new: DiffFile,
-
        hunk: Option<Hunk<Modification>>,
-
    },
-
    FileMoved {
-
        moved: Moved,
-
    },
-
    FileCopied {
-
        copied: Copied,
-
    },
-
    FileEofChanged {
-
        path: PathBuf,
-
        header: FileHeader,
-
        old: DiffFile,
-
        new: DiffFile,
-
        eof: EofNewLine,
-
    },
-
    FileModeChanged {
-
        path: PathBuf,
-
        header: FileHeader,
-
        old: DiffFile,
-
        new: DiffFile,
-
    },
-
}
-

-
impl ReviewItem {
-
    fn hunk(&self) -> Option<&Hunk<Modification>> {
-
        match self {
-
            Self::FileAdded { hunk, .. } => hunk.as_ref(),
-
            Self::FileDeleted { hunk, .. } => hunk.as_ref(),
-
            Self::FileModified { hunk, .. } => hunk.as_ref(),
-
            _ => None,
-
        }
-
    }
-

-
    fn hunk_header(&self) -> Option<HunkHeader> {
-
        self.hunk().and_then(|h| HunkHeader::try_from(h).ok())
-
    }
-

-
    fn paths(&self) -> (Option<(&Path, Oid)>, Option<(&Path, Oid)>) {
-
        match self {
-
            Self::FileAdded { path, new, .. } => (None, Some((path, new.oid))),
-
            Self::FileDeleted { path, old, .. } => (Some((path, old.oid)), None),
-
            Self::FileMoved { moved } => (
-
                Some((&moved.old_path, moved.old.oid)),
-
                Some((&moved.new_path, moved.new.oid)),
-
            ),
-
            Self::FileCopied { copied } => (
-
                Some((&copied.old_path, copied.old.oid)),
-
                Some((&copied.new_path, copied.new.oid)),
-
            ),
-
            Self::FileModified { path, old, new, .. } => {
-
                (Some((path, old.oid)), Some((path, new.oid)))
-
            }
-
            Self::FileEofChanged { path, old, new, .. } => {
-
                (Some((path, old.oid)), Some((path, new.oid)))
-
            }
-
            Self::FileModeChanged { path, old, new, .. } => {
-
                (Some((path, old.oid)), Some((path, new.oid)))
-
            }
-
        }
-
    }
-

-
    fn file_header(&self) -> FileHeader {
-
        match self {
-
            Self::FileAdded { header, .. } => header.clone(),
-
            Self::FileDeleted { header, .. } => header.clone(),
-
            Self::FileMoved { moved } => FileHeader::Moved {
-
                old_path: moved.old_path.clone(),
-
                new_path: moved.new_path.clone(),
-
            },
-
            Self::FileCopied { copied } => FileHeader::Copied {
-
                old_path: copied.old_path.clone(),
-
                new_path: copied.new_path.clone(),
-
            },
-
            Self::FileModified { header, .. } => header.clone(),
-
            Self::FileEofChanged { header, .. } => header.clone(),
-
            Self::FileModeChanged { header, .. } => header.clone(),
-
        }
-
    }
-

-
    fn blobs<R: Repo>(&self, repo: &R) -> Blobs<(PathBuf, Blob)> {
-
        let (old, new) = self.paths();
-
        Blobs::from_paths(old, new, repo)
-
    }
-

-
    fn pretty<R: Repo>(&self, repo: &R) -> Box<dyn Element> {
-
        let mut hi = Highlighter::default();
-
        let blobs = self.blobs(repo);
-
        let highlighted = blobs.highlight(&mut hi);
-
        let header = self.file_header();
-

-
        match self {
-
            Self::FileMoved { moved } => moved.pretty(&mut hi, &header, repo),
-
            Self::FileCopied { copied } => copied.pretty(&mut hi, &header, repo),
-
            Self::FileModified { hunk, .. }
-
            | Self::FileAdded { hunk, .. }
-
            | Self::FileDeleted { hunk, .. } => {
-
                let header = header.pretty(&mut hi, &None, repo);
-
                let vstack = term::VStack::default()
-
                    .border(Some(term::colors::FAINT))
-
                    .padding(1)
-
                    .child(header);
-

-
                if let Some(hunk) = hunk {
-
                    let hunk = hunk.pretty(&mut hi, &highlighted, repo);
-
                    if !hunk.is_empty() {
-
                        return vstack.divider().merge(hunk).boxed();
-
                    }
-
                }
-
                vstack
-
            }
-
            Self::FileEofChanged { eof, .. } => match eof {
-
                EofNewLine::NewMissing => {
-
                    VStack::default().child(term::Label::new("`\\n` missing at end-of-file"))
-
                }
-
                EofNewLine::OldMissing => {
-
                    VStack::default().child(term::Label::new("`\\n` added at end-of-file"))
-
                }
-
                _ => VStack::default(),
-
            },
-
            Self::FileModeChanged { .. } => VStack::default(),
-
        }
-
        .boxed()
-
    }
-
}
-

/// Queue of items (usually hunks) left to review.
-
#[derive(Default)]
+
#[derive(Clone, Default)]
pub struct ReviewQueue {
    /// Hunks left to review.
    queue: VecDeque<(usize, ReviewItem)>,
@@ -308,12 +164,13 @@ impl ReviewQueue {
                    hunk: if let DiffContent::Plain {
                        hunks: Hunks(mut hs),
                        ..
-
                    } = a.diff
+
                    } = a.diff.clone()
                    {
                        hs.pop()
                    } else {
                        None
                    },
+
                    stats: a.diff.stats().cloned(),
                });
            }
            FileDiff::Deleted(d) => {
@@ -324,12 +181,13 @@ impl ReviewQueue {
                    hunk: if let DiffContent::Plain {
                        hunks: Hunks(mut hs),
                        ..
-
                    } = d.diff
+
                    } = d.diff.clone()
                    {
                        hs.pop()
                    } else {
                        None
                    },
+
                    stats: d.diff.stats().cloned(),
                });
            }
            FileDiff::Modified(m) => {
@@ -352,12 +210,13 @@ impl ReviewQueue {
                            old: m.old.clone(),
                            new: m.new.clone(),
                            hunk: None,
+
                            stats: m.diff.stats().cloned(),
                        });
                    }
                    DiffContent::Plain {
                        hunks: Hunks(hunks),
                        eof,
-
                        ..
+
                        stats,
                    } => {
                        for hunk in hunks {
                            self.add_item(ReviewItem::FileModified {
@@ -366,6 +225,7 @@ impl ReviewQueue {
                                old: m.old.clone(),
                                new: m.new.clone(),
                                hunk: Some(hunk),
+
                                stats: Some(stats),
                            });
                        }
                        if let EofNewLine::OldMissing | EofNewLine::NewMissing = eof {
@@ -714,11 +574,11 @@ impl<'a, G: Signer> ReviewBuilder<'a, G> {
                Some(fr) => fr.set_item(&item),
                None => file.insert(FileReviewBuilder::new(&item)),
            };
-
            term::element::write_to(
-
                &item.pretty(repo),
-
                &mut writer,
-
                term::Constraint::from_env().unwrap_or_default(),
-
            )?;
+
            // term::element::write_to(
+
            //     &item.pretty(repo),
+
            //     &mut writer,
+
            //     term::Constraint::from_env().unwrap_or_default(),
+
            // )?;

            // Prompts the user for action on the above hunk.
            match self.prompt(&mut stdin, &mut writer, progress) {
modified bin/git.rs
@@ -1,3 +1,6 @@
+
use std::fs;
+
use std::path::Path;
+

use radicle::git;
use radicle::git::Oid;

@@ -18,3 +21,52 @@ pub fn diff_stats(
    diff.find_similar(Some(&mut find_opts))?;
    diff.stats()
}
+

+
/// Blob returned by the [`Repo`] trait.
+
#[derive(PartialEq, Eq, Debug)]
+
pub enum Blob {
+
    Binary,
+
    Empty,
+
    Plain(Vec<u8>),
+
}
+

+
/// A repository of Git blobs.
+
pub trait Repo {
+
    /// Lookup a blob from the repo.
+
    fn blob(&self, oid: git::Oid) -> Result<Blob, git::raw::Error>;
+
    /// Lookup a file in the workdir.
+
    fn file(&self, path: &Path) -> Option<Blob>;
+
}
+

+
impl Repo for git::raw::Repository {
+
    fn blob(&self, oid: git::Oid) -> Result<Blob, git::raw::Error> {
+
        let blob = self.find_blob(*oid)?;
+

+
        if blob.is_binary() {
+
            Ok(Blob::Binary)
+
        } else {
+
            let content = blob.content();
+

+
            if content.is_empty() {
+
                Ok(Blob::Empty)
+
            } else {
+
                Ok(Blob::Plain(blob.content().to_vec()))
+
            }
+
        }
+
    }
+

+
    fn file(&self, path: &Path) -> Option<Blob> {
+
        self.workdir()
+
            .and_then(|dir| fs::read(dir.join(path)).ok())
+
            .map(|content| {
+
                // A file is considered binary if there is a zero byte in the first 8 kilobytes
+
                // of the file. This is the same heuristic Git uses.
+
                let binary = content.iter().take(8192).any(|b| *b == 0);
+
                if binary {
+
                    Blob::Binary
+
                } else {
+
                    Blob::Plain(content)
+
                }
+
            })
+
    }
+
}
modified bin/ui/items.rs
@@ -1,4 +1,5 @@
use std::collections::HashMap;
+
use std::path::{Path, PathBuf};
use std::str::FromStr;

use nom::bytes::complete::{tag, take};
@@ -6,6 +7,8 @@ use nom::multi::separated_list0;
use nom::sequence::{delimited, preceded};
use nom::{IResult, Parser};

+
use ansi_to_tui::IntoText;
+

use radicle::cob::thread::{Comment, CommentId};
use radicle::cob::{Label, ObjectId, Timestamp, TypedId};
use radicle::git::Oid;
@@ -20,8 +23,15 @@ use radicle::storage::git::Repository;
use radicle::storage::{ReadRepository, ReadStorage, RefUpdate, WriteRepository};
use radicle::Profile;

-
use ratatui::style::{Style, Stylize};
-
use ratatui::text::{Line, Text};
+
use radicle_surf::diff::{self, Hunk, Modification};
+

+
use radicle_cli::git::unified_diff::{Decode, HunkHeader};
+
use radicle_cli::terminal;
+
use radicle_cli::terminal::highlight::Highlighter;
+

+
use ratatui::style::{Color, Style, Stylize};
+
// use ratatui::text::{Line, Text};
+
use ratatui::prelude::*;
use ratatui::widgets::Cell;

use tui_tree_widget::TreeItem;
@@ -32,6 +42,9 @@ use tui::ui::span;
use tui::ui::theme::style;
use tui::ui::{ToRow, ToTree};

+
use crate::cob::IndexedReviewItem;
+
use crate::git::{Blob, Repo};
+

use super::super::git;
use super::format;

@@ -1019,6 +1032,440 @@ impl ToTree<String> for CommentItem {
    }
}

+
pub struct TermLine(terminal::Line);
+

+
impl<'a> Into<Line<'a>> for TermLine {
+
    fn into(self) -> Line<'a> {
+
        Line::raw(self.0.to_string())
+
    }
+
}
+

+
#[derive(Clone, Debug)]
+
pub struct ReviewItem<'a> {
+
    pub inner: IndexedReviewItem,
+
    pub highlighted: Blobs<Vec<Line<'a>>>,
+
}
+

+
impl<'a> From<(&Repository, &IndexedReviewItem)> for ReviewItem<'a> {
+
    fn from(value: (&Repository, &IndexedReviewItem)) -> Self {
+
        let (repo, item) = value;
+
        let hi = Highlighter::default();
+

+
        let blobs = item.1.clone().blobs(repo.raw());
+
        let highlighted = blobs.highlight(hi);
+
        Self {
+
            inner: item.clone(),
+
            highlighted,
+
        }
+
    }
+
}
+

+
impl<'a> ToRow<3> for ReviewItem<'a> {
+
    fn to_row(&self) -> [Cell; 3] {
+
        use crate::cob::ReviewItem as Item;
+
        match &self.inner {
+
            (
+
                idx,
+
                Item::FileAdded {
+
                    path: _,
+
                    header: _,
+
                    new: _,
+
                    hunk: _,
+
                    stats: _,
+
                },
+
            ) => [
+
                span::secondary("?").into(),
+
                span::default(&format!("Hunk {}", idx)).into(),
+
                span::default("").into(),
+
            ],
+
            (idx, Item::FileCopied { copied: _ }) => [
+
                span::secondary("?").into(),
+
                span::default(&format!("Hunk {}", idx)).into(),
+
                span::default("").into(),
+
            ],
+
            (
+
                idx,
+
                Item::FileDeleted {
+
                    path: _,
+
                    header: _,
+
                    old: _,
+
                    hunk: _,
+
                    stats: _,
+
                },
+
            ) => [
+
                span::secondary("?").into(),
+
                span::default(&format!("Hunk {}", idx)).into(),
+
                span::default("").into(),
+
            ],
+
            (
+
                idx,
+
                Item::FileEofChanged {
+
                    path: _,
+
                    header: _,
+
                    old: _,
+
                    new: _,
+
                    eof: _,
+
                },
+
            ) => [
+
                span::secondary("?").into(),
+
                span::default(&format!("Hunk {}", idx)).into(),
+
                span::default("").into(),
+
            ],
+
            (
+
                idx,
+
                Item::FileModeChanged {
+
                    path: _,
+
                    header: _,
+
                    old: _,
+
                    new: _,
+
                },
+
            ) => [
+
                span::secondary("?").into(),
+
                span::default(&format!("Hunk {}", idx)).into(),
+
                span::default("").into(),
+
            ],
+
            (
+
                idx,
+
                Item::FileModified {
+
                    path: _,
+
                    header: _,
+
                    old: _,
+
                    new: _,
+
                    hunk: _,
+
                    stats: _,
+
                },
+
            ) => [
+
                span::secondary("?").into(),
+
                span::default(&format!("Hunk {}", idx)).into(),
+
                span::default("").into(),
+
            ],
+
            (idx, Item::FileMoved { moved: _ }) => [
+
                span::secondary("?").into(),
+
                span::default(&format!("Hunk {}", idx)).into(),
+
                span::default("").into(),
+
            ],
+
        }
+
    }
+
}
+

+
impl<'a> ReviewItem<'a> {
+
    pub fn pretty_path(path: &Path, crossed_out: bool) -> Line<'a> {
+
        let file = path.file_name().unwrap_or_default();
+
        let path = if path.iter().count() > 1 {
+
            path.into_iter()
+
                .take(path.into_iter().count() - 2)
+
                .map(|component| component.to_string_lossy().to_string())
+
                .collect::<Vec<_>>()
+
        } else {
+
            vec![]
+
        };
+

+
        let line = Line::from(
+
            [
+
                if crossed_out {
+
                    span::default(&file.to_string_lossy().to_string()).crossed_out()
+
                } else {
+
                    span::default(&file.to_string_lossy().to_string())
+
                },
+
                span::default(" "),
+
                span::default(&format!("{}", path.join(&String::from("/"))))
+
                    .dark_gray()
+
                    .dim(),
+
            ]
+
            .to_vec(),
+
        );
+
        line.into()
+
    }
+
}
+

+
/// Blobs passed down to the hunk renderer.
+
#[derive(Clone, Debug)]
+
pub struct Blobs<T> {
+
    pub old: Option<T>,
+
    pub new: Option<T>,
+
}
+

+
impl<T> Blobs<T> {
+
    pub fn new(old: Option<T>, new: Option<T>) -> Self {
+
        Self { old, new }
+
    }
+
}
+

+
impl<'a> Blobs<(PathBuf, Blob)> {
+
    pub fn highlight(self, mut hi: Highlighter) -> Blobs<Vec<Line<'a>>> {
+
        let mut blobs = Blobs::default();
+
        if let Some((path, Blob::Plain(content))) = &self.old {
+
            blobs.old = hi
+
                .highlight(path, content)
+
                .and_then(|hi| {
+
                    Ok(hi
+
                        .into_iter()
+
                        .map(|line| Line::raw(line.to_string()))
+
                        .collect::<Vec<_>>())
+
                })
+
                .ok();
+
        }
+
        if let Some((path, Blob::Plain(content))) = &self.new {
+
            blobs.new = hi
+
                .highlight(path, content)
+
                .and_then(|hi| {
+
                    Ok(hi
+
                        .into_iter()
+
                        .map(|line| Line::raw(line.to_string()))
+
                        .collect::<Vec<_>>())
+
                })
+
                .ok();
+
        }
+
        blobs
+
    }
+

+
    pub fn from_paths<R: Repo>(
+
        old: Option<(&Path, Oid)>,
+
        new: Option<(&Path, Oid)>,
+
        repo: &R,
+
    ) -> Blobs<(PathBuf, Blob)> {
+
        Blobs::new(
+
            old.and_then(|(path, oid)| {
+
                repo.blob(oid)
+
                    .ok()
+
                    .or_else(|| repo.file(path))
+
                    .map(|blob| (path.to_path_buf(), blob))
+
            }),
+
            new.and_then(|(path, oid)| {
+
                repo.blob(oid)
+
                    .ok()
+
                    .or_else(|| repo.file(path))
+
                    .map(|blob| (path.to_path_buf(), blob))
+
            }),
+
        )
+
    }
+
}
+

+
impl<T> Default for Blobs<T> {
+
    fn default() -> Self {
+
        Self {
+
            old: None,
+
            new: None,
+
        }
+
    }
+
}
+

+
pub struct HighlightedLine<'a>(Line<'a>);
+

+
impl<'a> From<Line<'a>> for HighlightedLine<'a> {
+
    fn from(highlighted: Line<'a>) -> Self {
+
        let converted = highlighted.to_string().into_text().unwrap().lines;
+

+
        Self {
+
            0: converted.first().cloned().unwrap_or_default(),
+
        }
+
    }
+
}
+

+
impl<'a> Into<Line<'a>> for HighlightedLine<'a> {
+
    fn into(self) -> Line<'a> {
+
        self.0
+
    }
+
}
+

+
/// Types that can be rendered as texts.
+
pub trait ToText<'a> {
+
    /// The output of the render process.
+
    type Output: Into<Text<'a>>;
+
    /// Context that can be passed down from parent objects during rendering.
+
    type Context;
+

+
    /// Render to pretty diff output.
+
    fn to_text<R: Repo>(
+
        &'a self,
+
        hi: &mut Highlighter,
+
        context: &Self::Context,
+
        repo: &R,
+
    ) -> Self::Output;
+
}
+

+
impl<'a> ToText<'a> for HunkHeader {
+
    type Output = Line<'a>;
+
    type Context = ();
+

+
    fn to_text<R: Repo>(
+
        &self,
+
        _hi: &mut Highlighter,
+
        _context: &Self::Context,
+
        _repo: &R,
+
    ) -> Self::Output {
+
        Line::from(
+
            [
+
                span::default(&format!(
+
                    "@@ -{},{} +{},{} @@",
+
                    self.old_line_no, self.old_size, self.new_line_no, self.new_size,
+
                ))
+
                .gray(),
+
                span::default(" "),
+
                span::default(&String::from_utf8_lossy(&self.text).to_string()),
+
            ]
+
            .to_vec(),
+
        )
+
    }
+
}
+

+
impl<'a> ToText<'a> for Modification {
+
    type Output = Line<'a>;
+
    type Context = Blobs<Vec<Line<'a>>>;
+

+
    fn to_text<R: Repo>(
+
        &'a self,
+
        _hi: &mut Highlighter,
+
        blobs: &Blobs<Vec<Line<'a>>>,
+
        _repo: &R,
+
    ) -> Self::Output {
+
        let line = match self {
+
            Modification::Deletion(diff::Deletion { line, line_no }) => {
+
                if let Some(lines) = &blobs.old.as_ref() {
+
                    lines[*line_no as usize - 1].clone()
+
                } else {
+
                    Line::raw(String::from_utf8_lossy(line.as_bytes()))
+
                }
+
            }
+
            Modification::Addition(diff::Addition { line, line_no }) => {
+
                if let Some(lines) = &blobs.new.as_ref() {
+
                    lines[*line_no as usize - 1].clone()
+
                } else {
+
                    Line::raw(String::from_utf8_lossy(line.as_bytes()))
+
                }
+
            }
+
            Modification::Context {
+
                line, line_no_new, ..
+
            } => {
+
                // Nb. we can check in the old or the new blob, we choose the new.
+
                if let Some(lines) = &blobs.new.as_ref() {
+
                    lines[*line_no_new as usize - 1].clone()
+
                } else {
+
                    Line::raw(String::from_utf8_lossy(line.as_bytes()))
+
                }
+
            }
+
        };
+

+
        HighlightedLine::from(line).into()
+
    }
+
}
+

+
impl<'a> ToText<'a> for Hunk<Modification> {
+
    type Output = Vec<Line<'a>>;
+
    type Context = Blobs<Vec<Line<'a>>>;
+

+
    fn to_text<R: Repo>(
+
        &'a self,
+
        hi: &mut Highlighter,
+
        blobs: &Self::Context,
+
        repo: &R,
+
    ) -> Self::Output {
+
        let mut lines: Vec<Line<'a>> = vec![];
+

+
        let default_dark = Color::Rgb(20, 20, 20);
+

+
        let positive_light = Color::Rgb(10, 60, 20);
+
        let positive_dark = Color::Rgb(10, 30, 20);
+

+
        let negative_light = Color::Rgb(60, 10, 20);
+
        let negative_dark = Color::Rgb(30, 10, 20);
+

+
        if let Ok(header) = HunkHeader::from_bytes(self.header.as_bytes()) {
+
            lines.push(Line::from(
+
                [
+
                    span::default(&format!(
+
                        "@@ -{},{} +{},{} @@",
+
                        header.old_line_no, header.old_size, header.new_line_no, header.new_size,
+
                    ))
+
                    .gray()
+
                    .dim(),
+
                    span::default(" "),
+
                    span::default(&String::from_utf8_lossy(&header.text).to_string())
+
                        .gray()
+
                        .dim(),
+
                ]
+
                .to_vec(),
+
            ))
+
        }
+

+
        for line in &self.lines {
+
            match line {
+
                Modification::Addition(a) => {
+
                    lines.push(Line::from(
+
                        [
+
                            [
+
                                span::positive(&format!("{:<5}", ""))
+
                                    .bg(positive_light)
+
                                    .dim(),
+
                                span::positive(&format!("{:<5}", &a.line_no.to_string()))
+
                                    .bg(positive_light)
+
                                    .dim(),
+
                                span::positive(" + ").bg(positive_dark).dim(),
+
                            ]
+
                            .to_vec(),
+
                            line.to_text(hi, blobs, repo)
+
                                .spans
+
                                .into_iter()
+
                                .map(|span| span.bg(positive_dark))
+
                                .collect::<Vec<_>>(),
+
                            [span::positive(&format!("{:<500}", "")).bg(positive_dark)].to_vec(),
+
                        ]
+
                        .concat(),
+
                    ));
+
                }
+
                Modification::Deletion(d) => {
+
                    lines.push(Line::from(
+
                        [
+
                            [
+
                                span::negative(&format!("{:<5}", &d.line_no.to_string()))
+
                                    .bg(negative_light)
+
                                    .dim(),
+
                                span::negative(&format!("{:<5}", ""))
+
                                    .bg(negative_light)
+
                                    .dim(),
+
                                span::negative(" - ").bg(negative_dark).dim(),
+
                            ]
+
                            .to_vec(),
+
                            line.to_text(hi, blobs, repo)
+
                                .spans
+
                                .into_iter()
+
                                .map(|span| span.bg(negative_dark))
+
                                .collect::<Vec<_>>(),
+
                            [span::positive(&format!("{:<500}", "")).bg(negative_dark)].to_vec(),
+
                        ]
+
                        .concat(),
+
                    ));
+
                }
+
                Modification::Context {
+
                    line_no_old,
+
                    line_no_new,
+
                    ..
+
                } => {
+
                    lines.push(Line::from(
+
                        [
+
                            [
+
                                span::default(&format!("{:<5}", &line_no_old.to_string()))
+
                                    .bg(default_dark)
+
                                    .gray()
+
                                    .dim(),
+
                                span::default(&format!("{:<5}", &line_no_new.to_string()))
+
                                    .bg(default_dark)
+
                                    .gray()
+
                                    .dim(),
+
                                span::default(&format!("{:<3}", "")),
+
                            ]
+
                            .to_vec(),
+
                            line.to_text(hi, blobs, repo).spans,
+
                        ]
+
                        .concat(),
+
                    ));
+
                }
+
            }
+
        }
+
        lines
+
    }
+
}
+

#[cfg(test)]
mod tests {
    use anyhow::Result;