Radish alpha
r
rad:z39mP9rQAaGmERfUMPULfPUi473tY
Radicle terminal user interface
Radicle
Git
Implement `patch review`
Merged did:key:z6MkgFq6...nBGz opened 1 year ago

This adds a new interface for reviewing patch revisions (). It starts a new or resumes an existing review and provides an UX to add comments, accept hunks or reject them.

Reviews need to be finalized via rad patch review [--accept | --reject] <id>.

Known issues / future improvements

  • startup is slow on large patches: highlighting is expensive and needs to be done asynchronously
  • single hunks can’t be rejected once they were accepted: current UX is to discard all that were already accepted and start out new
  • existing comments will not be shown when adding new ones in the editor
19 files changed +3251 -84 d819a2e8 35850c02
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",
]
@@ -735,6 +748,18 @@ dependencies = [
]

[[package]]
+
name = "escargot"
+
version = "0.5.12"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c000f23e9d459aef148b7267e02b03b94a0aaacf4ec64c65612f67e02f525fb6"
+
dependencies = [
+
 "log",
+
 "once_cell",
+
 "serde",
+
 "serde_json",
+
]
+

+
[[package]]
name = "fastrand"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1562,9 +1587,9 @@ checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de"

[[package]]
name = "pretty_assertions"
-
version = "1.4.0"
+
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "af7cee1a6c8a5b9208b3cb1061f10c0cb689087b3d8ce85fb9d2dd7a29b6ba66"
+
checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d"
dependencies = [
 "diff",
 "yansi",
@@ -1613,6 +1638,15 @@ dependencies = [
]

[[package]]
+
name = "qcheck"
+
version = "1.0.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b439bd4242da51d62d18c95e6a6add749346756b0d1a587dfd0cc22fa6b5f3f0"
+
dependencies = [
+
 "rand",
+
]
+

+
[[package]]
name = "quote"
version = "1.0.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1624,8 +1658,7 @@ dependencies = [
[[package]]
name = "radicle"
version = "0.13.0"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "4a818569c11f1bac56f38b002d778ce8ec92e312024b9aebcd68bad5dee6a465"
+
source = "git+https://seed.radicle.xyz/z3gqcJUoA1n9HaHKufZs5FCSGazv5.git?branch=master#0c9a7419dcfd265b6c1efe85c915d126feaef649"
dependencies = [
 "amplify",
 "base64",
@@ -1641,6 +1674,7 @@ dependencies = [
 "multibase",
 "nonempty",
 "once_cell",
+
 "qcheck",
 "radicle-cob",
 "radicle-crypto",
 "radicle-git-ext",
@@ -1657,8 +1691,7 @@ dependencies = [
[[package]]
name = "radicle-cli"
version = "0.11.0"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "2eb296daeb53927f63784a9b91b45b3a8d72a4aa327c092787e8724b19b81b8e"
+
source = "git+https://seed.radicle.xyz/z3gqcJUoA1n9HaHKufZs5FCSGazv5.git?branch=master#0c9a7419dcfd265b6c1efe85c915d126feaef649"
dependencies = [
 "anyhow",
 "chrono",
@@ -1699,12 +1732,13 @@ dependencies = [

[[package]]
name = "radicle-cli-test"
-
version = "0.1.1"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "df187ee120e2dfc949bfae6b1acbe8b8d815167ba5229a312cdabfeb4952bc27"
+
version = "0.10.0"
+
source = "git+https://seed.radicle.xyz/z3gqcJUoA1n9HaHKufZs5FCSGazv5.git?branch=master#0c9a7419dcfd265b6c1efe85c915d126feaef649"
dependencies = [
+
 "escargot",
 "log",
 "pretty_assertions",
+
 "radicle",
 "shlex",
 "snapbox",
 "thiserror",
@@ -1713,8 +1747,7 @@ dependencies = [
[[package]]
name = "radicle-cob"
version = "0.12.0"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "d4fac94999d8ffb6e88674bee487b080b69bbc9fb1b439ebfa51481ede1a17b3"
+
source = "git+https://seed.radicle.xyz/z3gqcJUoA1n9HaHKufZs5FCSGazv5.git?branch=master#0c9a7419dcfd265b6c1efe85c915d126feaef649"
dependencies = [
 "fastrand",
 "git2",
@@ -1732,13 +1765,14 @@ dependencies = [
[[package]]
name = "radicle-crypto"
version = "0.11.0"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "d1d6a67969719841ad06049597006368eb4238ca63a02d20207654dfd1d2d6ad"
+
source = "git+https://seed.radicle.xyz/z3gqcJUoA1n9HaHKufZs5FCSGazv5.git?branch=master#0c9a7419dcfd265b6c1efe85c915d126feaef649"
dependencies = [
 "amplify",
 "cyphernet",
 "ec25519",
+
 "fastrand",
 "multibase",
+
 "qcheck",
 "radicle-git-ext",
 "radicle-ssh",
 "serde",
@@ -1751,8 +1785,7 @@ dependencies = [
[[package]]
name = "radicle-dag"
version = "0.9.0"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "c2a678c3049a88ae6a34dd9f52ea9a5f9f066a0af63466b75cf8c48840303067"
+
source = "git+https://seed.radicle.xyz/z3gqcJUoA1n9HaHKufZs5FCSGazv5.git?branch=master#0c9a7419dcfd265b6c1efe85c915d126feaef649"
dependencies = [
 "fastrand",
]
@@ -1782,10 +1815,18 @@ dependencies = [
]

[[package]]
+
name = "radicle-signals"
+
version = "0.10.0"
+
source = "git+https://seed.radicle.xyz/z3gqcJUoA1n9HaHKufZs5FCSGazv5.git?branch=master#0c9a7419dcfd265b6c1efe85c915d126feaef649"
+
dependencies = [
+
 "crossbeam-channel",
+
 "libc",
+
]
+

+
[[package]]
name = "radicle-ssh"
version = "0.9.0"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "fbee758010fb64482be4b18591fbeb3cbc15b16450d143edf4edb5484c7366c6"
+
source = "git+https://seed.radicle.xyz/z3gqcJUoA1n9HaHKufZs5FCSGazv5.git?branch=master#0c9a7419dcfd265b6c1efe85c915d126feaef649"
dependencies = [
 "byteorder",
 "log",
@@ -1821,8 +1862,7 @@ dependencies = [
[[package]]
name = "radicle-term"
version = "0.11.0"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "d2cf3256980e3fddcd135f6e755022df8b385b842cdcbbfce059f47e87caec18"
+
source = "git+https://seed.radicle.xyz/z3gqcJUoA1n9HaHKufZs5FCSGazv5.git?branch=master#0c9a7419dcfd265b6c1efe85c915d126feaef649"
dependencies = [
 "anstyle-query",
 "anyhow",
@@ -1831,7 +1871,7 @@ dependencies = [
 "inquire",
 "libc",
 "once_cell",
-
 "radicle-signals",
+
 "radicle-signals 0.10.0 (git+https://seed.radicle.xyz/z3gqcJUoA1n9HaHKufZs5FCSGazv5.git?branch=master)",
 "shlex",
 "termion 3.0.0",
 "thiserror",
@@ -1844,6 +1884,7 @@ dependencies = [
name = "radicle-tui"
version = "0.5.1"
dependencies = [
+
 "ansi-to-tui",
 "anyhow",
 "fuzzy-matcher",
 "homedir",
@@ -1853,9 +1894,10 @@ dependencies = [
 "libc",
 "log",
 "nom",
+
 "pretty_assertions",
 "radicle",
 "radicle-cli",
-
 "radicle-signals",
+
 "radicle-signals 0.10.0 (registry+https://github.com/rust-lang/crates.io-index)",
 "radicle-surf",
 "radicle-term",
 "ratatui",
@@ -2181,6 +2223,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"
@@ -3228,9 +3276,9 @@ dependencies = [

[[package]]
name = "yansi"
-
version = "0.5.1"
+
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec"
+
checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"

[[package]]
name = "zerocopy"
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" }
@@ -31,9 +32,12 @@ lazy_static = { version = "1.5.0" }
libc = { version = "^0.2" }
log = { version = "0.4.19" }
nom = { version = "^7.1.0" }
-
radicle = { version = "0.13.0" }
-
radicle-term = { version = "0.11.0" }
-
radicle-cli = { version = "0.11.0" }
+
# radicle = { version = "0.13.0" }
+
# radicle-term = { version = "0.11.0" }
+
# radicle-cli = { version = "0.11.0" }
+
radicle = { git = "https://seed.radicle.xyz/z3gqcJUoA1n9HaHKufZs5FCSGazv5.git", branch = "master", package = "radicle" }
+
radicle-term = { git = "https://seed.radicle.xyz/z3gqcJUoA1n9HaHKufZs5FCSGazv5.git", branch = "master", package = "radicle-term" }
+
radicle-cli = { git = "https://seed.radicle.xyz/z3gqcJUoA1n9HaHKufZs5FCSGazv5.git", branch = "master", package = "radicle-cli" }
radicle-surf = { version = "0.22.0" }
radicle-signals = { version = "0.10.0" }
ratatui = { version = "0.27.0", default-features = false, features = ["all-widgets", "termion"] }
@@ -50,3 +54,6 @@ tokio = { version = "1.32.0", features = ["full"] }
tokio-stream = { version = "0.1.14" }
tui-textarea = { version = "0.5.1", default-features = false, features = ["termion"] }
tui-tree-widget = { version = "0.21.0" }
+

+
[dev-dependencies]
+
pretty_assertions = "^1.4.1"

\ No newline at end of file
modified README.md
@@ -115,6 +115,8 @@ Both commands will call into `rad-tui`, process its output and call `rad` accord

#### Interfaces

+
##### Selection
+

Select a patch, an issue or a notification and an operation:

```
@@ -133,6 +135,14 @@ Select a patch, an issue or a notification only and return its id:
rad-tui <patch | issue | inbox> select --mode id
```

+
##### Patch
+

+
Review a patch revision:
+
```
+
rad-tui patch review <id>
+
```
+
> **Note:** When the review is done, it needs to be finalized via `rad patch review [--accept | --reject] <id>`.
+

#### Output

All interfaces return a common JSON object on `stderr` that reflects the choices made by the user, e.g.:
modified bin/cob.rs
@@ -4,11 +4,27 @@ 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![];
@@ -38,3 +54,154 @@ pub fn parse_assignees(input: String) -> Result<Vec<Did>> {

    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/cob/patch.rs
@@ -4,9 +4,10 @@ use std::fmt::Write as _;
use anyhow::Result;

use radicle::cob::patch::{Patch, PatchId};
+
use radicle::crypto::Signer;
use radicle::identity::Did;
use radicle::patch::cache::Patches;
-
use radicle::patch::Status;
+
use radicle::patch::{Review, ReviewId, Revision, Status};
use radicle::storage::git::Repository;
use radicle::Profile;

@@ -80,12 +81,22 @@ pub fn all(profile: &Profile, repository: &Repository) -> Result<Vec<(PatchId, P
    Ok(patches.flatten().collect())
}

-
#[allow(dead_code)]
pub fn find(profile: &Profile, repository: &Repository, id: &PatchId) -> Result<Option<Patch>> {
    let cache = profile.patches(repository)?;
    Ok(cache.get(id)?)
}

+
pub fn find_review<'a, G: Signer>(
+
    patch: &'a Patch,
+
    revision: &Revision,
+
    signer: &G,
+
) -> Option<(ReviewId, &'a Review)> {
+
    patch
+
        .reviews_of(revision.id())
+
        .find(|(_, review)| review.author().public_key() == signer.public_key())
+
        .map(|(id, review)| (*id, review))
+
}
+

#[cfg(test)]
mod tests {
    use std::str::FromStr;
modified bin/commands/patch.rs
@@ -1,5 +1,7 @@
#[path = "patch/common.rs"]
mod common;
+
#[path = "patch/review.rs"]
+
mod review;
#[path = "patch/select.rs"]
mod select;

@@ -7,11 +9,14 @@ use std::ffi::OsString;

use anyhow::anyhow;

+
use radicle::cob::ObjectId;
use radicle::identity::RepoId;
-
use radicle::patch::Status;
+
use radicle::patch::{Patch, Revision, RevisionId, Status};

+
use radicle::storage::git::Repository;
+
use radicle_cli::git::Rev;
use radicle_cli::terminal;
-
use radicle_cli::terminal::args::{Args, Error, Help};
+
use radicle_cli::terminal::args::{string, Args, Error, Help};

use crate::cob::patch;
use crate::cob::patch::Filter;
@@ -54,11 +59,13 @@ pub struct Options {

pub enum Operation {
    Select { opts: SelectOptions },
+
    Review { opts: ReviewOptions },
}

#[derive(PartialEq, Eq)]
pub enum OperationName {
    Select,
+
    Review,
}

#[derive(Debug, Default, Clone, PartialEq, Eq)]
@@ -67,6 +74,37 @@ pub struct SelectOptions {
    filter: patch::Filter,
}

+
#[derive(Debug, Clone, PartialEq, Eq)]
+
pub struct ReviewOptions {
+
    patch_id: Option<Rev>,
+
    revision_id: Option<Rev>,
+
}
+

+
impl ReviewOptions {
+
    pub fn revision_or_latest<'a>(
+
        &'a self,
+
        patch: &'a Patch,
+
        repo: &Repository,
+
    ) -> anyhow::Result<(RevisionId, &Revision)> {
+
        let revision_id = self
+
            .revision_id
+
            .as_ref()
+
            .map(|rev| rev.resolve::<radicle::git::Oid>(&repo.backend))
+
            .transpose()?
+
            .map(radicle::cob::patch::RevisionId::from);
+

+
        match revision_id {
+
            Some(id) => Ok((
+
                id,
+
                patch
+
                    .revision(&id)
+
                    .ok_or_else(|| anyhow!("Patch revision `{id}` not found"))?,
+
            )),
+
            None => Ok((patch.latest().0, patch.latest().1)),
+
        }
+
    }
+
}
+

impl Args for Options {
    fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
        use lexopt::prelude::*;
@@ -75,6 +113,8 @@ impl Args for Options {
        let mut op: Option<OperationName> = None;
        let mut repo = None;
        let mut select_opts = SelectOptions::default();
+
        let mut patch_id = None;
+
        let mut revision_id = None;

        while let Some(arg) = parser.next()? {
            match arg {
@@ -116,18 +156,27 @@ impl Args for Options {
                        .filter
                        .with_author(terminal::args::did(&parser.value()?)?);
                }
-

                Long("repo") => {
                    let val = parser.value()?;
                    let rid = terminal::args::rid(&val)?;

                    repo = Some(rid);
                }
+
                Long("revision") => {
+
                    let val = parser.value()?;
+
                    let rev_id = terminal::args::rev(&val)?;

+
                    revision_id = Some(rev_id);
+
                }
                Value(val) if op.is_none() => match val.to_string_lossy().as_ref() {
                    "select" => op = Some(OperationName::Select),
+
                    "review" => op = Some(OperationName::Review),
                    unknown => anyhow::bail!("unknown operation '{}'", unknown),
                },
+
                Value(val) if patch_id.is_none() => {
+
                    let val = string(&val);
+
                    patch_id = Some(Rev::from(val));
+
                }
                _ => return Err(anyhow!(arg.unexpected())),
            }
        }
@@ -137,6 +186,12 @@ impl Args for Options {
        }

        let op = match op.ok_or_else(|| anyhow!("an operation must be provided"))? {
+
            OperationName::Review => Operation::Review {
+
                opts: ReviewOptions {
+
                    patch_id,
+
                    revision_id,
+
                },
+
            },
            OperationName::Select => Operation::Select { opts: select_opts },
        };
        Ok((Options { op, repo }, vec![]))
@@ -150,35 +205,200 @@ pub async fn run(options: Options, ctx: impl terminal::Context) -> anyhow::Resul
    let (_, rid) = radicle::rad::cwd()
        .map_err(|_| anyhow!("this command must be run in the context of a project"))?;

+
    if let Err(err) = crate::log::enable() {
+
        println!("{}", err);
+
    }
+

    match options.op {
        Operation::Select { opts } => {
            let profile = ctx.profile()?;
            let rid = options.repo.unwrap_or(rid);
-
            let repository = profile.storage.repository(rid).unwrap();
-

-
            if let Err(err) = crate::log::enable() {
-
                println!("{}", err);
-
            }
-
            log::info!("Starting patch selection interface in project {}..", rid);

-
            let context = select::Context {
-
                profile,
-
                repository,
-
                mode: opts.mode,
-
                filter: opts.filter.clone(),
-
            };
-
            let output = select::App::new(context, true).run().await?;
-

-
            let output = output
+
            // Run TUI with patch selection interface
+
            let selection = interface::select(opts, profile, rid).await?;
+
            let selection = selection
                .map(|o| serde_json::to_string(&o).unwrap_or_default())
                .unwrap_or_default();

-
            log::info!("About to print to `stderr`: {}", output);
+
            log::info!("About to print to `stderr`: {}", selection);
            log::info!("Exiting patch selection interface..");

-
            eprint!("{output}");
+
            eprint!("{selection}");
+
        }
+
        Operation::Review { ref opts } => {
+
            log::info!("Starting patch review interface in project {rid}..");
+

+
            let profile = ctx.profile()?;
+
            let rid = options.repo.unwrap_or(rid);
+
            let repo = profile.storage.repository(rid).unwrap();
+

+
            let patch_id: ObjectId = if let Some(patch_id) = &opts.patch_id {
+
                patch_id.resolve(&repo.backend)?
+
            } else {
+
                anyhow::bail!("a patch must be provided");
+
            };
+

+
            // Run TUI with patch review interface
+
            interface::review(opts.clone(), profile, rid, patch_id).await?;
        }
    }

    Ok(())
}
+

+
mod interface {
+
    use anyhow::anyhow;
+

+
    use radicle::cob;
+
    use radicle::cob::ObjectId;
+
    use radicle::crypto::Signer;
+
    use radicle::identity::RepoId;
+
    use radicle::patch::PatchId;
+
    use radicle::patch::Verdict;
+
    use radicle::storage::git::cob::DraftStore;
+
    use radicle::storage::{ReadStorage, WriteRepository};
+
    use radicle::Profile;
+

+
    use radicle_cli::terminal;
+

+
    use radicle_tui::Selection;
+

+
    use crate::cob::patch;
+
    use crate::tui_patch::review::builder::CommentBuilder;
+
    use crate::tui_patch::review::ReviewAction;
+
    use crate::tui_patch::select;
+

+
    use super::review;
+
    use super::review::builder::{Brain, ReviewBuilder};
+
    use super::{ReviewOptions, SelectOptions};
+

+
    pub async fn select(
+
        opts: SelectOptions,
+
        profile: Profile,
+
        rid: RepoId,
+
    ) -> anyhow::Result<Option<Selection<ObjectId>>> {
+
        let repository = profile.storage.repository(rid).unwrap();
+

+
        log::info!("Starting patch selection interface in project {}..", rid);
+

+
        let context = select::Context {
+
            profile,
+
            repository,
+
            mode: opts.mode,
+
            filter: opts.filter.clone(),
+
        };
+

+
        select::App::new(context, true).run().await
+
    }
+

+
    pub async fn review(
+
        opts: ReviewOptions,
+
        profile: Profile,
+
        rid: RepoId,
+
        patch_id: PatchId,
+
    ) -> anyhow::Result<()> {
+
        let repo = profile.storage.repository(rid).unwrap();
+
        let signer = terminal::signer(&profile)?;
+

+
        let patch = patch::find(&profile, &repo, &patch_id)?
+
            .ok_or_else(|| anyhow!("Patch `{patch_id}` not found"))?;
+

+
        let (_, revision) = opts.revision_or_latest(&patch, &repo)?;
+

+
        let brain = Brain::load_or_new(patch_id, revision, repo.raw(), &signer)?;
+
        let hunks = ReviewBuilder::new(&repo).hunks(&brain, revision)?;
+

+
        let drafts = DraftStore::new(&repo, *signer.public_key());
+
        let mut patches = cob::patch::Cache::no_cache(&drafts)?;
+
        let mut patch = patches.get_mut(&patch_id)?;
+

+
        if let Some(review) = revision.review_by(signer.public_key()) {
+
            // Review already finalized. Do nothing and warn.
+
            terminal::warning(format!(
+
                "Review ({}) already finalized. Exiting.",
+
                review.id()
+
            ));
+

+
            return Ok(());
+
        };
+

+
        if let Some((id, _)) = patch::find_review(&patch, revision, &signer) {
+
            // Review already started, resume.
+
            log::info!("Resuming review {id}..");
+
        } else {
+
            // No review to resume, start a new one.
+
            let id = patch.review(
+
                revision.id(),
+
                // This is amended before the review is finalized, if all hunks are
+
                // accepted. We can't set this to `None`, as that will be invalid without
+
                // a review summary.
+
                Some(Verdict::Reject),
+
                None,
+
                vec![],
+
                &signer,
+
            )?;
+

+
            log::info!("Starting new review {id}..");
+
        }
+

+
        loop {
+
            // Reload review
+
            let (review_id, review) = patch::find_review(&patch, revision, &signer)
+
                .ok_or_else(|| anyhow!("Could not find review."))?;
+

+
            let selection = review::Tui::new(
+
                patch_id,
+
                patch.title().to_string(),
+
                revision.clone(),
+
                review.clone(),
+
                hunks.clone(),
+
                profile.clone(),
+
                rid,
+
            )
+
            .run()
+
            .await?;
+

+
            log::info!("Received selection from TUI: {:?}", selection);
+

+
            if let Some(selection) = selection.as_ref() {
+
                match selection.action {
+
                    ReviewAction::Comment => {
+
                        let hunk = selection
+
                            .hunk
+
                            .ok_or_else(|| anyhow!("expected a selected hunk"))?;
+
                        let (_, item, _) = hunks
+
                            .get(hunk)
+
                            .ok_or_else(|| anyhow!("expected a hunk to comment on"))?;
+

+
                        let (old, new) = item.paths();
+
                        let path = old.or(new);
+

+
                        if let (Some(hunk), Some((path, _))) = (item.hunk(), path) {
+
                            let builder = CommentBuilder::new(revision.head(), path.to_path_buf());
+
                            let comments = builder.edit(hunk)?;
+

+
                            patch.transaction("Review comments", &signer, |tx| {
+
                                for comment in comments {
+
                                    tx.review_comment(
+
                                        review_id,
+
                                        comment.body,
+
                                        Some(comment.location),
+
                                        None,   // Not a reply.
+
                                        vec![], // No embeds.
+
                                    )?;
+
                                }
+
                                Ok(())
+
                            })?;
+
                        } else {
+
                            log::warn!("Commenting on binary blobs is not yet implemented");
+
                        }
+
                    }
+
                }
+
            } else {
+
                break;
+
            }
+
        }
+

+
        Ok(())
+
    }
+
}
added bin/commands/patch/review.rs
@@ -0,0 +1,613 @@
+
#[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 termion::event::Key;
+

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

+
use radicle::identity::RepoId;
+
use radicle::patch::PatchId;
+
use radicle::patch::Review;
+
use radicle::patch::Revision;
+
use radicle::storage::ReadStorage;
+
use radicle::storage::WriteRepository;
+
use radicle::Profile;
+

+
use radicle_cli::terminal;
+

+
use radicle_tui as tui;
+

+
use tui::store;
+
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::cob::HunkState;
+
use crate::tui_patch::review::builder::DiffUtil;
+
use crate::ui::format;
+
use crate::ui::items::HunkItem;
+

+
use self::builder::Brain;
+
use self::builder::FileReviewBuilder;
+
use self::builder::ReviewQueue;
+

+
/// The actions that a user can carry out on a review item.
+
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
+
pub enum ReviewAction {
+
    Comment,
+
}
+

+
#[derive(Clone, Debug, Eq, PartialEq)]
+
pub struct Args(String);
+

+
#[derive(Clone, Debug, Eq, PartialEq)]
+
pub struct Selection {
+
    pub action: ReviewAction,
+
    pub hunk: Option<usize>,
+
    pub args: Option<Args>,
+
}
+

+
pub struct Tui {
+
    pub patch: PatchId,
+
    pub title: String,
+
    pub revision: Revision,
+
    pub review: Review,
+
    pub queue: ReviewQueue,
+
    pub profile: Profile,
+
    pub rid: RepoId,
+
}
+

+
impl Tui {
+
    pub fn new(
+
        patch: PatchId,
+
        title: String,
+
        revision: Revision,
+
        review: Review,
+
        queue: ReviewQueue,
+
        profile: Profile,
+
        rid: RepoId,
+
    ) -> Self {
+
        Self {
+
            patch,
+
            title,
+
            revision,
+
            review,
+
            queue,
+
            rid,
+
            profile,
+
        }
+
    }
+

+
    pub async fn run(&self) -> Result<Option<Selection>> {
+
        let viewport = Viewport::Fullscreen;
+
        let _ = self.profile.signer()?;
+

+
        let channel = Channel::default();
+
        let state = App::new(
+
            self.patch,
+
            self.title.clone(),
+
            self.revision.clone(),
+
            self.review.clone(),
+
            self.queue.clone(),
+
            self.profile.clone(),
+
            self.rid,
+
        )?;
+

+
        tui::im(state, viewport, channel).await
+
    }
+
}
+

+
#[derive(Clone, Debug)]
+
pub enum Message<'a> {
+
    ShowMain,
+
    WindowsChanged { state: GroupState },
+
    ItemChanged { state: TableState },
+
    ItemViewChanged { state: ReviewItemState },
+
    Quit,
+
    Comment,
+
    Accept,
+
    Discard,
+
    ShowHelp,
+
    HelpChanged { state: TextViewState<'a> },
+
}
+

+
#[derive(Clone, Debug, Eq, PartialEq, Hash)]
+
pub enum AppPage {
+
    Main,
+
    Help,
+
}
+

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

+
#[derive(Clone)]
+
pub struct App<'a> {
+
    patch: PatchId,
+
    title: String,
+
    revision: Revision,
+
    queue: Arc<Mutex<(Vec<HunkItem<'a>>, TableState)>>,
+
    items: HashMap<usize, ReviewItemState>,
+
    profile: Profile,
+
    rid: RepoId,
+
    page: AppPage,
+
    windows: GroupState,
+
    help: TextViewState<'a>,
+
}
+

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

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

+
impl<'a> App<'a> {
+
    pub fn new(
+
        patch: PatchId,
+
        title: String,
+
        revision: Revision,
+
        review: Review,
+
        queue: ReviewQueue,
+
        profile: Profile,
+
        rid: RepoId,
+
    ) -> Result<Self, anyhow::Error> {
+
        let repo = profile.storage.repository(rid)?;
+
        let queue = queue
+
            .iter()
+
            .map(|item| HunkItem::from((&repo, &review, item)))
+
            .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 {
+
            profile,
+
            rid,
+
            patch,
+
            title,
+
            revision,
+
            queue: Arc::new(Mutex::new((queue, TableState::new(Some(0))))),
+
            items,
+
            page: AppPage::Main,
+
            windows: GroupState::new(2, Some(0)),
+
            help: TextViewState::new(help_text(), Position::default()),
+
        };
+

+
        app.reload_states()?;
+

+
        Ok(app)
+
    }
+

+
    pub fn accept_current_hunk(&mut self) -> Result<()> {
+
        let repo = self.profile.storage.repository(self.rid).unwrap();
+
        let signer = terminal::signer(&self.profile)?;
+

+
        let mut brain = Brain::load_or_new(self.patch, &self.revision, repo.raw(), &signer)?;
+
        let selected = self.queue.lock().unwrap().1.selected();
+

+
        if let Some(selected) = selected {
+
            let hunks = self.queue.lock().unwrap().0.clone();
+

+
            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)),
+
                };
+

+
                let diff = file.item_diff(&hunk.inner.1)?;
+
                brain.accept(diff, repo.raw())?;
+

+
                self.reload_states()?;
+
            }
+
        }
+

+
        Ok(())
+
    }
+

+
    pub fn discard_accepted_hunks(&mut self) -> Result<()> {
+
        let repo = self.profile.storage.repository(self.rid).unwrap();
+
        let signer = terminal::signer(&self.profile)?;
+

+
        let mut brain = Brain::load_or_new(self.patch, &self.revision, repo.raw(), &signer)?;
+
        brain.discard_accepted(repo.raw())?;
+

+
        self.reload_states()?;
+

+
        Ok(())
+
    }
+

+
    pub fn reload_states(&mut self) -> anyhow::Result<()> {
+
        let repo = self.profile.storage.repository(self.rid).unwrap();
+
        let signer = terminal::signer(&self.profile)?;
+

+
        let brain = Brain::load_or_new(self.patch, &self.revision, repo.raw(), &signer)?;
+

+
        let (base_diff, queue_diff) =
+
            DiffUtil::new(&repo).base_queue(brain.clone(), &self.revision)?;
+

+
        // Compute states
+
        let base_files = base_diff.into_files();
+
        let queue_files = queue_diff.into_files();
+

+
        let states = base_files
+
            .iter()
+
            .map(|file| {
+
                if !queue_files.contains(file) {
+
                    HunkState::Accepted
+
                } else {
+
                    HunkState::Rejected
+
                }
+
            })
+
            .collect::<Vec<_>>();
+

+
        let mut queue = self.queue.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();
+
            }
+
        }
+

+
        Ok(())
+
    }
+
}
+

+
impl<'a> App<'a> {
+
    fn show_hunk_list(&self, ui: &mut Ui<Message<'a>>, frame: &mut Frame) {
+
        let header = [Column::new(" Hunks ", Constraint::Fill(1))].to_vec();
+
        let columns = [
+
            Column::new("", Constraint::Length(2)),
+
            Column::new("", Constraint::Fill(1)),
+
            Column::new("", Constraint::Length(15)),
+
        ]
+
        .to_vec();
+

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

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

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

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

+
        if let Some(item) = item {
+
            let header = item.header();
+
            let hunk = item
+
                .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)
+
                .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));
+
                    }
+
                },
+
            );
+
        }
+
    }
+

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

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

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

+
        let accepted_stats = format!(" Accepted {hunks_accepted}/{hunks_total} ");
+

+
        ui.bar(
+
            frame,
+
            [
+
                Column::new(
+
                    span::default(" Review ").cyan().dim().reversed(),
+
                    Constraint::Length(8),
+
                ),
+
                Column::new(
+
                    span::default(&id)
+
                        .style(ui.theme().bar_on_black_style)
+
                        .magenta(),
+
                    Constraint::Length(9),
+
                ),
+
                Column::new(
+
                    span::default(title)
+
                        .style(ui.theme().bar_on_black_style)
+
                        .magenta()
+
                        .dim(),
+
                    Constraint::Length(title.chars().count() as u16),
+
                ),
+
                Column::new(
+
                    span::default(" ")
+
                        .into_left_aligned_line()
+
                        .style(ui.theme().bar_on_black_style),
+
                    Constraint::Fill(1),
+
                ),
+
                Column::new(
+
                    span::default(&accepted_stats)
+
                        .into_right_aligned_line()
+
                        .cyan()
+
                        .dim()
+
                        .reversed(),
+
                    Constraint::Length(accepted_stats.chars().count() as u16),
+
                ),
+
            ]
+
            .to_vec(),
+
            Some(Borders::None),
+
        );
+
    }
+
}
+

+
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();
+

+
            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),
+
                                });
+
                            }
+

+
                            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.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);
+

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

+
                    if ui.input_global(|key| key == Key::Char('?')) {
+
                        ui.send_message(Message::ShowMain);
+
                    }
+
                }
+
            }
+

+
            if ui.input_global(|key| key == Key::Char('q')) {
+
                ui.send_message(Message::Quit);
+
            }
+
        });
