Radish alpha
r
rad:z39mP9rQAaGmERfUMPULfPUi473tY
Radicle terminal user interface
Radicle
Git
Various improvements on patch review
Merged did:key:z6MkgFq6...nBGz opened 1 year ago
11 files changed +684 -739 76f55f0d a6cba00d
modified bin/cob.rs
@@ -1,207 +1,3 @@
-
use std::str::FromStr;
-

-
use anyhow::Result;
-

-
use radicle::cob::Label;
-
use radicle::prelude::Did;
-
use radicle_cli::git::unified_diff::FileHeader;
-

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

-
use radicle::git::Oid;
-

-
use radicle_surf::diff::*;
-

-
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 IndexedHunkItem = (usize, crate::cob::HunkItem, HunkState);
-
pub type FilePaths<'a> = (Option<(&'a Path, Oid)>, Option<(&'a Path, Oid)>);
-

-
#[allow(dead_code)]
-
pub fn parse_labels(input: String) -> Result<Vec<Label>> {
-
    let mut labels = vec![];
-
    if !input.is_empty() {
-
        for name in input.split(',') {
-
            match Label::new(name.trim()) {
-
                Ok(label) => labels.push(label),
-
                Err(err) => return Err(anyhow::anyhow!(err).context("Can't parse labels.")),
-
            }
-
        }
-
    }
-

-
    Ok(labels)
-
}
-

-
#[allow(dead_code)]
-
pub fn parse_assignees(input: String) -> Result<Vec<Did>> {
-
    let mut assignees = vec![];
-
    if !input.is_empty() {
-
        for did in input.split(',') {
-
            match Did::from_str(&format!("did:key:{}", did)) {
-
                Ok(did) => assignees.push(did),
-
                Err(err) => return Err(anyhow::anyhow!(err).context("Can't parse assignees.")),
-
            }
-
        }
-
    }
-

-
    Ok(assignees)
-
}
-

-
pub enum DiffStats {
-
    Hunk(HunkStats),
-
    File(FileStats),
-
}
-

-
#[derive(Default)]
-
pub struct HunkStats {
-
    added: usize,
-
    deleted: usize,
-
}
-

-
impl HunkStats {
-
    pub fn added(&self) -> usize {
-
        self.added
-
    }
-
    pub fn deleted(&self) -> usize {
-
        self.deleted
-
    }
-
}
-

-
impl From<&Hunk<Modification>> for HunkStats {
-
    fn from(hunk: &Hunk<Modification>) -> Self {
-
        let mut added = 0_usize;
-
        let mut deleted = 0_usize;
-

-
        for modification in &hunk.lines {
-
            match modification {
-
                Modification::Addition(_) => added += 1,
-
                Modification::Deletion(_) => deleted += 1,
-
                _ => {}
-
            }
-
        }
-

-
        Self { added, deleted }
-
    }
-
}
-

-
#[derive(Clone, Default, Debug, PartialEq)]
-
pub enum HunkState {
-
    #[default]
-
    Rejected,
-
    Accepted,
-
}
-

-
/// 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 HunkItem {
-
    Added {
-
        path: PathBuf,
-
        header: FileHeader,
-
        new: DiffFile,
-
        hunk: Option<Hunk<Modification>>,
-
        _stats: Option<FileStats>,
-
    },
-
    Deleted {
-
        path: PathBuf,
-
        header: FileHeader,
-
        old: DiffFile,
-
        hunk: Option<Hunk<Modification>>,
-
        _stats: Option<FileStats>,
-
    },
-
    Modified {
-
        path: PathBuf,
-
        header: FileHeader,
-
        old: DiffFile,
-
        new: DiffFile,
-
        hunk: Option<Hunk<Modification>>,
-
        _stats: Option<FileStats>,
-
    },
-
    Moved {
-
        moved: Moved,
-
    },
-
    Copied {
-
        copied: Copied,
-
    },
-
    EofChanged {
-
        path: PathBuf,
-
        header: FileHeader,
-
        old: DiffFile,
-
        new: DiffFile,
-
        _eof: EofNewLine,
-
    },
-
    ModeChanged {
-
        path: PathBuf,
-
        header: FileHeader,
-
        old: DiffFile,
-
        new: DiffFile,
-
    },
-
}
-