+
        Ok(())
+
    }
+
}
+

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

+
    fn update(&mut self, message: Message<'a>) -> Option<Exit<Self::Return>> {
+
        log::info!("Received message: {:?}", message);
+

+
        match message {
+
            Message::WindowsChanged { state } => {
+
                self.windows = state;
+
                None
+
            }
+
            Message::ItemChanged { state } => {
+
                let mut queue = self.queue.lock().unwrap();
+
                queue.1 = state;
+
                None
+
            }
+
            Message::ItemViewChanged { state } => {
+
                let queue = self.queue.lock().unwrap();
+
                if let Some(selected) = queue.1.selected() {
+
                    self.items.insert(selected, state);
+
                }
+
                None
+
            }
+
            Message::Quit => Some(Exit { value: None }),
+
            Message::Comment => {
+
                let queue = self.queue.lock().unwrap();
+
                Some(Exit {
+
                    value: Some(Selection {
+
                        action: ReviewAction::Comment,
+
                        hunk: queue.1.selected(),
+
                        args: None,
+
                    }),
+
                })
+
            }
+
            Message::Accept => {
+
                match self.accept_current_hunk() {
+
                    Ok(()) => log::info!("Accepted hunk."),
+
                    Err(err) => log::info!("An error occured while accepting hunk: {}", err),
+
                }
+
                None
+
            }
+
            Message::Discard => {
+
                match self.discard_accepted_hunks() {
+
                    Ok(()) => log::info!("Discarded all hunks."),
+
                    Err(err) => log::info!("An error occured while discarding hunks: {}", err),
+
                }
+
                None
+
            }
+
            Message::ShowMain => {
+
                self.page = AppPage::Main;
+
                None
+
            }
+
            Message::ShowHelp => {
+
                self.page = AppPage::Help;
+
                None
+
            }
+
            Message::HelpChanged { state } => {
+
                self.help = state;
+
                None
+
            }
+
        }
+
    }
+
}
+

+
fn help_text() -> String {
+
    r#"# About
+

+
A terminal interface for reviewing patch revisions.
+

+
Starts a new or resumes an existing review for a given revision (default: latest). When the
+
review is done, it needs to be finalized via `rad patch review --accept | --reject <id>`.
+
    
+
# Keybindings
+

+
`←,h`       move cursor to the left
+
`↑,k`       move cursor one line up
+
`↓,j`       move cursor one line down
+
`→,l`       move cursor to the right
+
`PageUp`    move cursor one page up
+
`PageDown`  move cursor one page down
+
`Home`      move cursor to the first line
+
`End`       move cursor to the last line
+

+
`?`         toogle help
+
`q`         quit / cancel
+

+
## Specific keybindings
+

+
`c`         comment on hunk
+
`a`         accept hunk
+
`d`         discard accepted hunks (reject all)"#
+
        .into()
+
}
added bin/commands/patch/review/builder.rs
@@ -0,0 +1,918 @@
+
//! Review builder.
+
//!
+
//! This module enables a user to review a patch by interactively viewing and accepting diff hunks.
+
//!
+
//! To implement this behavior, we keep a hidden Git tree object that tracks the state of the
+
//! repository including the accepted hunks. Thus, every time a diff hunk is accepted, it is applied
+
//! to that tree. We call that tree the "brain", as it tracks what the code reviewer has reviewed.
+
//!
+
//! The brain starts out equalling the tree of the base branch, and eventually, when the brain
+
//! matches the tree of the patch being reviewed (by accepting hunks), we can say that the patch has
+
//! been fully reviewed.
+
//!
+
use std::collections::VecDeque;
+
use std::fmt::Write as _;
+
use std::io;
+
use std::ops::{Not, Range};
+
use std::path::PathBuf;
+