-
impl HunkItem {
-
    pub fn hunk(&self) -> Option<&Hunk<Modification>> {
-
        match self {
-
            Self::Added { hunk, .. } => hunk.as_ref(),
-
            Self::Deleted { hunk, .. } => hunk.as_ref(),
-
            Self::Modified { hunk, .. } => hunk.as_ref(),
-
            _ => None,
-
        }
-
    }
-

-
    pub fn file_header(&self) -> FileHeader {
-
        match self {
-
            Self::Added { header, .. } => header.clone(),
-
            Self::Deleted { header, .. } => header.clone(),
-
            Self::Moved { moved } => FileHeader::Moved {
-
                old_path: moved.old_path.clone(),
-
                new_path: moved.new_path.clone(),
-
            },
-
            Self::Copied { copied } => FileHeader::Copied {
-
                old_path: copied.old_path.clone(),
-
                new_path: copied.new_path.clone(),
-
            },
-
            Self::Modified { header, .. } => header.clone(),
-
            Self::EofChanged { header, .. } => header.clone(),
-
            Self::ModeChanged { header, .. } => header.clone(),
-
        }
-
    }
-

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

-
    pub fn paths(&self) -> FilePaths {
-
        match self {
-
            Self::Added { path, new, .. } => (None, Some((path, new.oid))),
-
            Self::Deleted { path, old, .. } => (Some((path, old.oid)), None),
-
            Self::Moved { moved } => (
-
                Some((&moved.old_path, moved.old.oid)),
-
                Some((&moved.new_path, moved.new.oid)),
-
            ),
-
            Self::Copied { copied } => (
-
                Some((&copied.old_path, copied.old.oid)),
-
                Some((&copied.new_path, copied.new.oid)),
-
            ),
-
            Self::Modified { path, old, new, .. } => (Some((path, old.oid)), Some((path, new.oid))),
-
            Self::EofChanged { path, old, new, .. } => {
-
                (Some((path, old.oid)), Some((path, new.oid)))
-
            }
-
            Self::ModeChanged { path, old, new, .. } => {
-
                (Some((path, old.oid)), Some((path, new.oid)))
-
            }
-
        }
-
    }
-

-
    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/review.rs
@@ -1,17 +1,17 @@
#[path = "review/builder.rs"]
pub mod builder;

-
use std::collections::HashMap;
use std::fmt::Debug;
use std::sync::Arc;
use std::sync::Mutex;

use anyhow::Result;

+
use radicle::storage::git::Repository;
use termion::event::Key;

+
use ratatui::layout::Constraint;
use ratatui::layout::Position;
-
use ratatui::layout::{Constraint, Layout};
use ratatui::style::Stylize;
use ratatui::text::Text;
use ratatui::{Frame, Viewport};
@@ -28,7 +28,7 @@ use radicle::Storage;
use radicle_tui as tui;

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

-
use crate::cob::HunkState;
+
use crate::git::HunkState;
+
use crate::git::StatefulHunkDiff;
use crate::tui_patch::review::builder::DiffUtil;
use crate::ui::format;
use crate::ui::items::HunkItem;
+
use crate::ui::layout;

use self::builder::Brain;
use self::builder::FileReviewBuilder;
@@ -69,7 +71,7 @@ pub struct Tui {
    pub title: String,
    pub revision: Revision,
    pub review: Review,
-
    pub queue: ReviewQueue,
+
    pub hunks: ReviewQueue,
}

impl Tui {
@@ -82,7 +84,7 @@ impl Tui {
        title: String,
        revision: Revision,
        review: Review,
-
        queue: ReviewQueue,
+
        hunks: ReviewQueue,
    ) -> Self {
        Self {
            storage,
@@ -92,7 +94,7 @@ impl Tui {
            title,
            revision,
            review,
-
            queue,
+
            hunks,
        }
    }

@@ -108,7 +110,7 @@ impl Tui {
            self.title,
            self.revision,
            self.review,
-
            self.queue,
+
            self.hunks,
        )?;

        tui::im(state, viewport, channel).await
@@ -118,15 +120,15 @@ impl Tui {
#[derive(Clone, Debug)]
pub enum Message<'a> {
    ShowMain,
-
    WindowsChanged { state: GroupState },
-
    ItemChanged { state: TableState },
-
    ItemViewChanged { state: ReviewItemState },
-
    Quit,
+
    PanesChanged { state: PanesState },
+
    HunkChanged { state: TableState },
+
    HunkViewChanged { state: HunkItemState },
+
    ShowHelp,
+
    HelpChanged { state: TextViewState<'a> },
    Comment,
    Accept,
    Discard,
-
    ShowHelp,
-
    HelpChanged { state: TextViewState<'a> },
+
    Quit,
}

#[derive(Clone, Debug, Eq, PartialEq, Hash)]
@@ -136,10 +138,12 @@ pub enum AppPage {
}

#[derive(Clone, Debug)]
-
pub struct ReviewItemState {
+
pub struct HunkItemState {
    cursor: Position,
}

+
pub type HunkItems<'a> = Vec<(HunkItem<'a>, HunkItemState)>;
+

#[derive(Clone)]
pub struct App<'a> {
    /// The nodes' storage.
@@ -155,13 +159,11 @@ pub struct App<'a> {
    /// Revision this review belongs to.
    revision: Revision,
    /// List of all hunks and its table widget state.
-
    queue: Arc<Mutex<(Vec<HunkItem<'a>>, TableState)>>,
-
    /// States of diff views for all hunks.
-
    items: HashMap<usize, ReviewItemState>,
+
    hunks: Arc<Mutex<(HunkItems<'a>, TableState)>>,
    /// Current app page.
    page: AppPage,
    /// State of panes widget on the main page.
-
    windows: GroupState,
+
    group: PanesState,
    /// State of text view widget on the help page.
    help: TextViewState<'a>,
}
@@ -178,7 +180,7 @@ impl<'a> TryFrom<Tui> for App<'a> {
            tui.title,
            tui.revision,
            tui.review,
-
            tui.queue,
+
            tui.hunks,
        )
    }
}
@@ -193,24 +195,21 @@ impl<'a> App<'a> {
        title: String,
        revision: Revision,
        review: Review,
-
        queue: ReviewQueue,
+
        hunks: ReviewQueue,
    ) -> Result<Self, anyhow::Error> {
        let repo = storage.repository(rid)?;
-
        let queue = queue
+
        let hunks = hunks
            .iter()
-
            .map(|item| HunkItem::from((&repo, &review, item)))
+
            .map(|(_, item, state)| {
+
                (
+
                    HunkItem::from((&repo, &review, StatefulHunkDiff::from((item, state)))),
+
                    HunkItemState {
+
                        cursor: Position::new(0, 0),
+
                    },
+
                )
+
            })
            .collect::<Vec<_>>();

-
        let mut items = HashMap::new();
-
        for (idx, _) in queue.iter().enumerate() {
-
            items.insert(
-
                idx,
-
                ReviewItemState {
-
                    cursor: Position::new(0, 0),
-
                },
-
            );
-
        }
-

        let mut app = App {
            storage,
            signer: Arc::new(Mutex::new(signer)),
@@ -218,10 +217,9 @@ impl<'a> App<'a> {
            patch,
            title,
            revision,
-
            queue: Arc::new(Mutex::new((queue, TableState::new(Some(0))))),
-
            items,
+
            hunks: Arc::new(Mutex::new((hunks, TableState::new(Some(0))))),
            page: AppPage::Main,
-
            windows: GroupState::new(2, Some(0)),
+
            group: PanesState::new(2, Some(0)),
            help: TextViewState::new(help_text(), Position::default()),
        };

@@ -237,16 +235,16 @@ impl<'a> App<'a> {

        if let Some(selected) = self.selected_hunk_idx() {
            let mut brain = Brain::load_or_new(self.patch, &self.revision, repo.raw(), signer)?;
-
            let hunks = self.queue.lock().unwrap().0.clone();
+
            let hunks = self.hunks.lock().unwrap().0.clone();

-
            if let Some(hunk) = hunks.get(selected) {
+
            if let Some((hunk, _)) = hunks.get(selected) {
                let mut file: Option<FileReviewBuilder> = None;
                let file = match file.as_mut() {
-
                    Some(fr) => fr.set_item(&hunk.inner.1),
-
                    None => file.insert(FileReviewBuilder::new(&hunk.inner.1)),
+
                    Some(fr) => fr.set_item(hunk.inner.hunk()),
+
                    None => file.insert(FileReviewBuilder::new(hunk.inner.hunk())),
                };

-
                let diff = file.item_diff(&hunk.inner.1)?;
+
                let diff = file.item_diff(hunk.inner.hunk())?;
                brain.accept(diff, repo.raw())?;
            }
        }
@@ -256,7 +254,7 @@ impl<'a> App<'a> {

    #[allow(clippy::borrowed_box)]
    pub fn discard_accepted_hunks(&self) -> Result<()> {
-
        let repo = self.storage.repository(self.rid).unwrap();
+
        let repo = self.repo()?;
        let signer: &Box<dyn Signer> = &self.signer.lock().unwrap();

        let mut brain = Brain::load_or_new(self.patch, &self.revision, repo.raw(), signer)?;
@@ -267,7 +265,7 @@ impl<'a> App<'a> {

    #[allow(clippy::borrowed_box)]
    pub fn reload_states(&mut self) -> anyhow::Result<()> {
-
        let repo = self.storage.repository(self.rid).unwrap();
+
        let repo = self.repo()?;
        let signer: &Box<dyn Signer> = &self.signer.lock().unwrap();

        let brain = Brain::load_or_new(self.patch, &self.revision, repo.raw(), signer)?;
@@ -289,11 +287,10 @@ impl<'a> App<'a> {
            })
            .collect::<Vec<_>>();

-
        let mut queue = self.queue.lock().unwrap();
+
        let mut queue = self.hunks.lock().unwrap();
        for (idx, new_state) in states.iter().enumerate() {
-
            if let Some(hunk) = queue.0.get_mut(idx) {
-
                let (_, _, ref mut state) = hunk.inner;
-
                *state = new_state.clone();
+
            if let Some((hunk, _)) = queue.0.get_mut(idx) {
+
                *hunk.inner.state_mut() = new_state.clone();
            }
        }

@@ -301,7 +298,11 @@ impl<'a> App<'a> {
    }

    pub fn selected_hunk_idx(&self) -> Option<usize> {
-
        self.queue.lock().unwrap().1.selected()
+
        self.hunks.lock().unwrap().1.selected()
+
    }
+

+
    pub fn repo(&self) -> Result<Repository> {
+
        Ok(self.storage.repository(self.rid)?)
    }
}

@@ -315,66 +316,66 @@ impl<'a> App<'a> {
        ]
        .to_vec();

-
        let queue = self.queue.lock().unwrap();
-
        let mut selected = queue.1.selected();
+
        let hunks = self.hunks.lock().unwrap();
+
        let mut selected = hunks.1.selected();
+

+
        let hunks = hunks
+
            .0
+
            .iter()
+
            .map(|(hunk, _)| hunk.clone())
+
            .collect::<Vec<_>>();

-
        let table = ui.headered_table(frame, &mut selected, &queue.0, header, columns);
+
        let table = ui.headered_table(frame, &mut selected, &hunks, header, columns);
        if table.changed {
-
            ui.send_message(Message::ItemChanged {
+
            ui.send_message(Message::HunkChanged {
                state: TableState::new(selected),
            })
        }
    }

-
    fn show_review_item(&self, ui: &mut Ui<Message<'a>>, frame: &mut Frame) {
-
        let queue = self.queue.lock().unwrap();
+
    fn show_hunk(&self, ui: &mut Ui<Message<'a>>, frame: &mut Frame) {
+
        let hunks = self.hunks.lock().unwrap();

-
        let selected = queue.1.selected();
-
        let item = selected.and_then(|selected| queue.0.get(selected));
+
        let selected = hunks.1.selected();
+
        let hunk = selected.and_then(|selected| hunks.0.get(selected));

-
        if let Some(item) = item {
-
            let header = item.header();
-
            let hunk = item
+
        if let Some((hunk, _)) = hunk {
+
            let empty_text = hunk
                .hunk_text()
                .unwrap_or(Text::raw("Nothing to show.").dark_gray());

            let mut cursor = selected
-
                .and_then(|selected| self.items.get(&selected))
-
                .map(|state| state.cursor)
+
                .and_then(|selected| hunks.0.get(selected))
+
                .map(|(_, state)| state.cursor)
                .unwrap_or_default();

-
            ui.composite(
-
                Layout::vertical([Constraint::Length(3), Constraint::Min(1)]),
-
                1,
-
                |ui| {
-
                    ui.columns(frame, header, Some(Borders::Top));
-

-
                    if let Some(hunk) = item.hunk_text() {
-
                        let diff =
-
                            ui.text_view(frame, hunk, &mut cursor, Some(Borders::BottomSides));
-
                        if diff.changed {
-
                            ui.send_message(Message::ItemViewChanged {
-
                                state: ReviewItemState { cursor },
-
                            })
-
                        }
-
                    } else {
-
                        ui.centered_text_view(frame, hunk, Some(Borders::BottomSides));
+
            ui.composite(layout::container(), 1, |ui| {
+
                ui.columns(frame, hunk.header(), Some(Borders::Top));
+

+
                if let Some(text) = hunk.hunk_text() {
+
                    let diff = ui.text_view(frame, text, &mut cursor, Some(Borders::BottomSides));
+
                    if diff.changed {
+
                        ui.send_message(Message::HunkViewChanged {
+
                            state: HunkItemState { cursor },
+
                        })
                    }
-
                },
-
            );
+
                } else {
+
                    ui.centered_text_view(frame, empty_text, Some(Borders::BottomSides));
+
                }
+
            });
        }
    }

    fn show_context_bar(&self, ui: &mut Ui<Message<'a>>, frame: &mut Frame) {
-
        let queue = &self.queue.lock().unwrap().0;
+
        let hunks = &self.hunks.lock().unwrap().0;

        let id = format!(" {} ", format::cob(&self.patch));
        let title = &self.title;

-
        let hunks_total = queue.len();
-
        let hunks_accepted = queue
+
        let hunks_total = hunks.len();
+
        let hunks_accepted = hunks
            .iter()
-
            .filter(|item| item.inner.2 == HunkState::Accepted)
+
            .filter(|(hunk, _)| *hunk.inner.state() == HunkState::Accepted)
            .collect::<Vec<_>>()
            .len();

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

            match self.page {
                AppPage::Main => {
-
                    ui.layout(
-
                        Layout::vertical([
-
                            Constraint::Fill(1),
-
                            Constraint::Length(1),
-
                            Constraint::Length(1),
-
                        ]),
-
                        Some(0),
-
                        |ui| {
-
                            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.layout(layout::page(), Some(0), |ui| {
+
                        let group = ui.panes(layout::list_item(), &mut page_focus, |ui| {
+
                            self.show_hunk_list(ui, frame);
+
                            self.show_hunk(ui, frame);
+
                        });
+
                        if group.response.changed {
+
                            ui.send_message(Message::PanesChanged {
+
                                state: PanesState::new(self.group.len(), page_focus),
+
                            });
+
                        }

-
                            self.show_context_bar(ui, frame);
+
                        self.show_context_bar(ui, frame);
+

+
                        ui.shortcuts(
+
                            frame,
+
                            &[
+
                                ("c", "comment"),
+
                                ("a", "accept"),
+
                                ("d", "discard accepted"),
+
                                ("?", "help"),
+
                                ("q", "quit"),
+
                            ],
+
                            '∙',
+
                        );
+

+
                        if ui.input_global(|key| key == Key::Char('?')) {
+
                            ui.send_message(Message::ShowHelp);
+
                        }
+
                        if ui.input_global(|key| key == Key::Char('c')) {
+
                            ui.send_message(Message::Comment);
+
                        }
+
                        if ui.input_global(|key| key == Key::Char('a')) {
+
                            ui.send_message(Message::Accept);
+
                        }
+
                        if ui.input_global(|key| key == Key::Char('d')) {
+
                            ui.send_message(Message::Discard);
+
                        }
+
                    });
+
                }
+
                AppPage::Help => {
+
                    ui.panes(layout::page(), &mut page_focus, |ui| {
+
                        ui.composite(layout::container(), 1, |ui| {
+
                            let header = [Column::new(" Help ", Constraint::Fill(1))].to_vec();
+
                            let mut cursor = self.help.cursor();

-
                            ui.shortcuts(
+
                            ui.columns(frame, header, Some(Borders::Top));
+
                            let help = ui.text_view(
                                frame,
-
                                &[
-
                                    ("c", "comment"),
-
                                    ("a", "accept"),
-
                                    ("d", "discard accepted"),
-
                                    ("?", "help"),
-
                                    ("q", "quit"),
-
                                ],
-
                                '∙',
+
                                self.help.text().to_string(),
+
                                &mut cursor,
+
                                Some(Borders::BottomSides),
                            );
-

-
                            if ui.input_global(|key| key == Key::Char('?')) {
-
                                ui.send_message(Message::ShowHelp);
-
                            }
-
                            if ui.input_global(|key| key == Key::Char('c')) {
-
                                ui.send_message(Message::Comment);
-
                            }
-
                            if ui.input_global(|key| key == Key::Char('a')) {
-
                                ui.send_message(Message::Accept);
+
                            if help.changed {
+
                                ui.send_message(Message::HelpChanged {
+
                                    state: TextViewState::new(self.help.text().clone(), cursor),
+
                                })
                            }
-
                            if ui.input_global(|key| key == Key::Char('d')) {
-
                                ui.send_message(Message::Discard);
-
                            }
-
                        },
-
                    );
-
                }
-
                AppPage::Help => {
-
                    ui.group(
-
                        Layout::vertical([
-
                            Constraint::Fill(1),
-
                            Constraint::Length(1),
-
                            Constraint::Length(1),
-
                        ]),
-
                        &mut page_focus,
-
                        |ui| {
-
                            ui.composite(
-
                                Layout::vertical([Constraint::Length(3), Constraint::Min(1)]),
-
                                1,
-
                                |ui| {
-
                                    let header =
-
                                        [Column::new(" Help ", Constraint::Fill(1))].to_vec();
-
                                    let mut cursor = self.help.cursor();
-

-
                                    ui.columns(frame, header, Some(Borders::Top));
-
                                    let help = ui.text_view(
-
                                        frame,
-
                                        self.help.text().to_string(),
-
                                        &mut cursor,
-
                                        Some(Borders::BottomSides),
-
                                    );
-
                                    if help.changed {
-
                                        ui.send_message(Message::HelpChanged {
-
                                            state: TextViewState::new(
-
                                                self.help.text().clone(),
-
                                                cursor,
-
                                            ),
-
                                        })
-
                                    }
-
                                },
-
                            );
+
                        });

-
                            self.show_context_bar(ui, frame);
+
                        self.show_context_bar(ui, frame);

-
                            ui.shortcuts(frame, &[("?", "close"), ("q", "quit")], '∙');
-
                        },
-
                    );
+
                        ui.shortcuts(frame, &[("?", "close"), ("q", "quit")], '∙');
+
                    });

                    if ui.input_global(|key| key == Key::Char('?')) {
                        ui.send_message(Message::ShowMain);
@@ -544,29 +514,42 @@ impl<'a> store::Update<Message<'a>> for App<'a> {
        log::info!("Received message: {:?}", message);

        match message {
-
            Message::WindowsChanged { state } => {
-
                self.windows = state;
+
            Message::ShowMain => {
+
                self.page = AppPage::Main;
+
                None
+
            }
+
            Message::ShowHelp => {
+
                self.page = AppPage::Help;
+
                None
+
            }
+
            Message::PanesChanged { state } => {
+
                self.group = state;
                None
            }
-
            Message::ItemChanged { state } => {
-
                let mut queue = self.queue.lock().unwrap();
-
                queue.1 = state;
+
            Message::HunkChanged { state } => {
+
                let mut hunks = self.hunks.lock().unwrap();
+
                hunks.1 = state;
                None
            }
-
            Message::ItemViewChanged { state } => {
-
                let queue = self.queue.lock().unwrap();
-
                if let Some(selected) = queue.1.selected() {
-
                    self.items.insert(selected, state);
+
            Message::HunkViewChanged { state } => {
+
                let mut hunks = self.hunks.lock().unwrap();
+
                if let Some(selected) = hunks.1.selected() {
+
                    if let Some((_, item_state)) = hunks.0.get_mut(selected) {
+
                        *item_state = state;
+
                    }
                }
                None
            }
-
            Message::Quit => Some(Exit { value: None }),
+
            Message::HelpChanged { state } => {
+
                self.help = state;
+
                None
+
            }
            Message::Comment => {
-
                let queue = self.queue.lock().unwrap();
+
                let hunks = self.hunks.lock().unwrap();
                Some(Exit {
                    value: Some(Selection {
                        action: ReviewAction::Comment,
-
                        hunk: queue.1.selected(),
+
                        hunk: hunks.1.selected(),
                        args: None,
                    }),
                })
@@ -587,18 +570,7 @@ impl<'a> store::Update<Message<'a>> for App<'a> {
                let _ = self.reload_states();
                None
            }
-
            Message::ShowMain => {
-
                self.page = AppPage::Main;
-
                None
-
            }
-
            Message::ShowHelp => {
-
                self.page = AppPage::Help;
-
                None
-
            }
-
            Message::HelpChanged { state } => {
-
                self.help = state;
-
                None
-
            }
+
            Message::Quit => Some(Exit { value: None }),
        }
    }
}
@@ -622,6 +594,9 @@ review is done, it needs to be finalized via `rad patch review --accept | --reje
`Home`      move cursor to the first line
`End`       move cursor to the last line

+
`Tab`       Focus next pane
+
`BackTab`   Focus previous pane
+

`?`         toogle help
`q`         quit / cancel

@@ -645,8 +620,8 @@ mod test {
    use crate::test;

    impl<'a> App<'a> {
-
        pub fn hunks(&self) -> Vec<HunkItem> {
-
            self.queue.lock().unwrap().0.clone()
+
        pub fn hunks(&self) -> Vec<(HunkItem, HunkItemState)> {
+
            self.hunks.lock().unwrap().0.clone()
        }
    }

@@ -751,7 +726,7 @@ mod test {
        let patch = test::fixtures::patch(&alice, &branch, &mut patches)?;

        let mut app = fixtures::app(&alice, patch)?;
-
        app.update(Message::ItemChanged {
+
        app.update(Message::HunkChanged {
            state: TableState::new(Some(1)),
        });

modified bin/commands/patch/review/builder.rs
@@ -28,13 +28,13 @@ use radicle_cli::git::unified_diff::{self, FileHeader};
use radicle_cli::git::unified_diff::{Encode, HunkHeader};
use radicle_cli::terminal as term;

-
use crate::cob::{HunkItem, HunkState};
+
use crate::git::{HunkDiff, HunkState};

/// Queue of items (usually hunks) left to review.
#[derive(Clone, Default)]
pub struct ReviewQueue {
    /// Hunks left to review.
-
    queue: VecDeque<(usize, HunkItem, HunkState)>,
+
    queue: VecDeque<(usize, HunkDiff, HunkState)>,
}

impl ReviewQueue {
@@ -61,14 +61,14 @@ impl ReviewQueue {

        match file {
            FileDiff::Moved(moved) => {
-
                self.add_item(HunkItem::Moved { moved }, state);
+
                self.add_item(HunkDiff::Moved { moved }, state);
            }
            FileDiff::Copied(copied) => {
-
                self.add_item(HunkItem::Copied { copied }, state);
+
                self.add_item(HunkDiff::Copied { copied }, state);
            }
            FileDiff::Added(a) => {
                self.add_item(
-
                    HunkItem::Added {
+
                    HunkDiff::Added {
                        path: a.path,
                        header: header.clone(),
                        new: a.new,
@@ -88,7 +88,7 @@ impl ReviewQueue {
            }
            FileDiff::Deleted(d) => {
                self.add_item(
-
                    HunkItem::Deleted {
+
                    HunkDiff::Deleted {
                        path: d.path,
                        header: header.clone(),
                        old: d.old,
@@ -109,7 +109,7 @@ impl ReviewQueue {
            FileDiff::Modified(m) => {
                if m.old.mode != m.new.mode {
                    self.add_item(
-
                        HunkItem::ModeChanged {
+
                        HunkDiff::ModeChanged {
                            path: m.path.clone(),
                            header: header.clone(),
                            old: m.old.clone(),
@@ -124,7 +124,7 @@ impl ReviewQueue {
                    }
                    DiffContent::Binary => {
                        self.add_item(
-
                            HunkItem::Modified {
+
                            HunkDiff::Modified {
                                path: m.path.clone(),
                                header: header.clone(),
                                old: m.old.clone(),
@@ -142,7 +142,7 @@ impl ReviewQueue {
                    } => {
                        for hunk in hunks {
                            self.add_item(
-
                                HunkItem::Modified {
+
                                HunkDiff::Modified {
                                    path: m.path.clone(),
                                    header: header.clone(),
                                    old: m.old.clone(),
@@ -155,7 +155,7 @@ impl ReviewQueue {
                        }
                        if let EofNewLine::OldMissing | EofNewLine::NewMissing = eof {
                            self.add_item(
-
                                HunkItem::EofChanged {
+
                                HunkDiff::EofChanged {
                                    path: m.path.clone(),
                                    header: header.clone(),
                                    old: m.old.clone(),
@@ -171,13 +171,13 @@ impl ReviewQueue {
        }
    }

-
    fn add_item(&mut self, item: HunkItem, state: HunkState) {
+
    fn add_item(&mut self, item: HunkDiff, state: HunkState) {
        self.queue.push_back((self.queue.len(), item, state));
    }
}

impl std::ops::Deref for ReviewQueue {
-
    type Target = VecDeque<(usize, HunkItem, HunkState)>;
+
    type Target = VecDeque<(usize, HunkDiff, HunkState)>;

    fn deref(&self) -> &Self::Target {
        &self.queue
@@ -191,7 +191,7 @@ impl std::ops::DerefMut for ReviewQueue {
}

impl Iterator for ReviewQueue {
-
    type Item = (usize, HunkItem, HunkState);
+
    type Item = (usize, HunkDiff, HunkState);

    fn next(&mut self) -> Option<Self::Item> {
        self.queue.pop_front()
@@ -206,14 +206,14 @@ pub struct FileReviewBuilder {
}

impl FileReviewBuilder {
-
    pub fn new(item: &HunkItem) -> Self {
+
    pub fn new(item: &HunkDiff) -> Self {
        Self {
            delta: 0,
            header: item.file_header(),
        }
    }

-
    pub fn set_item(&mut self, item: &HunkItem) -> &mut Self {
+
    pub fn set_item(&mut self, item: &HunkDiff) -> &mut Self {
        let header = item.file_header();
        if self.header != header {
            self.header = header;
@@ -222,7 +222,7 @@ impl FileReviewBuilder {
        self
    }

-
    pub fn item_diff(&mut self, item: &HunkItem) -> Result<git::raw::Diff, Error> {
+
    pub fn item_diff(&mut self, item: &HunkDiff) -> Result<git::raw::Diff, Error> {
        let mut buf = Vec::new();
        let mut writer = unified_diff::Writer::new(&mut buf);
        writer.encode(&self.header)?;
modified bin/commands/patch/select/imui.rs
@@ -12,7 +12,7 @@ use ratatui::Frame;
use radicle_tui as tui;

use tui::ui::im;
-
use tui::ui::im::widget::{GroupState, TableState, TextEditState, TextViewState, Window};
+
use tui::ui::im::widget::{PanesState, TableState, TextEditState, TextViewState, Window};
use tui::ui::im::Borders;
use tui::ui::im::Show;
use tui::ui::{BufferedValue, Column};
@@ -59,7 +59,7 @@ pub enum Message<'a> {
        state: TableState,
    },
    MainGroupChanged {
-
        state: GroupState,
+
        state: PanesState,
    },
    PageChanged {
        page: Page,
@@ -92,7 +92,7 @@ pub struct App<'a> {
    storage: Storage,
    mode: Mode,
    page: Page,
-
    main_group: GroupState,
+
    main_group: PanesState,
    patches: TableState,
    search: BufferedValue<TextEditState>,
    show_search: bool,
@@ -125,7 +125,7 @@ impl<'a> TryFrom<&Context> for App<'a> {
            },
            mode: context.mode.clone(),
            page: Page::Main,
-
            main_group: GroupState::new(3, Some(0)),
+
            main_group: PanesState::new(3, Some(0)),
            patches: TableState::new(Some(0)),
            search: BufferedValue::new(TextEditState {
                text: search.clone(),
@@ -180,12 +180,12 @@ impl<'a> store::Update<Message<'a>> for App<'a> {
                None
            }
            Message::ShowSearch => {
-
                self.main_group = GroupState::new(3, None);
+
                self.main_group = PanesState::new(3, None);
                self.show_search = true;
                None
            }
            Message::HideSearch { apply } => {
-
                self.main_group = GroupState::new(3, Some(0));
+
                self.main_group = PanesState::new(3, Some(0));
                self.show_search = false;

                if apply {
@@ -223,11 +223,11 @@ impl<'a> Show<Message<'a>> for App<'a> {
                    let mut page_focus = if show_search { Some(1) } else { Some(0) };
                    let mut group_focus = self.main_group.focus();

-
                    ui.group(
+
                    ui.panes(
                        Layout::vertical([Constraint::Fill(1), Constraint::Length(2)]),
                        &mut page_focus,
                        |ui| {
-
                            let group = ui.group(
+
                            let group = ui.panes(
                                im::Layout::Expandable3 { left_only: true },
                                &mut group_focus,
                                |ui| {
@@ -249,7 +249,7 @@ impl<'a> Show<Message<'a>> for App<'a> {
                            );
                            if group.response.changed {
                                ui.send_message(Message::MainGroupChanged {
-
                                    state: GroupState::new(3, group_focus),
+
                                    state: PanesState::new(3, group_focus),
                                });
                            }

modified bin/git.rs
@@ -1,8 +1,15 @@
-
use std::fs;
use std::path::Path;
+
use std::{fs, path::PathBuf};

use radicle::git;
use radicle::git::Oid;
+
use radicle_cli::git::unified_diff::{FileHeader, HunkHeader};
+
use radicle_cli::terminal;
+
use radicle_cli::terminal::highlight::Highlighter;
+
use radicle_surf::diff::{Copied, DiffFile, EofNewLine, FileStats, Hunk, Modification, Moved};
+
use ratatui::text::Line;
+

+
pub type FilePaths<'a> = (Option<(&'a Path, Oid)>, Option<(&'a Path, Oid)>);

/// Get the diff stats between two commits.
/// Should match the default output of `git diff <old> <new> --stat` exactly.
@@ -22,14 +29,6 @@ pub fn diff_stats(
    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.
@@ -70,3 +69,282 @@ impl Repo for git::raw::Repository {
            })
    }
}
+

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

+
/// 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)
+
                .map(|hi| {
+
                    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)
+
                .map(|hi| {
+
                    hi.into_iter()
+
                        .map(|line| Line::raw(line.to_string()))
+
                        .collect::<Vec<_>>()
+
                })
+
                .ok();
+
        }
+
        blobs
+
    }
+

+
    pub fn _raw(self) -> Blobs<Vec<Line<'a>>> {
+
        let mut blobs = Blobs::default();
+
        if let Some((_, Blob::Plain(content))) = &self.old {
+
            blobs.old = std::str::from_utf8(content)
+
                .map(|lines| {
+
                    lines
+
                        .lines()
+
                        .map(terminal::Line::new)
+
                        .map(|line| Line::raw(line.to_string()))
+
                        .collect::<Vec<_>>()
+
                })
+
                .ok();
+
        }
+
        if let Some((_, Blob::Plain(content))) = &self.new {
+
            blobs.new = std::str::from_utf8(content)
+
                .map(|lines| {
+
                    lines
+
                        .lines()
+
                        .map(terminal::Line::new)
+
                        .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 enum DiffStats {
+
    Hunk(HunkStats),
+
    File(FileStats),
+
}
+

+
#[derive(Default)]
+
pub struct HunkStats {
+
    added: usize,
+
    deleted: usize,
+
}
+

+
impl HunkStats {
+
    pub fn added(&self) -> usize {
+
        self.added
+
    }
+
    pub fn deleted(&self) -> usize {
+
        self.deleted
+
    }
+
}
+

+
impl From<&Hunk<Modification>> for HunkStats {
+
    fn from(hunk: &Hunk<Modification>) -> Self {
+
        let mut added = 0_usize;
+
        let mut deleted = 0_usize;
+

+
        for modification in &hunk.lines {
+
            match modification {
+
                Modification::Addition(_) => added += 1,
+
                Modification::Deletion(_) => deleted += 1,
+
                _ => {}
+
            }
+
        }
+

+
        Self { added, deleted }
+
    }
+
}
+

+
#[derive(Clone, Default, Debug, PartialEq)]
+
pub enum HunkState {
+
    #[default]
+
    Rejected,
+
    Accepted,
+
}
+

+
/// 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 HunkDiff {
+
    Added {
+
        path: PathBuf,
+
        header: FileHeader,
+
        new: DiffFile,
+
        hunk: Option<Hunk<Modification>>,
+
        _stats: Option<FileStats>,
+
    },
+
    Deleted {
+
        path: PathBuf,
+
        header: FileHeader,
+
        old: DiffFile,
+
        hunk: Option<Hunk<Modification>>,
+
        _stats: Option<FileStats>,
+
    },
+
    Modified {
+
        path: PathBuf,
+
        header: FileHeader,
+
        old: DiffFile,
+
        new: DiffFile,
+
        hunk: Option<Hunk<Modification>>,
+
        _stats: Option<FileStats>,
+
    },
+
    Moved {
+
        moved: Moved,
+
    },
+
    Copied {
+
        copied: Copied,
+
    },
+
    EofChanged {
+
        path: PathBuf,
+
        header: FileHeader,
+
        old: DiffFile,
+
        new: DiffFile,
+
        _eof: EofNewLine,
+
    },
+
    ModeChanged {
+
        path: PathBuf,
+
        header: FileHeader,
+
        old: DiffFile,
+
        new: DiffFile,
+
    },
+
}
+

+
impl HunkDiff {
+
    pub fn hunk(&self) -> Option<&Hunk<Modification>> {
+
        match self {
+
            Self::Added { hunk, .. } => hunk.as_ref(),
+
            Self::Deleted { hunk, .. } => hunk.as_ref(),
+
            Self::Modified { hunk, .. } => hunk.as_ref(),
+
            _ => None,
+
        }
+
    }
+

+
    pub fn file_header(&self) -> FileHeader {
+
        match self {
+
            Self::Added { header, .. } => header.clone(),
+
            Self::Deleted { header, .. } => header.clone(),
+
            Self::Moved { moved } => FileHeader::Moved {
+
                old_path: moved.old_path.clone(),
+
                new_path: moved.new_path.clone(),
+
            },
+
            Self::Copied { copied } => FileHeader::Copied {
+
                old_path: copied.old_path.clone(),
+
                new_path: copied.new_path.clone(),
+
            },
+
            Self::Modified { header, .. } => header.clone(),
+
            Self::EofChanged { header, .. } => header.clone(),
+
            Self::ModeChanged { header, .. } => header.clone(),
+
        }
+
    }
+

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

+
    pub fn paths(&self) -> FilePaths {
+
        match self {
+
            Self::Added { path, new, .. } => (None, Some((path, new.oid))),
+
            Self::Deleted { path, old, .. } => (Some((path, old.oid)), None),
+
            Self::Moved { moved } => (
+
                Some((&moved.old_path, moved.old.oid)),
+
                Some((&moved.new_path, moved.new.oid)),
+
            ),
+
            Self::Copied { copied } => (
+
                Some((&copied.old_path, copied.old.oid)),
+
                Some((&copied.new_path, copied.new.oid)),
+
            ),
+
            Self::Modified { path, old, new, .. } => (Some((path, old.oid)), Some((path, new.oid))),
+
            Self::EofChanged { path, old, new, .. } => {
+
                (Some((path, old.oid)), Some((path, new.oid)))
+
            }
+
            Self::ModeChanged { path, old, new, .. } => {
+
                (Some((path, old.oid)), Some((path, new.oid)))
+
            }
+
        }
+
    }
+

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

+
#[derive(Clone, Debug)]
+
pub struct StatefulHunkDiff(HunkDiff, HunkState);
+

+
impl StatefulHunkDiff {
+
    pub fn hunk(&self) -> &HunkDiff {
+
        &self.0
+
    }
+

+
    pub fn state(&self) -> &HunkState {
+
        &self.1
+
    }
+

+
    pub fn state_mut(&mut self) -> &mut HunkState {
+
        &mut self.1
+
    }
+
}
+

+
impl From<(&HunkDiff, &HunkState)> for StatefulHunkDiff {
+
    fn from(value: (&HunkDiff, &HunkState)) -> Self {
+
        Self(value.0.clone(), value.1.clone())
+
    }
+
}
modified bin/ui.rs
@@ -1,6 +1,7 @@
pub mod format;
pub mod im;
pub mod items;
+
pub mod layout;
pub mod rm;
pub mod span;

modified bin/ui/items.rs
@@ -1,5 +1,4 @@
use std::collections::HashMap;
-
use std::path::{Path, PathBuf};
use std::str::FromStr;

use nom::bytes::complete::{tag, take};
@@ -42,8 +41,7 @@ use tui::ui::utils::LineMerger;
use tui::ui::{span, Column};
use tui::ui::{ToRow, ToTree};

-
use crate::cob::{DiffStats, HunkStats, IndexedHunkItem};
-
use crate::git::{Blob, Repo};
+
use crate::git::{Blobs, DiffStats, HunkDiff, HunkStats, StatefulHunkDiff};
use crate::ui;

use super::super::git;
@@ -1094,8 +1092,8 @@ impl From<Vec<(EntryId, Comment<CodeLocation>)>> for HunkComments {
/// provide access to the underlying hunk type.
#[derive(Clone, Debug)]
pub struct HunkItem<'a> {
-
    /// The indexed, underlying hunk type.
-
    pub inner: IndexedHunkItem,
+
    /// The underlying hunk type and its current state (accepted / rejected).
+
    pub inner: StatefulHunkDiff,
    /// Raw or highlighted hunk lines. Highlighting is expensive and needs to be asynchronously.
    /// Therefor, a hunks' lines need to stored separately.
    pub lines: Blobs<Vec<Line<'a>>>,
@@ -1103,25 +1101,26 @@ pub struct HunkItem<'a> {
    pub comments: HunkComments,
}

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

-
        let path = match &item.1 {
-
            crate::cob::HunkItem::Added { path, .. } => path,
-
            crate::cob::HunkItem::Modified { path, .. } => path,
-
            crate::cob::HunkItem::Deleted { path, .. } => path,
-
            crate::cob::HunkItem::Copied { copied } => &copied.new_path,
-
            crate::cob::HunkItem::Moved { moved } => &moved.new_path,
-
            crate::cob::HunkItem::ModeChanged { path, .. } => path,
-
            crate::cob::HunkItem::EofChanged { path, .. } => path,
+
        let hunk = item.hunk();
+

+
        let path = match &hunk {
+
            HunkDiff::Added { path, .. } => path,
+
            HunkDiff::Modified { path, .. } => path,
+
            HunkDiff::Deleted { path, .. } => path,
+
            HunkDiff::Copied { copied } => &copied.new_path,
+
            HunkDiff::Moved { moved } => &moved.new_path,
+
            HunkDiff::ModeChanged { path, .. } => path,
+
            HunkDiff::EofChanged { path, .. } => path,
        };

        // TODO(erikli): Start with raw, non-highlighted lines and
        // move highlighting to separate task / thread, e.g. here:
        // `let lines = blobs.raw()`
-
        let blobs = item.1.clone().blobs(repo.raw());
+
        let blobs = hunk.clone().blobs(repo.raw());
        let lines = blobs.highlight(hi);
        let comments = review
            .comments()
@@ -1140,8 +1139,6 @@ impl<'a> From<(&Repository, &Review, &IndexedHunkItem)> for HunkItem<'a> {

impl<'a> ToRow<3> for HunkItem<'a> {
    fn to_row(&self) -> [Cell; 3] {
-
        use crate::cob::HunkItem as Item;
-

        let build_stats_spans = |stats: &DiffStats| -> Vec<Span<'_>> {
            let mut cell = vec![];

@@ -1174,18 +1171,14 @@ impl<'a> ToRow<3> for HunkItem<'a> {
            cell
        };

-
        match &self.inner {
-
            (
-
                _,
-
                Item::Added {
-
                    path,
-
                    header: _,
-
                    new: _,
-
                    hunk,
-
                    _stats: _,
-
                },
-
                state,
-
            ) => {
+
        match &self.inner.hunk() {
+
            HunkDiff::Added {
+
                path,
+
                header: _,
+
                new: _,
+
                hunk,
+
                _stats: _,
+
            } => {
                let stats = hunk.as_ref().map(HunkStats::from).unwrap_or_default();
                let stats_cell = [
                    build_stats_spans(&DiffStats::Hunk(stats)),
@@ -1194,23 +1187,21 @@ impl<'a> ToRow<3> for HunkItem<'a> {
                .concat();

                [
-
                    ui::span::hunk_state(state).into_right_aligned_line().into(),
-
                    HunkItem::pretty_path(path, false).into(),
+
                    Line::from(ui::span::hunk_state(self.inner.state()))
+
                        .right_aligned()
+
                        .into(),
+
                    Line::from(ui::span::pretty_path(path, false, false)).into(),
                    Line::from(stats_cell).right_aligned().into(),
                ]
            }
-
            (
-
                _,
-
                Item::Modified {
-
                    path,
-
                    header: _,
-
                    old: _,
-
                    new: _,
-
                    hunk,
-
                    _stats: _,
-
                },
-
                state,
-
            ) => {
+
            HunkDiff::Modified {
+
                path,
+
                header: _,
+
                old: _,
+
                new: _,
+
                hunk,
+
                _stats: _,
+
            } => {
                let stats = hunk.as_ref().map(HunkStats::from).unwrap_or_default();
                let stats_cell = [
                    build_stats_spans(&DiffStats::Hunk(stats)),
@@ -1219,22 +1210,20 @@ impl<'a> ToRow<3> for HunkItem<'a> {
                .concat();

                [
-
                    ui::span::hunk_state(state).into_right_aligned_line().into(),
-
                    HunkItem::pretty_path(path, false).into(),
+
                    Line::from(ui::span::hunk_state(self.inner.state()))
+
                        .right_aligned()
+
                        .into(),
+
                    Line::from(ui::span::pretty_path(path, false, false)).into(),
                    Line::from(stats_cell).right_aligned().into(),
                ]
            }
-
            (
-
                _,
-
                Item::Deleted {
-
                    path,
-
                    header: _,
-
                    old: _,
-
                    hunk,
-
                    _stats: _,
-
                },
-
                state,
-
            ) => {
+
            HunkDiff::Deleted {
+
                path,
+
                header: _,
+
                old: _,
+
                hunk,
+
                _stats: _,
+
            } => {
                let stats = hunk.as_ref().map(HunkStats::from).unwrap_or_default();
                let stats_cell = [
                    build_stats_spans(&DiffStats::Hunk(stats)),
@@ -1243,12 +1232,14 @@ impl<'a> ToRow<3> for HunkItem<'a> {
                .concat();

                [
-
                    ui::span::hunk_state(state).into_right_aligned_line().into(),
-
                    HunkItem::pretty_path(path, true).into(),
+
                    Line::from(ui::span::hunk_state(self.inner.state()))
+
                        .right_aligned()
+
                        .into(),
+
                    Line::from(ui::span::pretty_path(path, false, false)).into(),
                    Line::from(stats_cell).right_aligned().into(),
                ]
            }
-
            (_, Item::Copied { copied }, state) => {
+
            HunkDiff::Copied { copied } => {
                let stats = copied.diff.stats().copied().unwrap_or_default();
                let stats_cell = [
                    build_stats_spans(&DiffStats::File(stats)),
@@ -1257,12 +1248,14 @@ impl<'a> ToRow<3> for HunkItem<'a> {
                .concat();

                [
-
                    ui::span::hunk_state(state).into_right_aligned_line().into(),
-
                    HunkItem::pretty_path(&copied.new_path, false).into(),
+
                    Line::from(ui::span::hunk_state(self.inner.state()))
+
                        .right_aligned()
+
                        .into(),
+
                    Line::from(ui::span::pretty_path(&copied.new_path, false, false)).into(),
                    Line::from(stats_cell).right_aligned().into(),
                ]
            }
-
            (_, Item::Moved { moved }, state) => {
+
            HunkDiff::Moved { moved } => {
                let stats = moved.diff.stats().copied().unwrap_or_default();
                let stats_cell = [
                    build_stats_spans(&DiffStats::File(stats)),
@@ -1271,44 +1264,40 @@ impl<'a> ToRow<3> for HunkItem<'a> {
                .concat();

                [
-
                    ui::span::hunk_state(state).into_right_aligned_line().into(),
-
                    HunkItem::pretty_path(&moved.new_path, false).into(),
+
                    Line::from(ui::span::hunk_state(self.inner.state()))
+
                        .right_aligned()
+
                        .into(),
+
                    Line::from(ui::span::pretty_path(&moved.new_path, false, false)).into(),
                    Line::from(stats_cell).right_aligned().into(),
                ]
            }
-
            (
-
                _,
-
                Item::EofChanged {
-
                    path,
-
                    header: _,
-
                    old: _,
-
                    new: _,
-
                    _eof: _,
-
                },
-
                state,
-
            ) => [
-
                ui::span::hunk_state(state).into_right_aligned_line().into(),
-
                HunkItem::pretty_path(path, false).into(),
-
                span::default("EOF ")
-
                    .light_blue()
-
                    .into_right_aligned_line()
+
            HunkDiff::EofChanged {
+
                path,
+
                header: _,
+
                old: _,
+
                new: _,
+
                _eof: _,
+
            } => [
+
                Line::from(ui::span::hunk_state(self.inner.state()))
+
                    .right_aligned()
+
                    .into(),
+
                Line::from(ui::span::pretty_path(path, false, false)).into(),
+
                Line::from(span::default("EOF ").light_blue())
+
                    .right_aligned()
                    .into(),
            ],
-
            (
-
                _,
-
                Item::ModeChanged {
-
                    path,
-
                    header: _,
-
                    old: _,
-
                    new: _,
-
                },
-
                state,
-
            ) => [
-
                ui::span::hunk_state(state).into_right_aligned_line().into(),
-
                HunkItem::pretty_path(path, false).into(),
-
                span::default("FM ")
-
                    .light_blue()
-
                    .into_right_aligned_line()
+
            HunkDiff::ModeChanged {
+
                path,
+
                header: _,
+
                old: _,
+
                new: _,
+
            } => [
+
                Line::from(ui::span::hunk_state(self.inner.state()))
+
                    .right_aligned()
+
                    .into(),
+
                Line::from(ui::span::pretty_path(path, false, false)).into(),
+
                Line::from(span::default("FM ").light_blue())
+
                    .right_aligned()
                    .into(),
            ],
        }
@@ -1316,34 +1305,6 @@ impl<'a> ToRow<3> for HunkItem<'a> {
}

impl<'a> HunkItem<'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.iter()
-
                .take(path.iter().count() - 1)
-
                .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().as_ref()).crossed_out()
-
                } else {
-
                    span::default(file.to_string_lossy().as_ref())
-
                },
-
                span::default(" "),
-
                span::default(&path.join(&String::from("/")).to_string()).dark_gray(),
-
            ]
-
            .to_vec(),
-
        );
-
        line
-
    }
-
}
-

-
impl<'a> HunkItem<'a> {
    pub fn header(&self) -> Vec<Column<'a>> {
        let comment_tag = if !self.comments.is_empty() {
            let count = self.comments.len();
@@ -1358,19 +1319,15 @@ impl<'a> HunkItem<'a> {
            span::blank()
        };

-
        match &self.inner {
-
            (
-
                _,
-
                crate::cob::HunkItem::Added {
-
                    path,
-
                    header: _,
-
                    new: _,
-
                    hunk: _,
-
                    _stats: _,
-
                },
-
                _,
-
            ) => {
-
                let path = HunkItem::pretty_path(path, false);
+
        match &self.inner.hunk() {
+
            HunkDiff::Added {
+
                path,
+
                header: _,
+
                new: _,
+
                hunk: _,
+
                _stats: _,
+
            } => {
+
                let path = Line::from(ui::span::pretty_path(path, false, true));
                let header = [
                    Column::new("", Constraint::Length(0)),
                    Column::new(path.clone(), Constraint::Length(path.width() as u16)),
@@ -1390,19 +1347,16 @@ impl<'a> HunkItem<'a> {

                header.to_vec()
            }
-
            (
-
                _,
-
                crate::cob::HunkItem::Modified {
-
                    path,
-
                    header: _,
-
                    old: _,
-
                    new: _,
-
                    hunk: _,
-
                    _stats: _,
-
                },
-
                _,
-
            ) => {
-
                let path = HunkItem::pretty_path(path, false);
+

+
            HunkDiff::Modified {
+
                path,
+
                header: _,
+
                old: _,
+
                new: _,
+
                hunk: _,
+
                _stats: _,
+
            } => {
+
                let path = Line::from(ui::span::pretty_path(path, false, true));
                let header = [
                    Column::new("", Constraint::Length(0)),
                    Column::new(path.clone(), Constraint::Length(path.width() as u16)),
@@ -1422,18 +1376,15 @@ impl<'a> HunkItem<'a> {

                header.to_vec()
            }
-
            (
-
                _,
-
                crate::cob::HunkItem::Deleted {
-
                    path,
-
                    header: _,
-
                    old: _,
-
                    hunk: _,
-
                    _stats: _,
-
                },
-
                _,
-
            ) => {
-
                let path = HunkItem::pretty_path(path, true);
+

+
            HunkDiff::Deleted {
+
                path,
+
                header: _,
+
                old: _,
+
                hunk: _,
+
                _stats: _,
+
            } => {
+
                let path = Line::from(ui::span::pretty_path(path, true, true));
                let header = [
                    Column::new("", Constraint::Length(0)),
                    Column::new(path.clone(), Constraint::Length(path.width() as u16)),
@@ -1453,12 +1404,12 @@ impl<'a> HunkItem<'a> {

                header.to_vec()
            }
-
            (_, crate::cob::HunkItem::Copied { copied }, _) => {
+
            HunkDiff::Copied { copied } => {
                let path = Line::from(
                    [
-
                        HunkItem::pretty_path(&copied.old_path, false).spans,
+
                        ui::span::pretty_path(&copied.old_path, false, true),
                        [span::default(" -> ")].to_vec(),
-
                        HunkItem::pretty_path(&copied.new_path, false).spans,
+
                        ui::span::pretty_path(&copied.new_path, false, true),
                    ]
                    .concat()
                    .to_vec(),
@@ -1478,12 +1429,12 @@ impl<'a> HunkItem<'a> {

                header.to_vec()
            }
-
            (_, crate::cob::HunkItem::Moved { moved }, _) => {
+
            HunkDiff::Moved { moved } => {
                let path = Line::from(
                    [
-
                        HunkItem::pretty_path(&moved.old_path, false).spans,
+
                        ui::span::pretty_path(&moved.old_path, false, true),
                        [span::default(" -> ")].to_vec(),
-
                        HunkItem::pretty_path(&moved.new_path, false).spans,
+
                        ui::span::pretty_path(&moved.new_path, false, true),
                    ]
                    .concat()
                    .to_vec(),
@@ -1503,18 +1454,15 @@ impl<'a> HunkItem<'a> {

                header.to_vec()
            }
-
            (
-
                _,
-
                crate::cob::HunkItem::EofChanged {
-
                    path,
-
                    header: _,
-
                    old: _,
-
                    new: _,
-
                    _eof: _,
-
                },
-
                _,
-
            ) => {
-
                let path = HunkItem::pretty_path(path, false);
+

+
            HunkDiff::EofChanged {
+
                path,
+
                header: _,
+
                old: _,
+
                new: _,
+
                _eof: _,
+
            } => {
+
                let path = Line::from(ui::span::pretty_path(path, false, true));
                let header = [
                    Column::new("", Constraint::Length(0)),
                    Column::new(path.clone(), Constraint::Length(path.width() as u16)),
@@ -1529,17 +1477,13 @@ impl<'a> HunkItem<'a> {

                header.to_vec()
            }
-
            (
-
                _,
-
                crate::cob::HunkItem::ModeChanged {
-
                    path,
-
                    header: _,
-
                    old: _,
-
                    new: _,
-
                },
-
                _,
-
            ) => {
-
                let path = HunkItem::pretty_path(path, false);
+
            HunkDiff::ModeChanged {
+
                path,
+
                header: _,
+
                old: _,
+
                new: _,
+
            } => {
+
                let path = Line::from(ui::span::pretty_path(path, false, true));
                let header = [
                    Column::new("", Constraint::Length(0)),
                    Column::new(path.clone(), Constraint::Length(path.width() as u16)),
@@ -1558,12 +1502,10 @@ impl<'a> HunkItem<'a> {
    }

    pub fn hunk_text(&'a self) -> Option<Text<'a>> {
-
        use crate::cob::HunkItem;
-

-
        match &self.inner {
-
            (_, HunkItem::Added { hunk, .. }, _)
-
            | (_, HunkItem::Modified { hunk, .. }, _)
-
            | (_, HunkItem::Deleted { hunk, .. }, _) => {
+
        match &self.inner.hunk() {
+
            HunkDiff::Added { hunk, .. }
+
            | HunkDiff::Modified { hunk, .. }
+
            | HunkDiff::Deleted { hunk, .. } => {
                let mut lines = hunk
                    .as_ref()
                    .map(|hunk| Text::from(hunk.to_text(&self.lines)));
@@ -1638,103 +1580,6 @@ impl<'a> HunkItem<'a> {
    }
}

-
/// 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)
-
                .map(|hi| {
-
                    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)
-
                .map(|hi| {
-
                    hi.into_iter()
-
                        .map(|line| Line::raw(line.to_string()))
-
                        .collect::<Vec<_>>()
-
                })
-
                .ok();
-
        }
-
        blobs
-
    }
-

-
    pub fn _raw(self) -> Blobs<Vec<Line<'a>>> {
-
        let mut blobs = Blobs::default();
-
        if let Some((_, Blob::Plain(content))) = &self.old {
-
            blobs.old = std::str::from_utf8(content)
-
                .map(|lines| {
-
                    lines
-
                        .lines()
-
                        .map(terminal::Line::new)
-
                        .map(|line| Line::raw(line.to_string()))
-
                        .collect::<Vec<_>>()
-
                })
-
                .ok();
-
        }
-
        if let Some((_, Blob::Plain(content))) = &self.new {
-
            blobs.new = std::str::from_utf8(content)
-
                .map(|lines| {
-
                    lines
-
                        .lines()
-
                        .map(terminal::Line::new)
-
                        .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> {
added bin/ui/layout.rs
@@ -0,0 +1,17 @@
+
use ratatui::layout::{Constraint, Layout};
+

+
pub fn page() -> Layout {
+
    Layout::vertical([
+
        Constraint::Fill(1),
+
        Constraint::Length(1),
+
        Constraint::Length(1),
+
    ])
+
}
+

+
pub fn container() -> Layout {
+
    Layout::vertical([Constraint::Length(3), Constraint::Min(1)])
+
}
+

+
pub fn list_item() -> Layout {
+
    Layout::horizontal([Constraint::Ratio(1, 3), Constraint::Ratio(2, 3)])
+
}
modified bin/ui/span.rs
@@ -1,6 +1,9 @@
+
use std::path::Path;
+

+
use ratatui::prelude::Stylize;
use ratatui::text::Span;

-
use crate::cob::HunkState;
+
use crate::git::HunkState;

use radicle_tui as tui;

@@ -13,3 +16,33 @@ pub fn hunk_state(state: &HunkState) -> Span<'static> {
        HunkState::Rejected => span::negative("✗"),
    }
}
+

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

+
    let mut spans = vec![];
+

+
    let filename = if crossed_out {
+
        span::default(file.to_string_lossy().as_ref()).crossed_out()
+
    } else {
+
        span::default(file.to_string_lossy().as_ref())
+
    };
+
    spans.push(filename);
+

+
    if show_path {
+
        spans.extend([
+
            span::default(" "),
+
            span::default(&path.join(&String::from("/")).to_string()).dark_gray(),
+
        ]);
+
    }
+

+
    spans
+
}
modified src/ui/im.rs
@@ -452,7 +452,7 @@ impl<M> Ui<M>
where
    M: Clone,
{
-
    pub fn group<R>(
+
    pub fn panes<R>(
        &mut self,
        layout: impl Into<Layout>,
        focus: &mut Option<usize>,
@@ -470,7 +470,7 @@ where
            ..self.child_ui(area, layout)
        };

-
        widget::Group::new(len, focus).show(&mut child_ui, add_contents)
+
        widget::Panes::new(len, focus).show(&mut child_ui, add_contents)
    }

    pub fn composite<R>(
modified src/ui/im/widget.rs
@@ -61,12 +61,12 @@ impl Window {
}

#[derive(Clone, Debug)]
-
pub struct GroupState {
+
pub struct PanesState {
    len: usize,
    focus: Option<usize>,
}

-
impl GroupState {
+
impl PanesState {
    pub fn new(len: usize, focus: Option<usize>) -> Self {
        Self { len, focus }
    }
@@ -94,12 +94,12 @@ impl GroupState {
    }
}

-
pub struct Group<'a> {
+
pub struct Panes<'a> {
    focus: &'a mut Option<usize>,
    len: usize,
}

-
impl<'a> Group<'a> {
+
impl<'a> Panes<'a> {
    pub fn new(len: usize, focus: &'a mut Option<usize>) -> Self {
        Self { len, focus }
    }
@@ -125,7 +125,7 @@ impl<'a> Group<'a> {
    {
        let mut response = Response::default();

-
        let mut state = GroupState {
+
        let mut state = PanesState {
            focus: *self.focus,
            len: self.len,
        };