+
use radicle::cob::patch::{PatchId, Revision};
+
use radicle::cob::{CodeLocation, CodeRange};
+
use radicle::git;
+
use radicle::git::Oid;
+
use radicle::prelude::*;
+
use radicle::storage::git::Repository;
+
use radicle_surf::diff::*;
+

+
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};
+

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

+
impl ReviewQueue {
+
    pub fn new(base: Diff, queue: Diff) -> Self {
+
        let base_files = base.into_files();
+
        let queue_files = queue.into_files();
+

+
        let mut queue = Self::default();
+
        for file in base_files {
+
            let state = if !queue_files.contains(&file) {
+
                HunkState::Accepted
+
            } else {
+
                HunkState::Rejected
+
            };
+
            queue.add_file(file, state);
+
        }
+
        queue
+
    }
+

+
    /// Add a file to the queue.
+
    /// Mostly splits files into individual review items (eg. hunks) to review.
+
    fn add_file(&mut self, file: FileDiff, state: HunkState) {
+
        let header = FileHeader::from(&file);
+

+
        match file {
+
            FileDiff::Moved(moved) => {
+
                self.add_item(HunkItem::Moved { moved }, state);
+
            }
+
            FileDiff::Copied(copied) => {
+
                self.add_item(HunkItem::Copied { copied }, state);
+
            }
+
            FileDiff::Added(a) => {
+
                self.add_item(
+
                    HunkItem::Added {
+
                        path: a.path,
+
                        header: header.clone(),
+
                        new: a.new,
+
                        hunk: if let DiffContent::Plain {
+
                            hunks: Hunks(mut hs),
+
                            ..
+
                        } = a.diff.clone()
+
                        {
+
                            hs.pop()
+
                        } else {
+
                            None
+
                        },
+
                        _stats: a.diff.stats().cloned(),
+
                    },
+
                    state,
+
                );
+
            }
+
            FileDiff::Deleted(d) => {
+
                self.add_item(
+
                    HunkItem::Deleted {
+
                        path: d.path,
+
                        header: header.clone(),
+
                        old: d.old,
+
                        hunk: if let DiffContent::Plain {
+
                            hunks: Hunks(mut hs),
+
                            ..
+
                        } = d.diff.clone()
+
                        {
+
                            hs.pop()
+
                        } else {
+
                            None
+
                        },
+
                        _stats: d.diff.stats().cloned(),
+
                    },
+
                    state,
+
                );
+
            }
+
            FileDiff::Modified(m) => {
+
                if m.old.mode != m.new.mode {
+
                    self.add_item(
+
                        HunkItem::ModeChanged {
+
                            path: m.path.clone(),
+
                            header: header.clone(),
+
                            old: m.old.clone(),
+
                            new: m.new.clone(),
+
                        },
+
                        state.clone(),
+
                    );
+
                }
+
                match m.diff {
+
                    DiffContent::Empty => {
+
                        // Likely a file mode change, which is handled above.
+
                    }
+
                    DiffContent::Binary => {
+
                        self.add_item(
+
                            HunkItem::Modified {
+
                                path: m.path.clone(),
+
                                header: header.clone(),
+
                                old: m.old.clone(),
+
                                new: m.new.clone(),
+
                                hunk: None,
+
                                _stats: m.diff.stats().cloned(),
+
                            },
+
                            state,
+
                        );
+
                    }
+
                    DiffContent::Plain {
+
                        hunks: Hunks(hunks),
+
                        eof,
+
                        stats,
+
                    } => {
+
                        for hunk in hunks {
+
                            self.add_item(
+
                                HunkItem::Modified {
+
                                    path: m.path.clone(),
+
                                    header: header.clone(),
+
                                    old: m.old.clone(),
+
                                    new: m.new.clone(),
+
                                    hunk: Some(hunk),
+
                                    _stats: Some(stats),
+
                                },
+
                                state.clone(),
+
                            );
+
                        }
+
                        if let EofNewLine::OldMissing | EofNewLine::NewMissing = eof {
+
                            self.add_item(
+
                                HunkItem::EofChanged {
+
                                    path: m.path.clone(),
+
                                    header: header.clone(),
+
                                    old: m.old.clone(),
+
                                    new: m.new.clone(),
+
                                    _eof: eof,
+
                                },
+
                                state,
+
                            )
+
                        }
+
                    }
+
                }
+
            }
+
        }
+
    }
+

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

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

+
    fn deref(&self) -> &Self::Target {
+
        &self.queue
+
    }
+
}
+

+
impl std::ops::DerefMut for ReviewQueue {
+
    fn deref_mut(&mut self) -> &mut Self::Target {
+
        &mut self.queue
+
    }
+
}
+

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

+
    fn next(&mut self) -> Option<Self::Item> {
+
        self.queue.pop_front()
+
    }
+
}
+

+
/// Builds a review for a single file.
+
/// Adjusts line deltas when a hunk is ignored.
+
pub struct FileReviewBuilder {
+
    delta: i32,
+
    header: FileHeader,
+
}
+

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

+
    pub fn set_item(&mut self, item: &HunkItem) -> &mut Self {
+
        let header = item.file_header();
+
        if self.header != header {
+
            self.header = header;
+
            self.delta = 0;
+
        }
+
        self
+
    }
+

+
    pub fn item_diff(&mut self, item: &HunkItem) -> Result<git::raw::Diff, Error> {
+
        let mut buf = Vec::new();
+
        let mut writer = unified_diff::Writer::new(&mut buf);
+
        writer.encode(&self.header)?;
+

+
        if let (Some(h), Some(mut header)) = (item.hunk(), item.hunk_header()) {
+
            header.old_line_no -= self.delta as u32;
+
            header.new_line_no -= self.delta as u32;
+

+
            let h = Hunk {
+
                header: header.to_unified_string()?.as_bytes().to_owned().into(),
+
                lines: h.lines.clone(),
+
                old: h.old.clone(),
+
                new: h.new.clone(),
+
            };
+
            writer.encode(&h)?;
+
        }
+
        drop(writer);
+

+
        git::raw::Diff::from_buffer(&buf).map_err(Error::from)
+
    }
+
}
+

+
/// Represents the reviewer's brain, ie. what they have seen or not seen in terms
+
/// of changes introduced by a patch.
+
#[derive(Clone)]
+
pub struct Brain<'a> {
+
    /// Where the review draft is being stored.
+
    refname: git::Namespaced<'a>,
+
    /// The merge base
+
    base: git::raw::Commit<'a>,
+
    /// The commit pointed to by the ref.
+
    head: git::raw::Commit<'a>,
+
    /// The tree of accepted changes pointed to by the head commit.
+
    accepted: git::raw::Tree<'a>,
+
}
+

+
impl<'a> Brain<'a> {
+
    /// Create a new brain in the repository.
+
    pub fn new(
+
        patch: PatchId,
+
        remote: &NodeId,
+
        base: git::raw::Commit<'a>,
+
        repo: &'a git::raw::Repository,
+
    ) -> Result<Self, git::raw::Error> {
+
        let refname = Self::refname(&patch, remote);
+
        let author = repo.signature()?;
+
        let oid = repo.commit(
+
            Some(refname.as_str()),
+
            &author,
+
            &author,
+
            &format!("Review for {patch}"),
+
            &base.tree()?,
+
            // TODO: Verify this is necessary, shouldn't matter.
+
            &[&base],
+
        )?;
+
        let head = repo.find_commit(oid)?;
+
        let tree = head.tree()?;
+

+
        Ok(Self {
+
            refname,
+
            base,
+
            head,
+
            accepted: tree,
+
        })
+
    }
+

+
    /// Load an existing brain from the repository.
+
    pub fn load(
+
        patch: PatchId,
+
        remote: &NodeId,
+
        base: git::raw::Commit<'a>,
+
        repo: &'a git::raw::Repository,
+
    ) -> Result<Self, git::raw::Error> {
+
        // TODO: Validate this leads to correct UX for potentially abandoned drafts on
+
        // past revisions.
+
        let refname = Self::refname(&patch, remote);
+
        let head = repo.find_reference(&refname)?.peel_to_commit()?;
+
        let tree = head.tree()?;
+

+
        Ok(Self {
+
            refname,
+
            base,
+
            head,
+
            accepted: tree,
+
        })
+
    }
+

+
    pub fn load_or_new<G: Signer>(
+
        patch: PatchId,
+
        revision: &Revision,
+
        repo: &'a git::raw::Repository,
+
        signer: &'a G,
+
    ) -> Result<Self, git::raw::Error> {
+
        let base = repo.find_commit((*revision.base()).into())?;
+

+
        let brain = if let Ok(b) = Brain::load(patch, signer.public_key(), base.clone(), repo) {
+
            log::info!(
+
                "Loaded existing brain {} for patch {}",
+
                b.head().id(),
+
                &patch
+
            );
+
            b
+
        } else {
+
            Brain::new(patch, signer.public_key(), base, repo)?
+
        };
+

+
        Ok(brain)
+
    }
+

+
    pub fn discard_accepted(
+
        &mut self,
+
        repo: &'a git::raw::Repository,
+
    ) -> Result<(), git::raw::Error> {
+
        // Reset brain
+
        let head = self.head.amend(
+
            Some(&self.refname),
+
            None,
+
            None,
+
            None,
+
            None,
+
            Some(&self.base.tree()?),
+
        )?;
+
        self.head = repo.find_commit(head)?;
+
        self.accepted = self.head.tree()?;
+

+
        Ok(())
+
    }
+

+
    /// Accept changes to the brain.
+
    pub fn accept(
+
        &mut self,
+
        diff: git::raw::Diff,
+
        repo: &'a git::raw::Repository,
+
    ) -> Result<(), git::raw::Error> {
+
        let mut index = repo.apply_to_tree(&self.accepted, &diff, None)?;
+
        let accepted = index.write_tree_to(repo)?;
+
        self.accepted = repo.find_tree(accepted)?;
+

+
        // Update review with new brain.
+
        let head = self.head.amend(
+
            Some(&self.refname),
+
            None,
+
            None,
+
            None,
+
            None,
+
            Some(&self.accepted),
+
        )?;
+
        self.head = repo.find_commit(head)?;
+

+
        Ok(())
+
    }
+

+
    /// Get the brain's refname given the patch and remote.
+
    pub fn refname(patch: &PatchId, remote: &NodeId) -> git::Namespaced<'a> {
+
        git::refs::storage::draft::review(remote, patch)
+
    }
+

+
    pub fn head(&self) -> &git::raw::Commit<'a> {
+
        &self.head
+
    }
+

+
    pub fn accepted(&self) -> &git::raw::Tree<'a> {
+
        &self.accepted
+
    }
+
}
+

+
pub struct DiffUtil<'a> {
+
    repo: &'a Repository,
+
}
+

+
impl<'a> DiffUtil<'a> {
+
    pub fn new(repo: &'a Repository) -> Self {
+
        Self { repo }
+
    }
+

+
    pub fn base_queue(
+
        &self,
+
        brain: Brain<'a>,
+
        revision: &Revision,
+
    ) -> anyhow::Result<(Diff, Diff)> {
+
        let repo = self.repo.raw();
+

+
        let base = repo.find_commit((*revision.base()).into())?.tree()?;
+
        let revision = {
+
            let commit = repo.find_commit(revision.head().into())?;
+
            commit.tree()?
+
        };
+

+
        let mut opts = git::raw::DiffOptions::new();
+
        opts.patience(true).minimal(true).context_lines(3_u32);
+

+
        let base_diff = self.diff(&base, &revision, repo, &mut opts)?;
+
        let queue_diff = self.diff(brain.accepted(), &revision, repo, &mut opts)?;
+

+
        Ok((base_diff, queue_diff))
+
    }
+

+
    pub fn diff(
+
        &self,
+
        brain: &git::raw::Tree<'_>,
+
        tree: &git::raw::Tree<'_>,
+
        repo: &'a git::raw::Repository,
+
        opts: &mut git::raw::DiffOptions,
+
    ) -> Result<Diff, Error> {
+
        let mut find_opts = git::raw::DiffFindOptions::new();
+
        find_opts.exact_match_only(true);
+
        find_opts.all(true);
+
        find_opts.copies(false); // We don't support finding copies at the moment.
+

+
        let mut diff = repo.diff_tree_to_tree(Some(brain), Some(tree), Some(opts))?;
+
        diff.find_similar(Some(&mut find_opts))?;
+

+
        let diff = Diff::try_from(diff)?;
+

+
        Ok(diff)
+
    }
+
}
+

+
/// Builds a patch review interactively, across multiple files.
+
pub struct ReviewBuilder<'a> {
+
    /// Stored copy of repository.
+
    repo: &'a Repository,
+
}
+

+
impl<'a> ReviewBuilder<'a> {
+
    /// Create a new review builder.
+
    pub fn new(repo: &'a Repository) -> Self {
+
        Self { repo }
+
    }
+

+
    /// Assemble the review for the given revision.
+
    pub fn hunks(&self, brain: &'a Brain<'a>, revision: &Revision) -> anyhow::Result<ReviewQueue> {
+
        DiffUtil::new(self.repo)
+
            .base_queue(brain.clone(), revision)
+
            .map(|(base, queue)| Ok(ReviewQueue::new(base, queue)))?
+
    }
+
}
+

+
#[derive(Debug, PartialEq, Eq)]
+
pub struct ReviewComment {
+
    pub location: CodeLocation,
+
    pub body: String,
+
}
+

+
#[derive(thiserror::Error, Debug)]
+
pub enum Error {
+
    #[error(transparent)]
+
    Diff(#[from] unified_diff::Error),
+
    #[error(transparent)]
+
    Surf(#[from] radicle_surf::diff::git::error::Diff),
+
    #[error(transparent)]
+
    Io(#[from] io::Error),
+
    #[error(transparent)]
+
    Format(#[from] std::fmt::Error),
+
    #[error(transparent)]
+
    Git(#[from] git::raw::Error),
+
}
+

+
#[derive(Debug)]
+
pub struct CommentBuilder {
+
    commit: Oid,
+
    path: PathBuf,
+
    comments: Vec<ReviewComment>,
+
}
+

+
impl CommentBuilder {
+
    pub fn new(commit: Oid, path: PathBuf) -> Self {
+
        Self {
+
            commit,
+
            path,
+
            comments: Vec::new(),
+
        }
+
    }
+

+
    pub fn edit(mut self, hunk: &Hunk<Modification>) -> Result<Vec<ReviewComment>, Error> {
+
        let mut input = String::new();
+
        for line in hunk.to_unified_string()?.lines() {
+
            writeln!(&mut input, "> {line}")?;
+
        }
+
        let output = term::Editor::new().extension("diff").edit(input)?;
+

+
        if let Some(output) = output {
+
            let header = HunkHeader::try_from(hunk)?;
+
            self.add_hunk(header, &output);
+
        }
+
        Ok(self.comments())
+
    }
+

+
    pub fn add_hunk(&mut self, hunk: HunkHeader, input: &str) -> &mut Self {
+
        let lines = input.trim().lines().map(|l| l.trim());
+
        let (mut old_line, mut new_line) = (hunk.old_line_no as usize, hunk.new_line_no as usize);
+
        let (mut old_start, mut new_start) = (old_line, new_line);
+
        let mut comment = String::new();
+

+
        for line in lines {
+
            if line.starts_with('>') {
+
                if !comment.is_empty() {
+
                    self.add_comment(
+
                        &hunk,
+
                        &comment,
+
                        old_start..old_line - 1,
+
                        new_start..new_line - 1,
+
                    );
+

+
                    old_start = old_line - 1;
+
                    new_start = new_line - 1;
+

+
                    comment.clear();
+
                }
+
                match line.trim_start_matches('>').trim_start().chars().next() {
+
                    Some('-') => old_line += 1,
+
                    Some('+') => new_line += 1,
+
                    _ => {
+
                        old_line += 1;
+
                        new_line += 1;
+
                    }
+
                }
+
            } else {
+
                comment.push_str(line);
+
                comment.push('\n');
+
            }
+
        }
+
        if !comment.is_empty() {
+
            self.add_comment(
+
                &hunk,
+
                &comment,
+
                old_start..old_line - 1,
+
                new_start..new_line - 1,
+
            );
+
        }
+
        self
+
    }
+

+
    fn add_comment(
+
        &mut self,
+
        hunk: &HunkHeader,
+
        comment: &str,
+
        mut old_range: Range<usize>,
+
        mut new_range: Range<usize>,
+
    ) {
+
        // Empty lines between quoted text can generate empty comments
+
        // that should be filtered out.
+
        if comment.trim().is_empty() {
+
            return;
+
        }
+
        // Top-level comment, it should apply to the whole hunk.
+
        if old_range.is_empty() && new_range.is_empty() {
+
            old_range = hunk.old_line_no as usize..(hunk.old_line_no + hunk.old_size + 1) as usize;
+
            new_range = hunk.new_line_no as usize..(hunk.new_line_no + hunk.new_size + 1) as usize;
+
        }
+
        let old_range = old_range
+
            .is_empty()
+
            .not()
+
            .then_some(old_range)
+
            .map(|range| CodeRange::Lines { range });
+
        let new_range = (new_range)
+
            .is_empty()
+
            .not()
+
            .then_some(new_range)
+
            .map(|range| CodeRange::Lines { range });
+

+
        self.comments.push(ReviewComment {
+
            location: CodeLocation {
+
                commit: self.commit,
+
                path: self.path.clone(),
+
                old: old_range,
+
                new: new_range,
+
            },
+
            body: comment.trim().to_owned(),
+
        });
+
    }
+

+
    fn comments(self) -> Vec<ReviewComment> {
+
        self.comments
+
    }
+
}
+
#[cfg(test)]
+
mod tests {
+
    use super::*;
+
    use std::str::FromStr;
+

+
    #[test]
+
    fn test_review_comments_basic() {
+
        let input = r#"
+
> @@ -2559,18 +2560,18 @@ where
+
>                  // Only consider onion addresses if configured.
+
>                  AddressType::Onion => self.config.onion.is_some(),
+
>                  AddressType::Dns | AddressType::Ipv4 | AddressType::Ipv6 => true,
+
> -            })
+
> -            .take(wanted)
+
> -            .collect::<Vec<_>>(); // # -2564
+

+
Comment #1.
+

+
> +            });
+
>
+
> -        if available.len() < target {
+
> -            log::warn!( # -2567
+
> +        // Peers we are going to attempt connections to.
+
> +        let connect = available.take(wanted).collect::<Vec<_>>();
+

+
Comment #2.
+

+
> +        if connect.len() < wanted {
+
> +            log::debug!(
+
>                  target: "service",
+
> -                "Not enough available peers to connect to (available={}, target={target})",
+
> -                available.len()
+

+
Comment #3.
+

+
> +                "Not enough available peers to connect to (available={}, wanted={wanted})",
+

+
Comment #4.
+

+
> +                connect.len()
+
>              );
+
>          }
+
> -        for (id, ka) in available {
+
> +        for (id, ka) in connect {
+
>              self.connect(id, ka.addr.clone());
+
>          }
+
>     }
+

+
Comment #5.
+

+
"#;
+

+
        let commit = Oid::from_str("a32c4b93e2573fd83b15ac1ad6bf1317dc8fd760").unwrap();
+
        let path = PathBuf::from_str("main.rs").unwrap();
+
        let expected = &[
+
            (ReviewComment {
+
                location: CodeLocation {
+
                    commit,
+
                    path: path.clone(),
+
                    old: Some(CodeRange::Lines { range: 2559..2565 }),
+
                    new: Some(CodeRange::Lines { range: 2560..2563 }),
+
                },
+
                body: "Comment #1.".to_owned(),
+
            }),
+
            (ReviewComment {
+
                location: CodeLocation {
+
                    commit,
+
                    path: path.clone(),
+
                    old: Some(CodeRange::Lines { range: 2565..2568 }),
+
                    new: Some(CodeRange::Lines { range: 2563..2567 }),
+
                },
+
                body: "Comment #2.".to_owned(),
+
            }),
+
            (ReviewComment {
+
                location: CodeLocation {
+
                    commit,
+
                    path: path.clone(),
+
                    old: Some(CodeRange::Lines { range: 2568..2571 }),
+
                    new: Some(CodeRange::Lines { range: 2567..2570 }),
+
                },
+
                body: "Comment #3.".to_owned(),
+
            }),
+
            (ReviewComment {
+
                location: CodeLocation {
+
                    commit,
+
                    path: path.clone(),
+
                    old: None,
+
                    new: Some(CodeRange::Lines { range: 2570..2571 }),
+
                },
+
                body: "Comment #4.".to_owned(),
+
            }),
+
            (ReviewComment {
+
                location: CodeLocation {
+
                    commit,
+
                    path: path.clone(),
+
                    old: Some(CodeRange::Lines { range: 2571..2577 }),
+
                    new: Some(CodeRange::Lines { range: 2571..2578 }),
+
                },
+
                body: "Comment #5.".to_owned(),
+
            }),
+
        ];
+

+
        let mut builder = CommentBuilder::new(commit, path.clone());
+
        builder.add_hunk(
+
            HunkHeader {
+
                old_line_no: 2559,
+
                old_size: 18,
+
                new_line_no: 2560,
+
                new_size: 18,
+
                text: vec![],
+
            },
+
            input,
+
        );
+
        let actual = builder.comments();
+

+
        assert_eq!(actual.len(), expected.len(), "{actual:#?}");
+

+
        for (left, right) in actual.iter().zip(expected) {
+
            assert_eq!(left, right);
+
        }
+
    }
+

+
    #[test]
+
    fn test_review_comments_multiline() {
+
        let input = r#"
+
> @@ -2559,9 +2560,7 @@ where
+
>                  // Only consider onion addresses if configured.
+
>                  AddressType::Onion => self.config.onion.is_some(),
+
>                  AddressType::Dns | AddressType::Ipv4 | AddressType::Ipv6 => true,
+
> -            })
+
> -            .take(wanted)
+
> -            .collect::<Vec<_>>(); // # -2564
+

+
Blah blah blah blah blah blah blah.
+
Blah blah blah.
+

+
Blaah blaah blaah blaah blaah blaah blaah.
+
blaah blaah blaah.
+

+
Blaaah blaaah blaaah.
+

+
> +            });
+
>
+
> -        if available.len() < target {
+
> -            log::warn!( # -2567
+
> +        // Peers we are going to attempt connections to.
+
> +        let connect = available.take(wanted).collect::<Vec<_>>();
+

+
Woof woof.
+
Woof.
+
Woof.
+

+
Woof.
+

+
"#;
+

+
        let commit = Oid::from_str("a32c4b93e2573fd83b15ac1ad6bf1317dc8fd760").unwrap();
+
        let path = PathBuf::from_str("main.rs").unwrap();
+
        let expected = &[
+
            (ReviewComment {
+
                location: CodeLocation {
+
                    commit,
+
                    path: path.clone(),
+
                    old: Some(CodeRange::Lines { range: 2559..2565 }),
+
                    new: Some(CodeRange::Lines { range: 2560..2563 }),
+
                },
+
                body: r#"
+
Blah blah blah blah blah blah blah.
+
Blah blah blah.
+

+
Blaah blaah blaah blaah blaah blaah blaah.
+
blaah blaah blaah.
+

+
Blaaah blaaah blaaah.
+
"#
+
                .trim()
+
                .to_owned(),
+
            }),
+
            (ReviewComment {
+
                location: CodeLocation {
+
                    commit,
+
                    path: path.clone(),
+
                    old: Some(CodeRange::Lines { range: 2565..2568 }),
+
                    new: Some(CodeRange::Lines { range: 2563..2567 }),
+
                },
+
                body: r#"
+
Woof woof.
+
Woof.
+
Woof.
+

+
Woof.
+
"#
+
                .trim()
+
                .to_owned(),
+
            }),
+
        ];
+

+
        let mut builder = CommentBuilder::new(commit, path.clone());
+
        builder.add_hunk(
+
            HunkHeader {
+
                old_line_no: 2559,
+
                old_size: 9,
+
                new_line_no: 2560,
+
                new_size: 7,
+
                text: vec![],
+
            },
+
            input,
+
        );
+
        let actual = builder.comments();
+

+
        assert_eq!(actual.len(), expected.len(), "{actual:#?}");
+

+
        for (left, right) in actual.iter().zip(expected) {
+
            assert_eq!(left, right);
+
        }
+
    }
+

+
    #[test]
+
    fn test_review_comments_before() {
+
        let input = r#"
+
This is a top-level comment.
+

+
> @@ -2559,9 +2560,7 @@ where
+
>                  // Only consider onion addresses if configured.
+
>                  AddressType::Onion => self.config.onion.is_some(),
+
>                  AddressType::Dns | AddressType::Ipv4 | AddressType::Ipv6 => true,
+
> -            })
+
> -            .take(wanted)
+
> -            .collect::<Vec<_>>(); // # -2564
+
> +            });
+
>
+
> -        if available.len() < target {
+
> -            log::warn!( # -2567
+
> +        // Peers we are going to attempt connections to.
+
> +        let connect = available.take(wanted).collect::<Vec<_>>();
+
"#;
+

+
        let commit = Oid::from_str("a32c4b93e2573fd83b15ac1ad6bf1317dc8fd760").unwrap();
+
        let path = PathBuf::from_str("main.rs").unwrap();
+
        let expected = &[(ReviewComment {
+
            location: CodeLocation {
+
                commit,
+
                path: path.clone(),
+
                old: Some(CodeRange::Lines { range: 2559..2569 }),
+
                new: Some(CodeRange::Lines { range: 2560..2568 }),
+
            },
+
            body: "This is a top-level comment.".to_owned(),
+
        })];
+

+
        let mut builder = CommentBuilder::new(commit, path.clone());
+
        builder.add_hunk(
+
            HunkHeader {
+
                old_line_no: 2559,
+
                old_size: 9,
+
                new_line_no: 2560,
+
                new_size: 7,
+
                text: vec![],
+
            },
+
            input,
+
        );
+
        let actual = builder.comments();
+

+
        assert_eq!(actual.len(), expected.len(), "{actual:#?}");
+

+
        for (left, right) in actual.iter().zip(expected) {
+
            assert_eq!(left, right);
+
        }
+
    }
+

+
    #[test]
+
    fn test_review_comments_split_hunk() {
+
        let input = r#"
+
> @@ -2559,6 +2560,4 @@ where
+
>                  // Only consider onion addresses if configured.
+
>                  AddressType::Onion => self.config.onion.is_some(),
+
>                  AddressType::Dns | AddressType::Ipv4 | AddressType::Ipv6 => true,
+
> -            })
+
> -            .take(wanted)
+

+
> -            .collect::<Vec<_>>();
+
> +            });
+

+
Comment on a split hunk.
+
"#;
+

+
        let commit = Oid::from_str("a32c4b93e2573fd83b15ac1ad6bf1317dc8fd760").unwrap();
+
        let path = PathBuf::from_str("main.rs").unwrap();
+
        let expected = &[(ReviewComment {
+
            location: CodeLocation {
+
                commit,
+
                path: path.clone(),
+
                old: Some(CodeRange::Lines { range: 2564..2565 }),
+
                new: Some(CodeRange::Lines { range: 2563..2564 }),
+
            },
+
            body: "Comment on a split hunk.".to_owned(),
+
        })];
+

+
        let mut builder = CommentBuilder::new(commit, path.clone());
+
        builder.add_hunk(
+
            HunkHeader {
+
                old_line_no: 2559,
+
                old_size: 6,
+
                new_line_no: 2560,
+
                new_size: 4,
+
                text: vec![],
+
            },
+
            input,
+
        );
+
        let actual = builder.comments();
+

+
        assert_eq!(actual.len(), expected.len(), "{actual:#?}");
+

+
        for (left, right) in actual.iter().zip(expected) {
+
            assert_eq!(left, right);
+
        }
+
    }
+
}
modified bin/commands/patch/select/imui.rs
@@ -4,7 +4,7 @@ use anyhow::Result;

use termion::event::Key;

-
use ratatui::layout::{Constraint, Layout};
+
use ratatui::layout::{Constraint, Layout, Position};
use ratatui::style::Stylize;
use ratatui::text::Span;
use ratatui::Frame;
@@ -132,7 +132,7 @@ impl<'a> TryFrom<&Context> for App<'a> {
                cursor: search.len(),
            }),
            show_search: false,
-
            help: TextViewState::new(HELP, (0, 0)),
+
            help: TextViewState::new(HELP, Position::default()),
            filter,
        })
    }
@@ -236,13 +236,13 @@ impl<'a> Show<Message<'a>> for App<'a> {
                                    ui.text_view(
                                        frame,
                                        String::new(),
-
                                        &mut (0, 0),
+
                                        &mut Position::default(),
                                        Some(Borders::All),
                                    );
                                    ui.text_view(
                                        frame,
                                        String::new(),
-
                                        &mut (0, 0),
+
                                        &mut Position::default(),
                                        Some(Borders::All),
                                    );
                                },
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.rs
@@ -2,6 +2,7 @@ pub mod format;
pub mod im;
pub mod items;
pub mod rm;
+
pub mod span;

#[derive(Clone, Debug)]
pub struct TerminalInfo {
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,32 +7,45 @@ 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::cob::{CodeLocation, CodeRange, EntryId, Label, ObjectId, Timestamp, TypedId};
use radicle::git::Oid;
use radicle::identity::{Did, Identity};
use radicle::issue;
use radicle::issue::{CloseReason, Issue, IssueId, Issues};
use radicle::node::notifications::{Notification, NotificationId, NotificationKind};
use radicle::node::{Alias, AliasStore, NodeId};
-
use radicle::patch;
+
use radicle::patch::{self, Review};
use radicle::patch::{Patch, PatchId, Patches};
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::prelude::*;
+
use ratatui::style::{Color, Style, Stylize};
use ratatui::widgets::Cell;

use tui_tree_widget::TreeItem;

use radicle_tui as tui;

-
use tui::ui::span;
use tui::ui::theme::style;
+
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::ui;
+

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

@@ -1019,6 +1033,902 @@ impl ToTree<String> for CommentItem {
    }
}

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

+
impl<'a> From<TermLine> for Line<'a> {
+
    fn from(val: TermLine) -> Self {
+
        Line::raw(val.0.to_string())
+
    }
+
}
+

+
/// All comments per hunk, indexed by their starting line.
+
#[derive(Clone, Debug)]
+
pub struct HunkComments {
+
    /// All comments. Can be unsorted.
+
    comments: HashMap<usize, Vec<(EntryId, Comment<CodeLocation>)>>,
+
}
+

+
impl HunkComments {
+
    pub fn all(&self) -> &HashMap<usize, Vec<(EntryId, Comment<CodeLocation>)>> {
+
        &self.comments
+
    }
+

+
    pub fn is_empty(&self) -> bool {
+
        self.comments.is_empty()
+
    }
+

+
    pub fn len(&self) -> usize {
+
        self.comments.values().fold(0_usize, |mut count, comments| {
+
            count += comments.len();
+
            count
+
        })
+
    }
+
}
+

+
impl From<Vec<(EntryId, Comment<CodeLocation>)>> for HunkComments {
+
    fn from(comments: Vec<(EntryId, Comment<CodeLocation>)>) -> Self {
+
        let mut line_comments: HashMap<usize, Vec<(EntryId, Comment<CodeLocation>)>> =
+
            HashMap::new();
+

+
        for comment in comments {
+
            // TODO(erikli): Check why we need range end instead of range start.
+
            let line = match comment.1.location().as_ref().unwrap().new.as_ref().unwrap() {
+
                CodeRange::Lines { range } => range.end,
+
                _ => 0,
+
            };
+

+
            if let Some(comments) = line_comments.get_mut(&line) {
+
                comments.push(comment.clone());
+
            } else {
+
                line_comments.insert(line, vec![comment.clone()]);
+
            }
+
        }
+

+
        Self {
+
            comments: line_comments,
+
        }
+
    }
+
}
+

+
/// A [`HunkItem`] that can be rendered. Hunk items are indexed sequentially and
+
/// provide access to the underlying hunk type.
+
#[derive(Clone, Debug)]
+
pub struct HunkItem<'a> {
+
    /// The indexed, underlying hunk type.
+
    pub inner: IndexedHunkItem,
+
    /// 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>>>,
+
    /// A hunks' comments, indexed by line.
+
    pub comments: HunkComments,
+
}
+

+
impl<'a> From<(&Repository, &Review, &IndexedHunkItem)> for HunkItem<'a> {
+
    fn from(value: (&Repository, &Review, &IndexedHunkItem)) -> 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,
+
        };
+

+
        // 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 lines = blobs.highlight(hi);
+
        let comments = review
+
            .comments()
+
            .filter(|(_, comment)| comment.location().is_some())
+
            .filter(|(_, comment)| comment.location().unwrap().path == *path)
+
            .map(|(id, comment)| (*id, comment.clone()))
+
            .collect::<Vec<_>>();
+

+
        Self {
+
            inner: item.clone(),
+
            lines,
+
            comments: HunkComments::from(comments),
+
        }
+
    }
+
}
+

+
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![];
+

+
            if !self.comments.is_empty() {
+
                cell.push(
+
                    span::default(&format!(" {} ", self.comments.len()))
+
                        .dim()
+
                        .reversed(),
+
                );
+
                cell.push(span::default(" "));
+
            }
+

+
            let (added, deleted) = match stats {
+
                DiffStats::Hunk(stats) => (stats.added(), stats.deleted()),
+
                DiffStats::File(stats) => (stats.additions, stats.deletions),
+
            };
+

+
            if added > 0 {
+
                cell.push(span::default(&format!("+{}", added)).light_green().dim());
+
            }
+

+
            if added > 0 && deleted > 0 {
+
                cell.push(span::default(",").dim());
+
            }
+

+
            if deleted > 0 {
+
                cell.push(span::default(&format!("-{}", deleted)).light_red().dim());
+
            }
+

+
            cell
+
        };
+

+
        match &self.inner {
+
            (
+
                _,
+
                Item::Added {
+
                    path,
+
                    header: _,
+
                    new: _,
+
                    hunk,
+
                    _stats: _,
+
                },
+
                state,
+
            ) => {
+
                let stats = hunk.as_ref().map(HunkStats::from).unwrap_or_default();
+
                let stats_cell = [
+
                    build_stats_spans(&DiffStats::Hunk(stats)),
+
                    [span::default(" A ").bold().light_green().dim()].to_vec(),
+
                ]
+
                .concat();
+

+
                [
+
                    ui::span::hunk_state(state).into_right_aligned_line().into(),
+
                    HunkItem::pretty_path(path, false).into(),
+
                    Line::from(stats_cell).right_aligned().into(),
+
                ]
+
            }
+
            (
+
                _,
+
                Item::Modified {
+
                    path,
+
                    header: _,
+
                    old: _,
+
                    new: _,
+
                    hunk,
+
                    _stats: _,
+
                },
+
                state,
+
            ) => {
+
                let stats = hunk.as_ref().map(HunkStats::from).unwrap_or_default();
+
                let stats_cell = [
+
                    build_stats_spans(&DiffStats::Hunk(stats)),
+
                    [span::default(" M ").bold().light_yellow().dim()].to_vec(),
+
                ]
+
                .concat();
+

+
                [
+
                    ui::span::hunk_state(state).into_right_aligned_line().into(),
+
                    HunkItem::pretty_path(path, false).into(),
+
                    Line::from(stats_cell).right_aligned().into(),
+
                ]
+
            }
+
            (
+
                _,
+
                Item::Deleted {
+
                    path,
+
                    header: _,
+
                    old: _,
+
                    hunk,
+
                    _stats: _,
+
                },
+
                state,
+
            ) => {
+
                let stats = hunk.as_ref().map(HunkStats::from).unwrap_or_default();
+
                let stats_cell = [
+
                    build_stats_spans(&DiffStats::Hunk(stats)),
+
                    [span::default(" D ").bold().light_red().dim()].to_vec(),
+
                ]
+
                .concat();
+

+
                [
+
                    ui::span::hunk_state(state).into_right_aligned_line().into(),
+
                    HunkItem::pretty_path(path, true).into(),
+
                    Line::from(stats_cell).right_aligned().into(),
+
                ]
+
            }
+
            (_, Item::Copied { copied }, state) => {
+
                let stats = copied.diff.stats().copied().unwrap_or_default();
+
                let stats_cell = [
+
                    build_stats_spans(&DiffStats::File(stats)),
+
                    [span::default(" CP ").bold().light_blue().dim()].to_vec(),
+
                ]
+
                .concat();
+

+
                [
+
                    ui::span::hunk_state(state).into_right_aligned_line().into(),
+
                    HunkItem::pretty_path(&copied.new_path, false).into(),
+
                    Line::from(stats_cell).right_aligned().into(),
+
                ]
+
            }
+
            (_, Item::Moved { moved }, state) => {
+
                let stats = moved.diff.stats().copied().unwrap_or_default();
+
                let stats_cell = [
+
                    build_stats_spans(&DiffStats::File(stats)),
+
                    [span::default(" MV ").bold().light_blue().dim()].to_vec(),
+
                ]
+
                .concat();
+

+
                [
+
                    ui::span::hunk_state(state).into_right_aligned_line().into(),
+
                    HunkItem::pretty_path(&moved.new_path, 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()
+
                    .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()
+
                    .into(),
+
            ],
+
        }
+
    }
+
}
+

+
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();
+
            if count == 1 {
+
                span::default(" 1 comment ").dim().reversed()
+
            } else {
+
                span::default(&format!(" {} comments ", count))
+
                    .dim()
+
                    .reversed()
+
            }
+
        } else {
+
            span::blank()
+
        };
+

+
        match &self.inner {
+
            (
+
                _,
+
                crate::cob::HunkItem::Added {
+
                    path,
+
                    header: _,
+
                    new: _,
+
                    hunk: _,
+
                    _stats: _,
+
                },
+
                _,
+
            ) => {
+
                let path = HunkItem::pretty_path(path, false);
+
                let header = [
+
                    Column::new("", Constraint::Length(0)),
+
                    Column::new(path.clone(), Constraint::Length(path.width() as u16)),
+
                    Column::new(
+
                        Line::from(
+
                            [
+
                                comment_tag,
+
                                span::default(" "),
+
                                span::default(" added ").light_green().dim().reversed(),
+
                            ]
+
                            .to_vec(),
+
                        )
+
                        .right_aligned(),
+
                        Constraint::Fill(1),
+
                    ),
+
                ];
+

+
                header.to_vec()
+
            }
+
            (
+
                _,
+
                crate::cob::HunkItem::Modified {
+
                    path,
+
                    header: _,
+
                    old: _,
+
                    new: _,
+
                    hunk: _,
+
                    _stats: _,
+
                },
+
                _,
+
            ) => {
+
                let path = HunkItem::pretty_path(path, false);
+
                let header = [
+
                    Column::new("", Constraint::Length(0)),
+
                    Column::new(path.clone(), Constraint::Length(path.width() as u16)),
+
                    Column::new(
+
                        Line::from(
+
                            [
+
                                comment_tag,
+
                                span::default(" "),
+
                                span::default(" modified ").light_yellow().dim().reversed(),
+
                            ]
+
                            .to_vec(),
+
                        )
+
                        .right_aligned(),
+
                        Constraint::Fill(1),
+
                    ),
+
                ];
+

+
                header.to_vec()
+
            }
+
            (
+
                _,
+
                crate::cob::HunkItem::Deleted {
+
                    path,
+
                    header: _,
+
                    old: _,
+
                    hunk: _,
+
                    _stats: _,
+
                },
+
                _,
+
            ) => {
+
                let path = HunkItem::pretty_path(path, true);
+
                let header = [
+
                    Column::new("", Constraint::Length(0)),
+
                    Column::new(path.clone(), Constraint::Length(path.width() as u16)),
+
                    Column::new(
+
                        Line::from(
+
                            [
+
                                comment_tag,
+
                                span::default(" "),
+
                                span::default(" deleted ").light_red().dim().reversed(),
+
                            ]
+
                            .to_vec(),
+
                        )
+
                        .right_aligned(),
+
                        Constraint::Fill(1),
+
                    ),
+
                ];
+

+
                header.to_vec()
+
            }
+
            (_, crate::cob::HunkItem::Copied { copied }, _) => {
+
                let path = Line::from(
+
                    [
+
                        HunkItem::pretty_path(&copied.old_path, false).spans,
+
                        [span::default(" -> ")].to_vec(),
+
                        HunkItem::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_blue()
+
                            .dim()
+
                            .reversed()
+
                            .into_right_aligned_line(),
+
                        Constraint::Fill(1),
+
                    ),
+
                ];
+

+
                header.to_vec()
+
            }
+
            (_, crate::cob::HunkItem::Moved { moved }, _) => {
+
                let path = Line::from(
+
                    [
+
                        HunkItem::pretty_path(&moved.old_path, false).spans,
+
                        [span::default(" -> ")].to_vec(),
+
                        HunkItem::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_blue()
+
                            .dim()
+
                            .reversed()
+
                            .into_right_aligned_line(),
+
                        Constraint::Fill(1),
+
                    ),
+
                ];
+

+
                header.to_vec()
+
            }
+
            (
+
                _,
+
                crate::cob::HunkItem::EofChanged {
+
                    path,
+
                    header: _,
+
                    old: _,
+
                    new: _,
+
                    _eof: _,
+
                },
+
                _,
+
            ) => {
+
                let path = HunkItem::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),
+
                    ),
+
                ];
+

+
                header.to_vec()
+
            }
+
            (
+
                _,
+
                crate::cob::HunkItem::ModeChanged {
+
                    path,
+
                    header: _,
+
                    old: _,
+
                    new: _,
+
                },
+
                _,
+
            ) => {
+
                let path = HunkItem::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),
+
                    ),
+
                ];
+

+
                header.to_vec()
+
            }
+
        }
+
    }
+

+
    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, .. }, _) => {
+
                let mut lines = hunk
+
                    .as_ref()
+
                    .map(|hunk| Text::from(hunk.to_text(&self.lines)));
+
                let start = hunk
+
                    .as_ref()
+
                    .map(|hunk| hunk.new.start as usize)
+
                    .unwrap_or_default();
+

+
                lines = lines.map(|lines| {
+
                    let mut mixins = HashMap::new();
+

+
                    let divider = span::default(&"─".to_string().repeat(500)).gray().dim();
+

+
                    for (line, comments) in self.comments.all() {
+
                        mixins.insert(
+
                            *line,
+
                            comments
+
                                .iter()
+
                                .enumerate()
+
                                .map(|(idx, comment)| {
+
                                    // let body = span::default(comment.1.body()).gray();
+
                                    let timestamp =
+
                                        span::timestamp(&format::timestamp(&comment.1.timestamp()));
+
                                    let author =
+
                                        span::alias(&format::did(&Did::from(comment.1.author())));
+

+
                                    let mut rendered = vec![];
+

+
                                    // Only add top divider for the first comment
+
                                    if idx == 0 {
+
                                        rendered.push(Line::from([divider.clone()].to_vec()));
+
                                    }
+

+
                                    // Add comment body
+
                                    rendered.extend(
+
                                        comment
+
                                            .1
+
                                            .body()
+
                                            .lines()
+
                                            .map(|line| {
+
                                                Line::from([span::default(line).gray()].to_vec())
+
                                            })
+
                                            .collect::<Vec<_>>(),
+
                                    );
+

+
                                    // Add metadata
+
                                    rendered.push(
+
                                        Line::from(
+
                                            [timestamp, span::default(" by ").dim(), author]
+
                                                .to_vec(),
+
                                        )
+
                                        .right_aligned(),
+
                                    );
+

+
                                    // Add bottom divider
+
                                    rendered.push(Line::from([divider.clone()].to_vec()));
+

+
                                    rendered
+
                                })
+
                                .collect(),
+
                        );
+
                    }
+
                    let merged = LineMerger::merge(lines.lines.clone(), mixins, start);
+

+
                    Text::from(merged)
+
                });
+

+
                lines
+
            }
+
            _ => None,
+
        }
+
    }
+
}
+

+
/// 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> {
+
    fn from(highlighted: Line<'a>) -> Self {
+
        let converted = highlighted.to_string().into_text().unwrap().lines;
+

+
        Self(converted.first().cloned().unwrap_or_default())
+
    }
+
}
+

+
impl<'a> From<HighlightedLine<'a>> for Line<'a> {
+
    fn from(val: HighlightedLine<'a>) -> Self {
+
        val.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(&'a self, context: &Self::Context) -> Self::Output;
+
}
+

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

+
    fn to_text(&self, _context: &Self::Context) -> 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).as_ref()),
+
            ]
+
            .to_vec(),
+
        )
+
    }
+
}
+

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

+
    fn to_text(&'a self, blobs: &Blobs<Vec<Line<'a>>>) -> 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(&'a self, blobs: &Self::Context) -> 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).as_ref())
+
                        .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(blobs)
+
                                .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(blobs)
+
                                .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(blobs).spans,
+
                        ]
+
                        .concat(),
+
                    ));
+
                }
+
            }
+
        }
+
        lines
+
    }
+
}
+

#[cfg(test)]
mod tests {
    use anyhow::Result;
added bin/ui/span.rs
@@ -0,0 +1,15 @@
+
use ratatui::text::Span;
+

+
use crate::cob::HunkState;
+

+
use radicle_tui as tui;
+

+
use tui::ui::span;
+

+
pub fn hunk_state(state: &HunkState) -> Span<'static> {
+
    match state {
+
        HunkState::Accepted => span::positive("✓"),
+
        // HunkState::Rejected => span::secondary("?"),
+
        HunkState::Rejected => span::negative("✗"),
+
    }
+
}
modified examples/hello.rs
@@ -1,5 +1,6 @@
use anyhow::Result;

+
use ratatui::layout::Position;
use termion::event::Key;

use ratatui::{Frame, Viewport};
@@ -50,7 +51,12 @@ impl store::Update<Message> for App {
impl Show<Message> for App {
    fn show(&self, ctx: &Context<Message>, frame: &mut Frame) -> Result<()> {
        Window::default().show(ctx, |ui| {
-
            ui.text_view(frame, self.alien.clone(), &mut (0, 0), Some(Borders::None));
+
            ui.text_view(
+
                frame,
+
                self.alien.clone(),
+
                &mut Position::default(),
+
                Some(Borders::None),
+
            );

            if ui.input_global(|key| key == Key::Char('q')) {
                ui.send_message(Message::Quit);
modified examples/selection.rs
@@ -68,10 +68,8 @@ impl Update<Message> for App {
                .selector
                .selected()
                .and_then(|selected| self.items.get(selected))
-
                .and_then(|item| {
-
                    Some(Exit {
-
                        value: Some(item.id),
-
                    })
+
                .map(|item| Exit {
+
                    value: Some(item.id),
                }),
            Message::Quit => Some(Exit { value: None }),
        }
modified src/ui.rs
@@ -4,6 +4,7 @@ pub mod layout;
pub mod rm;
pub mod span;
pub mod theme;
+
pub mod utils;

use ratatui::layout::Constraint;
use ratatui::text::Text;
modified src/ui/im.rs
@@ -14,7 +14,7 @@ use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};

use termion::event::Key;

-
use ratatui::layout::{Constraint, Rect};
+
use ratatui::layout::{Constraint, Position, Rect};
use ratatui::{Frame, Viewport};

use crate::event::Event;
@@ -563,12 +563,21 @@ where
        &mut self,
        frame: &mut Frame,
        text: impl Into<Text<'a>>,
-
        scroll: &'a mut (usize, usize),
+
        scroll: &'a mut Position,
        borders: Option<Borders>,
    ) -> Response {
        widget::TextView::new(text, scroll, borders).ui(self, frame)
    }

+
    pub fn centered_text_view<'a>(
+
        &mut self,
+
        frame: &mut Frame,
+
        text: impl Into<Text<'a>>,
+
        borders: Option<Borders>,
+
    ) -> Response {
+
        widget::CenteredTextView::new(text, borders).ui(self, frame)
+
    }
+

    pub fn text_edit_singleline(
        &mut self,
        frame: &mut Frame,
modified src/ui/im/widget.rs
@@ -1,6 +1,6 @@
use std::cmp;

-
use ratatui::layout::{Direction, Layout, Rect};
+
use ratatui::layout::{Direction, Layout, Position, Rect};
use ratatui::style::{Style, Stylize};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, BorderType, Row, Scrollbar, ScrollbarState};
@@ -663,11 +663,11 @@ impl<'a> Widget for Bar<'a> {
#[derive(Clone, Debug)]
pub struct TextViewState<'a> {
    text: Text<'a>,
-
    cursor: (usize, usize),
+
    cursor: Position,
}

impl<'a> TextViewState<'a> {
-
    pub fn new(text: impl Into<Text<'a>>, cursor: (usize, usize)) -> Self {
+
    pub fn new(text: impl Into<Text<'a>>, cursor: Position) -> Self {
        Self {
            text: text.into(),
            cursor,
@@ -678,61 +678,61 @@ impl<'a> TextViewState<'a> {
        &self.text
    }

-
    pub fn cursor(&self) -> (usize, usize) {
+
    pub fn cursor(&self) -> Position {
        self.cursor
    }
}

impl<'a> TextViewState<'a> {
    fn scroll_up(&mut self) {
-
        self.cursor.0 = self.cursor.0.saturating_sub(1);
+
        self.cursor.x = self.cursor.x.saturating_sub(1);
    }

    fn scroll_down(&mut self, len: usize, page_size: usize) {
        let end = len.saturating_sub(page_size);
-
        self.cursor.0 = std::cmp::min(self.cursor.0.saturating_add(1), end);
+
        self.cursor.x = std::cmp::min(self.cursor.x.saturating_add(1), end as u16);
    }

    fn scroll_left(&mut self) {
-
        self.cursor.1 = self.cursor.1.saturating_sub(3);
+
        self.cursor.y = self.cursor.y.saturating_sub(3);
    }

    fn scroll_right(&mut self, max_line_length: usize) {
-
        self.cursor.1 = std::cmp::min(
-
            self.cursor.1.saturating_add(3),
-
            max_line_length.saturating_add(3),
+
        self.cursor.y = std::cmp::min(
+
            self.cursor.y.saturating_add(3),
+
            max_line_length.saturating_add(3) as u16,
        );
    }

    fn prev_page(&mut self, page_size: usize) {
-
        self.cursor.0 = self.cursor.0.saturating_sub(page_size);
+
        self.cursor.x = self.cursor.x.saturating_sub(page_size as u16);
    }

    fn next_page(&mut self, len: usize, page_size: usize) {
        let end = len.saturating_sub(page_size);

-
        self.cursor.0 = std::cmp::min(self.cursor.0.saturating_add(page_size), end);
+
        self.cursor.x = std::cmp::min(self.cursor.x.saturating_add(page_size as u16), end as u16);
    }

    fn begin(&mut self) {
-
        self.cursor.0 = 0;
+
        self.cursor.x = 0;
    }

    fn end(&mut self, len: usize, page_size: usize) {
-
        self.cursor.0 = len.saturating_sub(page_size);
+
        self.cursor.x = len.saturating_sub(page_size) as u16;
    }
}

pub struct TextView<'a> {
    text: Text<'a>,
    borders: Option<Borders>,
-
    cursor: &'a mut (usize, usize),
+
    cursor: &'a mut Position,
}

impl<'a> TextView<'a> {
    pub fn new(
        text: impl Into<Text<'a>>,
-
        cursor: &'a mut (usize, usize),
+
        cursor: &'a mut Position,
        borders: Option<Borders>,
    ) -> Self {
        Self {
@@ -794,11 +794,11 @@ impl<'a> Widget for TextView<'a> {
        let mut scroller_state = ScrollbarState::default()
            .content_length(length.saturating_sub(content_length))
            .viewport_content_length(1)
-
            .position(self.cursor.0);
+
            .position(self.cursor.x as usize);

        frame.render_stateful_widget(scroller, scroller_area, &mut scroller_state);
        frame.render_widget(
-
            Paragraph::new(self.text.clone()).scroll((self.cursor.0 as u16, self.cursor.1 as u16)),
+
            Paragraph::new(self.text.clone()).scroll((self.cursor.x, self.cursor.y)),
            text_area,
        );

@@ -849,6 +849,44 @@ impl<'a> Widget for TextView<'a> {
    }
}

+
pub struct CenteredTextView<'a> {
+
    content: Text<'a>,
+
    borders: Option<Borders>,
+
}
+

+
impl<'a> CenteredTextView<'a> {
+
    pub fn new(content: impl Into<Text<'a>>, borders: Option<Borders>) -> Self {
+
        Self {
+
            content: content.into(),
+
            borders,
+
        }
+
    }
+
}
+

+
impl<'a> Widget for CenteredTextView<'a> {
+
    fn ui<M>(self, ui: &mut Ui<M>, frame: &mut Frame) -> Response {
+
        let (area, area_focus) = ui.next_area().unwrap_or_default();
+

+
        let border_style = if area_focus && ui.has_focus() {
+
            ui.theme.focus_border_style
+
        } else {
+
            ui.theme.border_style
+
        };
+

+
        let area = render_block(frame, area, self.borders, border_style);
+
        let area = Rect {
+
            x: area.x.saturating_add(1),
+
            width: area.width.saturating_sub(1),
+
            ..area
+
        };
+
        let center = layout::centered_rect(area, 50, 10);
+

+
        frame.render_widget(self.content.centered(), center);
+

+
        Response::default()
+
    }
+
}
+

#[derive(Clone, Debug)]
pub struct TextEditState {
    pub text: String,
added src/ui/utils.rs
@@ -0,0 +1,143 @@
+
use std::collections::HashMap;
+

+
pub struct LineMerger;
+

+
impl LineMerger {
+
    pub fn merge<T: Clone>(
+
        lines: Vec<T>,
+
        mixins: HashMap<usize, Vec<Vec<T>>>,
+
        start: usize,
+
    ) -> Vec<T> {
+
        let mut merged = vec![];
+
        for (idx, line) in lines.iter().enumerate() {
+
            merged.push(line.clone());
+

+
            let actual_idx = idx.saturating_add(start);
+
            if let Some(mixins) = mixins.get(&actual_idx) {
+
                for mixin in mixins {
+
                    for mixin_line in mixin {
+
                        merged.push(mixin_line.clone());
+
                    }
+
                }
+
            }
+
        }
+

+
        merged
+
    }
+
}
+

+
#[cfg(test)]
+
mod test {
+
    use std::collections::HashMap;
+

+
    use pretty_assertions::assert_eq;
+

+
    use crate::ui::utils::LineMerger;
+

+
    #[test]
+
    fn lines_should_be_merged_correctly() -> anyhow::Result<()> {
+
        let diff = r#"
+
fn main() {
+
    println!("Hello, world!");
+

+
    another_function();
+
}
+

+
fn another_function() {
+
    println!("Another function.");
+
}"#;
+

+
        let comment = r#"──────────────────────────────────────
+
Is this needed?
+
──────────────────────────────────────"#
+
            .to_string();
+
        let comment = comment.lines().collect::<Vec<_>>();
+

+
        let merged = LineMerger::merge(
+
            diff.lines().collect(),
+
            HashMap::from([(3_usize, vec![comment])]),
+
            1,
+
        );
+
        let actual = build_string(merged);
+

+
        let expected = r#"
+
fn main() {
+
    println!("Hello, world!");
+
──────────────────────────────────────
+
Is this needed?
+
──────────────────────────────────────
+

+
    another_function();
+
}
+

+
fn another_function() {
+
    println!("Another function.");
+
}"#;
+

+
        let expected = expected.to_string();
+

+
        assert_eq!(expected, actual);
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn lines_with_start_should_be_merged_correctly() -> anyhow::Result<()> {
+
        let diff = r#"
+
fn main() {
+
    println!("Hello, world!");
+

+
    another_function();
+
}
+

+
fn another_function() {
+
    println!("Another function.");
+
}"#;
+

+
        let comment = r#"──────────────────────────────────────
+
Is this needed?
+
──────────────────────────────────────"#
+
            .to_string();
+
        let comment = comment.lines().collect::<Vec<_>>();
+

+
        let merged = LineMerger::merge(
+
            diff.lines().collect(),
+
            HashMap::from([(104_usize, vec![comment])]),
+
            100,
+
        );
+
        let actual = build_string(merged);
+

+
        let expected = r#"
+
fn main() {
+
    println!("Hello, world!");
+

+
    another_function();
+
──────────────────────────────────────
+
Is this needed?
+
──────────────────────────────────────
+
}
+

+
fn another_function() {
+
    println!("Another function.");
+
}"#;
+

+
        let expected = expected.to_string();
+

+
        assert_eq!(expected, actual);
+

+
        Ok(())
+
    }
+

+
    fn build_string(lines: Vec<&str>) -> String {
+
        let mut actual = String::new();
+
        for (idx, line) in lines.iter().enumerate() {
+
            if idx == lines.len() - 1 {
+
                actual.push_str(&line.to_string());
+
            } else {
+
                actual.push_str(&format!("{}\n", line));
+
            }
+
        }
+

+
        actual
+
    }
+
}