Radish alpha
r
rad:z39mP9rQAaGmERfUMPULfPUi473tY
Radicle terminal user interface
Radicle
Git
[WIP] feat(patch): Move to new diff location
Draft did:key:z6MkgFq6...nBGz opened 9 months ago
9 files changed +441 -522 a2de36dd 2c908f70
modified Cargo.lock
@@ -706,6 +706,12 @@ dependencies = [
]

[[package]]
+
name = "defer-heavy"
+
version = "0.1.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "5053691e3e6c0e5979cfb55503b7eb4b06531897b5c15b0f617110096b05a0e1"
+

+
[[package]]
name = "der"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -883,10 +889,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad"
dependencies = [
 "libc",
-
 "windows-sys 0.52.0",
+
 "windows-sys 0.59.0",
]

[[package]]
+
name = "fast-glob"
+
version = "0.3.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "3afcf4effa2c44390b9912544582d5af29e10dc4c816c5dbebf748e1c7416faa"
+

+
[[package]]
name = "fastrand"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1259,6 +1271,7 @@ checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661"
dependencies = [
 "equivalent",
 "hashbrown 0.15.4",
+
 "serde",
]

[[package]]
@@ -1467,9 +1480,9 @@ dependencies = [

[[package]]
name = "log"
-
version = "0.4.19"
+
version = "0.4.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "b06a4cde4c0f271a446782e3eff8de789548ce57dbc8eca9292c27f4a42004b4"
+
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"

[[package]]
name = "lru"
@@ -2005,8 +2018,6 @@ dependencies = [
[[package]]
name = "radicle"
version = "0.16.1"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "d5fe953b25a8f5c24baf1019f746912e7453746d617a1af04cb347904d241005"
dependencies = [
 "amplify",
 "base64 0.21.7",
@@ -2015,8 +2026,10 @@ dependencies = [
 "colored",
 "crossbeam-channel",
 "cyphernet",
+
 "fast-glob",
 "fastrand",
 "git2",
+
 "indexmap",
 "libc",
 "localtime",
 "log",
@@ -2036,13 +2049,12 @@ dependencies = [
 "tempfile",
 "thiserror",
 "unicode-normalization",
+
 "winpipe",
]

[[package]]
name = "radicle-cli"
version = "0.14.0"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "0b362b0301c59fb94f4ead7bd7366c0668ac431c1fed8a9fe8fc88a64b03dc91"
dependencies = [
 "anyhow",
 "chrono",
@@ -2084,14 +2096,11 @@ dependencies = [
[[package]]
name = "radicle-cob"
version = "0.14.0"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "6e9c90efa7a3febd01d33ed2e72cb12296c971ce03efa243d11c01520fcc1be8"
dependencies = [
 "fastrand",
 "git2",
 "log",
 "nonempty",
-
 "once_cell",
 "radicle-crypto",
 "radicle-dag",
 "radicle-git-ext",
@@ -2104,8 +2113,6 @@ dependencies = [
[[package]]
name = "radicle-crypto"
version = "0.12.0"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "d16d9e1403a6c3073dce14f3ed893f430bb67d7af6a07cc7fe4b81907025ba22"
dependencies = [
 "amplify",
 "cyphernet",
@@ -2126,8 +2133,6 @@ dependencies = [
[[package]]
name = "radicle-dag"
version = "0.10.0"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "cb41c7e10ada3a4df960190a96bfb4af56d33ada890f917acc8e3b122b614875"
dependencies = [
 "fastrand",
]
@@ -2149,8 +2154,6 @@ dependencies = [
[[package]]
name = "radicle-signals"
version = "0.11.0"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "d2bcf567e08ec477457dafd190a9785f368c9b86272a42c684db609510ebc456"
dependencies = [
 "crossbeam-channel",
 "libc",
@@ -2160,12 +2163,9 @@ dependencies = [
[[package]]
name = "radicle-ssh"
version = "0.9.0"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "fbee758010fb64482be4b18591fbeb3cbc15b16450d143edf4edb5484c7366c6"
dependencies = [
-
 "byteorder",
-
 "log",
 "thiserror",
+
 "winpipe",
 "zeroize",
]

@@ -2197,8 +2197,6 @@ dependencies = [
[[package]]
name = "radicle-term"
version = "0.13.0"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "5fd82b00d1e729319fdeccc6a2e37158b01ad32cc8e4dbff40a612ca94a1e311"
dependencies = [
 "anstyle-query",
 "anyhow",
@@ -2206,9 +2204,7 @@ dependencies = [
 "crossterm 0.29.0",
 "git2",
 "inquire",
-
 "libc",
 "radicle-signals",
-
 "shlex",
 "thiserror",
 "unicode-display-width",
 "unicode-segmentation",
@@ -2262,6 +2258,7 @@ version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
+
 "libc",
 "rand_chacha",
 "rand_core",
]
@@ -2456,7 +2453,7 @@ dependencies = [
 "errno",
 "libc",
 "linux-raw-sys 0.9.4",
-
 "windows-sys 0.52.0",
+
 "windows-sys 0.59.0",
]

[[package]]
@@ -2900,6 +2897,12 @@ dependencies = [
]

[[package]]
+
name = "sync-ptr"
+
version = "0.1.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "2b115b4cc742d11625f50e0e48ab15baf6fa548c2ec33a8d4113711886316a4f"
+

+
[[package]]
name = "synstructure"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3549,7 +3552,17 @@ version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143"
dependencies = [
-
 "windows-core",
+
 "windows-core 0.57.0",
+
 "windows-targets 0.52.6",
+
]
+

+
[[package]]
+
name = "windows"
+
version = "0.58.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6"
+
dependencies = [
+
 "windows-core 0.58.0",
 "windows-targets 0.52.6",
]

@@ -3559,9 +3572,22 @@ version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d"
dependencies = [
-
 "windows-implement",
-
 "windows-interface",
-
 "windows-result",
+
 "windows-implement 0.57.0",
+
 "windows-interface 0.57.0",
+
 "windows-result 0.1.2",
+
 "windows-targets 0.52.6",
+
]
+

+
[[package]]
+
name = "windows-core"
+
version = "0.58.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99"
+
dependencies = [
+
 "windows-implement 0.58.0",
+
 "windows-interface 0.58.0",
+
 "windows-result 0.2.0",
+
 "windows-strings",
 "windows-targets 0.52.6",
]

@@ -3577,6 +3603,17 @@ dependencies = [
]

[[package]]
+
name = "windows-implement"
+
version = "0.58.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b"
+
dependencies = [
+
 "proc-macro2",
+
 "quote",
+
 "syn 2.0.90",
+
]
+

+
[[package]]
name = "windows-interface"
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3588,6 +3625,17 @@ dependencies = [
]

[[package]]
+
name = "windows-interface"
+
version = "0.58.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515"
+
dependencies = [
+
 "proc-macro2",
+
 "quote",
+
 "syn 2.0.90",
+
]
+

+
[[package]]
name = "windows-result"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3597,6 +3645,25 @@ dependencies = [
]

[[package]]
+
name = "windows-result"
+
version = "0.2.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e"
+
dependencies = [
+
 "windows-targets 0.52.6",
+
]
+

+
[[package]]
+
name = "windows-strings"
+
version = "0.1.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
+
dependencies = [
+
 "windows-result 0.2.0",
+
 "windows-targets 0.52.6",
+
]
+

+
[[package]]
name = "windows-sys"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3745,6 +3812,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"

[[package]]
+
name = "winpipe"
+
version = "0.1.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "1ccf671d62d1bd0c913d9059e69bb4a6b51f7a4c899ab83c62d921e35f206053"
+
dependencies = [
+
 "defer-heavy",
+
 "log",
+
 "rand",
+
 "sync-ptr",
+
 "windows 0.58.0",
+
]
+

+
[[package]]
name = "writeable"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
modified Cargo.toml
@@ -35,11 +35,15 @@ lazy_static = { version = "1.5.0" }
libc = { version = "^0.2" }
log = { version = "0.4.19" }
nom = { version = "^7.1.0" }
-
radicle = { version = "0.16.1" }
-
radicle-term = { version = "0.13.0" }
-
radicle-cli = { version = "0.14.0" }
+
# radicle = { version = "0.16.1" }
+
# radicle-term = { version = "0.13.0" }
+
# radicle-cli = { version = "0.14.0" }
radicle-surf = { version = "0.22.0" }
-
radicle-signals = { version = "0.11.0" }
+
# radicle-signals = { version = "0.11.0" }
+
radicle = { path = "../heartwood/crates/radicle" }
+
radicle-term = { path = "../heartwood/crates/radicle-term" }
+
radicle-cli = { path = "../heartwood/crates/radicle-cli" }
+
radicle-signals = { path = "../heartwood/crates/radicle-signals" }
ratatui = { version = "0.29.0", default-features = false, features = [
    "all-widgets",
    "termion",
@@ -66,7 +70,8 @@ tui-tree-widget = { version = "0.23.0" }
assert_cmd = "2.0.14"
predicates = "3.1.0"
pretty_assertions = "^1.4.1"
-
radicle = { version = "0.16.1", features = ["test"] }
+
# radicle = { version = "0.16.1", features = ["test"] }
+
radicle = { path = "../heartwood/crates/radicle", features = ["test"] }
radicle-git-ext = { version = "0.8.0", features = ["serde"] }


modified bin/commands/patch.rs
@@ -452,7 +452,11 @@ mod interface {
                    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 builder = CommentBuilder::new(
+
                            *revision.base(),
+
                            revision.head(),
+
                            path.to_path_buf(),
+
                        );
                        let comments = builder.edit(hunk)?;

                        let signer = profile.signer()?;
modified bin/commands/patch/review.rs
@@ -267,7 +267,7 @@ impl App<'_> {
            .enumerate()
            .map(|(idx, item)| {
                StatefulHunkItem::new(
-
                    HunkItem::from((&repo, &review, item)),
+
                    HunkItem::from((&repo, &review, item, &idx)),
                    state.hunk_states().get(idx).cloned().unwrap_or_default(),
                )
            })
modified bin/commands/patch/review/builder.rs
@@ -12,22 +12,21 @@
//!
use std::fmt::Write as _;
use std::io;
-
use std::ops::{Not, Range};
use std::path::PathBuf;

use radicle::cob::patch::Revision;
-
use radicle::cob::{CodeLocation, CodeRange};
+
use radicle::cob::{DiffLocation, HunkIndex};
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::Encode;
use radicle_cli::git::unified_diff::{self, FileHeader};
-
use radicle_cli::git::unified_diff::{Encode, HunkHeader};
use radicle_cli::terminal as term;

-
use crate::git::HunkDiff;
+
use crate::git::{Hunk, HunkDiff};

/// Queue of items (usually hunks) left to review.
#[derive(Clone, Default, Debug)]
@@ -70,7 +69,9 @@ impl Hunks {
                        ..
                    } = a.diff.clone()
                    {
-
                        hs.pop()
+
                        hs.len()
+
                            .checked_sub(1)
+
                            .and_then(|i| hs.pop().map(|h| Hunk::new(i, h)))
                    } else {
                        None
                    },
@@ -87,7 +88,9 @@ impl Hunks {
                        ..
                    } = d.diff.clone()
                    {
-
                        hs.pop()
+
                        hs.len()
+
                            .checked_sub(1)
+
                            .and_then(|i| hs.pop().map(|h| Hunk::new(i, h)))
                    } else {
                        None
                    },
@@ -124,13 +127,13 @@ impl Hunks {
                    } => {
                        let base_hunks = hunks.clone();

-
                        for hunk in base_hunks {
+
                        for (i, hunk) in base_hunks.iter().enumerate() {
                            self.add_item(HunkDiff::Modified {
                                path: m.path.clone(),
                                header: header.clone(),
                                old: m.old.clone(),
                                new: m.new.clone(),
-
                                hunk: Some(hunk),
+
                                hunk: Some(Hunk::new(i, hunk.clone())),
                                _stats: Some(stats),
                            });
                        }
@@ -235,7 +238,7 @@ impl<'a> ReviewBuilder<'a> {

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

@@ -251,71 +254,74 @@ pub enum Error {
    Format(#[from] std::fmt::Error),
    #[error(transparent)]
    Git(#[from] git::raw::Error),
+
    #[error(transparent)]
+
    Editor(#[from] term::editor::Error),
}

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

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

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

        let output = term::Editor::comment()
            .extension("diff")
-
            .initial(input)?
+
            .initial(&input)
            .edit()?;

        if let Some(output) = output {
-
            let header = HunkHeader::try_from(hunk)?;
-
            self.add_hunk(header, &output);
+
            self.add_hunk(hunk, &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);
+
    fn add_hunk(&mut self, hunk: &Hunk, input: &str) -> &mut Self {
+
        let lines = input
+
            .trim()
+
            .lines()
+
            .map(|l| l.trim())
+
            // Skip the hunk header
+
            .filter(|l| !l.starts_with("> @@"));
        let mut comment = String::new();
+
        // Keeps track of the line index within the hunk itself
+
        let mut line_ix = 0_usize;
+
        // Keeps track of whether the first comment is at the top-level
+
        let mut top_level = true;

        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;
+
                    if top_level {
+
                        // Top-level comment
+
                        self.add_comment(hunk.as_index(None), &comment);
+
                    } else {
+
                        self.add_comment(
+
                            hunk.as_index(Some(line_ix.saturating_sub(1)..line_ix)),
+
                            &comment,
+
                        );
                    }
+
                    comment.clear();
                }
+
                line_ix += 1;
+
                // Can no longer be a top-level comment
+
                top_level = false;
            } else {
                comment.push_str(line);
                comment.push('\n');
@@ -323,49 +329,26 @@ impl CommentBuilder {
        }
        if !comment.is_empty() {
            self.add_comment(
-
                &hunk,
+
                hunk.as_index(Some(line_ix.saturating_sub(1)..line_ix)),
                &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>,
-
    ) {
+
    fn add_comment(&mut self, selection: Option<HunkIndex>, comment: &str) {
        // 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,
+
            location: DiffLocation {
+
                base: self.base,
+
                head: self.commit,
                path: self.path.clone(),
-
                old: old_range,
-
                new: new_range,
+
                selection,
            },
            body: comment.trim().to_owned(),
        });
@@ -378,6 +361,8 @@ impl CommentBuilder {
#[cfg(test)]
mod tests {
    use super::*;
+
    use radicle::cob::CodeRange;
+
    use radicle_surf::diff;
    use std::str::FromStr;

    #[test]
@@ -427,65 +412,73 @@ Comment #5.

"#;

+
        let base = git::raw::Oid::zero().into();
        let commit = Oid::from_str("a32c4b93e2573fd83b15ac1ad6bf1317dc8fd760").unwrap();
        let path = PathBuf::from_str("main.rs").unwrap();
        let expected = &[
            (ReviewComment {
-
                location: CodeLocation {
+
                location: DiffLocation::hunk_level(
+
                    git::raw::Oid::zero().into(),
                    commit,
-
                    path: path.clone(),
-
                    old: Some(CodeRange::Lines { range: 2559..2565 }),
-
                    new: Some(CodeRange::Lines { range: 2560..2563 }),
-
                },
+
                    path.clone(),
+
                    HunkIndex::new(0, CodeRange::lines(5..6)),
+
                ),
                body: "Comment #1.".to_owned(),
            }),
            (ReviewComment {
-
                location: CodeLocation {
+
                location: DiffLocation::hunk_level(
+
                    git::raw::Oid::zero().into(),
                    commit,
-
                    path: path.clone(),
-
                    old: Some(CodeRange::Lines { range: 2565..2568 }),
-
                    new: Some(CodeRange::Lines { range: 2563..2567 }),
-
                },
+
                    path.clone(),
+
                    HunkIndex::new(0, CodeRange::lines(11..12)),
+
                ),
                body: "Comment #2.".to_owned(),
            }),
            (ReviewComment {
-
                location: CodeLocation {
+
                location: DiffLocation::hunk_level(
+
                    git::raw::Oid::zero().into(),
                    commit,
-
                    path: path.clone(),
-
                    old: Some(CodeRange::Lines { range: 2568..2571 }),
-
                    new: Some(CodeRange::Lines { range: 2567..2570 }),
-
                },
+
                    path.clone(),
+
                    HunkIndex::new(0, CodeRange::lines(16..17)),
+
                ),
                body: "Comment #3.".to_owned(),
            }),
            (ReviewComment {
-
                location: CodeLocation {
+
                location: DiffLocation::hunk_level(
+
                    git::raw::Oid::zero().into(),
                    commit,
-
                    path: path.clone(),
-
                    old: None,
-
                    new: Some(CodeRange::Lines { range: 2570..2571 }),
-
                },
+
                    path.clone(),
+
                    HunkIndex::new(0, CodeRange::lines(17..18)),
+
                ),
                body: "Comment #4.".to_owned(),
            }),
            (ReviewComment {
-
                location: CodeLocation {
+
                location: DiffLocation::hunk_level(
+
                    git::raw::Oid::zero().into(),
                    commit,
-
                    path: path.clone(),
-
                    old: Some(CodeRange::Lines { range: 2571..2577 }),
-
                    new: Some(CodeRange::Lines { range: 2571..2578 }),
-
                },
+
                    path.clone(),
+
                    HunkIndex::new(0, CodeRange::lines(25..26)),
+
                ),
                body: "Comment #5.".to_owned(),
            }),
        ];

-
        let mut builder = CommentBuilder::new(commit, path.clone());
+
        let mut builder = CommentBuilder::new(base, commit, path.clone());
        builder.add_hunk(
-
            HunkHeader {
-
                old_line_no: 2559,
-
                old_size: 18,
-
                new_line_no: 2560,
-
                new_size: 18,
-
                text: vec![],
-
            },
+
            &Hunk::new(
+
                0,
+
                diff::Hunk {
+
                    header: diff::Line::from(vec![]),
+
                    lines: std::iter::repeat(diff::Modification::addition(
+
                        diff::Line::from(vec![]),
+
                        1,
+
                    ))
+
                    .take(26)
+
                    .collect(),
+
                    old: 2559..2578,
+
                    new: 2560..2579,
+
                },
+
            ),
            input,
        );
        let actual = builder.comments();
@@ -531,16 +524,17 @@ Woof.

"#;

+
        let base = git::raw::Oid::zero().into();
        let commit = Oid::from_str("a32c4b93e2573fd83b15ac1ad6bf1317dc8fd760").unwrap();
        let path = PathBuf::from_str("main.rs").unwrap();
        let expected = &[
            (ReviewComment {
-
                location: CodeLocation {
+
                location: DiffLocation::hunk_level(
+
                    git::raw::Oid::zero().into(),
                    commit,
-
                    path: path.clone(),
-
                    old: Some(CodeRange::Lines { range: 2559..2565 }),
-
                    new: Some(CodeRange::Lines { range: 2560..2563 }),
-
                },
+
                    path.clone(),
+
                    HunkIndex::new(0, CodeRange::lines(5..6)),
+
                ),
                body: r#"
Blah blah blah blah blah blah blah.
Blah blah blah.
@@ -554,12 +548,12 @@ Blaaah blaaah blaaah.
                .to_owned(),
            }),
            (ReviewComment {
-
                location: CodeLocation {
+
                location: DiffLocation::hunk_level(
+
                    git::raw::Oid::zero().into(),
                    commit,
-
                    path: path.clone(),
-
                    old: Some(CodeRange::Lines { range: 2565..2568 }),
-
                    new: Some(CodeRange::Lines { range: 2563..2567 }),
-
                },
+
                    path.clone(),
+
                    HunkIndex::new(0, CodeRange::lines(11..12)),
+
                ),
                body: r#"
Woof woof.
Woof.
@@ -572,15 +566,22 @@ Woof.
            }),
        ];

-
        let mut builder = CommentBuilder::new(commit, path.clone());
+
        let mut builder = CommentBuilder::new(base, commit, path.clone());
        builder.add_hunk(
-
            HunkHeader {
-
                old_line_no: 2559,
-
                old_size: 9,
-
                new_line_no: 2560,
-
                new_size: 7,
-
                text: vec![],
-
            },
+
            &Hunk::new(
+
                0,
+
                diff::Hunk {
+
                    header: diff::Line::from(vec![]),
+
                    lines: std::iter::repeat(diff::Modification::addition(
+
                        diff::Line::from(vec![]),
+
                        1,
+
                    ))
+
                    .take(12)
+
                    .collect(),
+
                    old: 2559..2569,
+
                    new: 2560..2568,
+
                },
+
            ),
            input,
        );
        let actual = builder.comments();
@@ -612,27 +613,30 @@ This is a top-level comment.
> +        let connect = available.take(wanted).collect::<Vec<_>>();
"#;

+
        let base = git::raw::Oid::zero().into();
        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 }),
-
            },
+
            location: DiffLocation::file_level(git::raw::Oid::zero().into(), commit, path.clone()),
            body: "This is a top-level comment.".to_owned(),
        })];

-
        let mut builder = CommentBuilder::new(commit, path.clone());
+
        let mut builder = CommentBuilder::new(base, commit, path.clone());
        builder.add_hunk(
-
            HunkHeader {
-
                old_line_no: 2559,
-
                old_size: 9,
-
                new_line_no: 2560,
-
                new_size: 7,
-
                text: vec![],
-
            },
+
            &Hunk::new(
+
                0,
+
                diff::Hunk {
+
                    header: diff::Line::from(vec![]),
+
                    lines: std::iter::repeat(diff::Modification::addition(
+
                        diff::Line::from(vec![]),
+
                        1,
+
                    ))
+
                    .take(12)
+
                    .collect(),
+
                    old: 2559..2569,
+
                    new: 2560..2568,
+
                },
+
            ),
            input,
        );
        let actual = builder.comments();
@@ -660,27 +664,30 @@ This is a top-level comment.
Comment on a split hunk.
"#;

+
        let base = git::raw::Oid::zero().into();
        let commit = Oid::from_str("a32c4b93e2573fd83b15ac1ad6bf1317dc8fd760").unwrap();
        let path = PathBuf::from_str("main.rs").unwrap();
        let expected = &[(ReviewComment {
-
            location: CodeLocation {
+
            location: DiffLocation::hunk_level(
+
                git::raw::Oid::zero().into(),
                commit,
-
                path: path.clone(),
-
                old: Some(CodeRange::Lines { range: 2564..2565 }),
-
                new: Some(CodeRange::Lines { range: 2563..2564 }),
-
            },
+
                path.clone(),
+
                HunkIndex::new(0, CodeRange::lines(6..7)),
+
            ),
            body: "Comment on a split hunk.".to_owned(),
        })];

-
        let mut builder = CommentBuilder::new(commit, path.clone());
+
        let mut builder = CommentBuilder::new(base, commit, path.clone());
        builder.add_hunk(
-
            HunkHeader {
-
                old_line_no: 2559,
-
                old_size: 6,
-
                new_line_no: 2560,
-
                new_size: 4,
-
                text: vec![],
-
            },
+
            &Hunk::new(
+
                0,
+
                diff::Hunk {
+
                    header: diff::Line::from(vec![]),
+
                    lines: vec![],
+
                    old: 2559..2566,
+
                    new: 2560..2565,
+
                },
+
            ),
            input,
        );
        let actual = builder.comments();
modified bin/git.rs
@@ -1,19 +1,23 @@
use std::fmt;
use std::fmt::Debug;
+
use std::ops::Range;
use std::path::Path;
use std::{fs, path::PathBuf};

+
use serde::{Deserialize, Serialize};
+

use ratatui::text::Line;

-
use radicle_surf::diff::{Copied, DiffFile, EofNewLine, FileStats, Hunk, Modification, Moved};
+
use radicle_surf::diff;
+
use radicle_surf::diff::{Copied, DiffFile, EofNewLine, FileStats, Modification, Moved};

+
use radicle::cob::{CodeRange, HunkIndex};
use radicle::git;
use radicle::git::Oid;

-
use radicle_cli::git::unified_diff::FileHeader;
+
use radicle_cli::git::unified_diff::{self, FileHeader, HunkHeader};
use radicle_cli::terminal;
use radicle_cli::terminal::highlight::Highlighter;
-
use serde::{Deserialize, Serialize};

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

@@ -201,8 +205,8 @@ impl HunkStats {
    }
}

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

@@ -228,20 +232,20 @@ pub enum HunkState {

/// A single review item. Can be a hunk or eg. a file move.
/// Files are usually split into multiple review items.
-
#[derive(Clone, PartialEq)]
+
#[derive(Clone, Debug)]
pub enum HunkDiff {
    Added {
        path: PathBuf,
        header: FileHeader,
        new: DiffFile,
-
        hunk: Option<Hunk<Modification>>,
+
        hunk: Option<Hunk>,
        _stats: Option<FileStats>,
    },
    Deleted {
        path: PathBuf,
        header: FileHeader,
        old: DiffFile,
-
        hunk: Option<Hunk<Modification>>,
+
        hunk: Option<Hunk>,
        _stats: Option<FileStats>,
    },
    Modified {
@@ -249,7 +253,7 @@ pub enum HunkDiff {
        header: FileHeader,
        old: DiffFile,
        new: DiffFile,
-
        hunk: Option<Hunk<Modification>>,
+
        hunk: Option<Hunk>,
        _stats: Option<FileStats>,
    },
    Moved {
@@ -274,7 +278,7 @@ pub enum HunkDiff {
}

impl HunkDiff {
-
    pub fn hunk(&self) -> Option<&Hunk<Modification>> {
+
    pub fn hunk(&self) -> Option<&Hunk> {
        match self {
            Self::Added { hunk, .. } => hunk.as_ref(),
            Self::Deleted { hunk, .. } => hunk.as_ref(),
@@ -323,25 +327,33 @@ impl HunkDiff {
    }
}

-
impl Debug for HunkDiff {
-
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-
        let (name, path, hunk) = match self {
-
            Self::Added { path, hunk, .. } => ("Added", path, hunk),
-
            Self::Deleted { path, hunk, .. } => ("Deleted", path, hunk),
-
            Self::Moved { moved } => ("Moved", &moved.new_path, &None),
-
            Self::Copied { copied } => ("Copied", &copied.new_path, &None),
-
            Self::Modified { path, hunk, .. } => ("Modified", path, hunk),
-
            Self::EofChanged { path, .. } => ("EofChanged", path, &None),
-
            Self::ModeChanged { path, .. } => ("ModeChanged", path, &None),
-
        };
-

-
        match hunk {
-
            Some(hunk) => f
-
                .debug_struct(name)
-
                .field("path", path)
-
                .field("hunk", &(hunk.old.clone(), hunk.new.clone()))
-
                .finish(),
-
            _ => f.debug_struct(name).field("path", path).finish(),
-
        }
+
/// Keep track of the [`diff::Hunk`] and its `index` within the diff patch.
+
#[derive(Clone, Debug)]
+
pub struct Hunk {
+
    /// Index of the hunk within its respective patch.
+
    index: usize,
+
    /// The [`diff::Hunk`] that is being kept track of.
+
    inner: diff::Hunk<Modification>,
+
}
+

+
impl Hunk {
+
    pub fn new(index: usize, hunk: diff::Hunk<Modification>) -> Self {
+
        Self { index, inner: hunk }
+
    }
+

+
    pub fn as_index(&self, range: Option<Range<usize>>) -> Option<HunkIndex> {
+
        range.map(|range| HunkIndex::new(self.index, CodeRange::lines(range)))
+
    }
+

+
    pub fn inner(&self) -> &diff::Hunk<Modification> {
+
        &self.inner
+
    }
+
}
+

+
impl TryFrom<&Hunk> for HunkHeader {
+
    type Error = unified_diff::Error;
+

+
    fn try_from(Hunk { ref inner, .. }: &Hunk) -> Result<Self, Self::Error> {
+
        Self::try_from(inner)
    }
}
modified bin/test.rs
@@ -128,6 +128,7 @@ pub mod fixtures {
    use radicle::git;
    use radicle::identity::{RepoId, Visibility};
    use radicle::node::device::Device;
+
    use radicle::patch;
    use radicle::patch::{Cache, MergeTarget, PatchMut, Patches};
    use radicle::rad;
    use radicle::storage::git::Repository;
@@ -137,9 +138,10 @@ pub mod fixtures {
    use radicle::Storage;
    use radicle_cli::git::unified_diff::FileHeader;
    use radicle_git_ext::Oid;
-
    use radicle_surf::diff::{self, DiffFile, Hunk, Line, Modification};
+
    use radicle_surf::diff;
+
    use radicle_surf::diff::{DiffFile, Line, Modification};

-
    use crate::git::HunkDiff;
+
    use crate::git::{Hunk, HunkDiff};

    use super::setup::{NodeRepo, NodeRepoCheckout, NodeWithRepo};

@@ -226,6 +228,7 @@ fn main() {
            MergeTarget::Delegates,
            branch.base,
            branch.oid,
+
            Some(patch::diff::Options::default()),
            &[],
            &node.signer,
        )?;
@@ -317,7 +320,7 @@ fn main() {
            },
            old: diff.clone(),
            new: diff,
-
            hunk: Some(Hunk {
+
            hunk: Some(Hunk::new(0, diff::Hunk {
                header: Line::from(b"@@ -3,8 +3,7 @@\n".to_vec()),
                lines: vec![
                    Modification::context(
@@ -343,7 +346,7 @@ fn main() {
                ],
                old: 3..11,
                new: 3..10,
-
            }),
+
            })),
            _stats: None,
        })
    }
@@ -384,7 +387,7 @@ fn main() {
            },
            old: diff.clone(),
            new: diff,
-
            hunk: Some(Hunk {
+
            hunk:Some(Hunk::new(0, diff::Hunk {
                header: Line::from(b"@@ -1,17 +1,15 @@\n".to_vec()),
                lines: vec![
                    Modification::deletion(b"use radicle::issue::IssueId;\n".to_vec(), 1),
@@ -442,7 +445,7 @@ fn main() {
                ],
                old: 1..18,
                new: 1..16,
-
            }),
+
            })),
            _stats: None,
        })
    }
@@ -463,12 +466,15 @@ fn main() {
                binary: false,
            },
            old: diff.clone(),
-
            hunk: Some(Hunk {
-
                header: Line::from(b"@@ -1,1 +0,0 @@\n".to_vec()),
-
                lines: vec![Modification::deletion(b"TBD\n".to_vec(), 1)],
-
                old: 1..2,
-
                new: 0..0,
-
            }),
+
            hunk: Some(Hunk::new(
+
                0,
+
                diff::Hunk {
+
                    header: Line::from(b"@@ -1,1 +0,0 @@\n".to_vec()),
+
                    lines: vec![Modification::deletion(b"TBD\n".to_vec(), 1)],
+
                    old: 1..2,
+
                    new: 0..0,
+
                },
+
            )),
            _stats: None,
        })
    }
modified bin/ui/items.rs
@@ -12,7 +12,9 @@ use nom::{IResult, Parser};
use ansi_to_tui::IntoText;

use radicle::cob::thread::{Comment, CommentId};
-
use radicle::cob::{CodeLocation, CodeRange, EntryId, Label, ObjectId, Timestamp, TypedId};
+
use radicle::cob::{
+
    CodeRange, DiffLocation, EntryId, Label, ObjectId, PartialLocation, Timestamp, TypedId,
+
};
use radicle::git::Oid;
use radicle::identity::{Did, Identity};
use radicle::issue;
@@ -51,6 +53,8 @@ use crate::ui;
use super::super::git;
use super::format;

+
type CommentEntry = (EntryId, Comment<DiffLocation>);
+

pub trait Filter<T> {
    fn matches(&self, item: &T) -> bool;
}
@@ -1043,178 +1047,39 @@ impl From<TermLine> for Line<'_> {
    }
}

-
/// Represents the old and new ranges of a unified diff.
-
pub struct DiffLineRanges {
-
    old: Range<u32>,
-
    new: Range<u32>,
-
}
-

-
impl From<&Hunk<Modification>> for DiffLineRanges {
-
    fn from(hunk: &Hunk<Modification>) -> Self {
-
        Self {
-
            old: hunk.old.clone(),
-
            new: hunk.new.clone(),
-
        }
-
    }
-
}
-

-
/// Identifies a line in a unified diff by its old and new line number.
-
#[derive(Clone, Debug, Default, Hash, Eq, PartialEq)]
-
pub struct DiffLineIndex {
-
    old: Option<u32>,
-
    new: Option<u32>,
-
}
-

-
impl DiffLineIndex {
-
    pub fn is_start_of(&self, ranges: &DiffLineRanges) -> bool {
-
        // TODO(erikli): Find out, why comments inserted right before or after
-
        // the hunk header can have such weird values.
-
        let old = self
-
            .old
-
            .map(|o| self.new.is_none() && o >= 4294967294)
-
            .unwrap_or_default();
-
        let new = self
-
            .new
-
            .map(|n| n == u32::MAX.saturating_sub(1) || n == ranges.new.end)
-
            .unwrap_or_default();
-

-
        old || new
-
    }
-

-
    pub fn is_end_of(&self, ranges: &DiffLineRanges) -> bool {
-
        let old = self
-
            .old
-
            .map(|o| o == ranges.old.end.saturating_sub(1))
-
            .unwrap_or_default();
-
        let new = self
-
            .new
-
            .map(|n| n == ranges.new.end.saturating_sub(1))
-
            .unwrap_or_default();
-

-
        old || new
-
    }
-

-
    pub fn is_inside_of(&self, ranges: &DiffLineRanges) -> bool {
-
        let old = self
-
            .old
-
            .map(|o| o >= ranges.old.start && o < ranges.old.end.saturating_sub(1))
-
            .unwrap_or_default();
-
        let new = self
-
            .new
-
            .map(|n| n >= ranges.new.start && n < ranges.new.end.saturating_sub(1))
-
            .unwrap_or_default();
-

-
        old || new
-
    }
-
}
-

-
/// Mention hunk header
-
impl From<&CodeLocation> for DiffLineIndex {
-
    fn from(location: &CodeLocation) -> Self {
-
        Self {
-
            old: location.old.as_ref().map(|r| match r {
-
                CodeRange::Lines { range } => range.end.saturating_sub(1) as u32,
-
                CodeRange::Chars { line, range: _ } => line.saturating_sub(1) as u32,
-
            }),
-
            new: location.new.as_ref().map(|r| match r {
-
                CodeRange::Lines { range } => range.end.saturating_sub(1) as u32,
-
                CodeRange::Chars { line, range: _ } => line.saturating_sub(1) as u32,
-
            }),
-
        }
-
    }
-
}
-

-
/// A type that can map a line index to a line number in a unified diff.
-
#[derive(Debug)]
-
pub struct IndexedDiffLines {
-
    lines: HashMap<DiffLineIndex, u32>,
-
}
-

-
impl IndexedDiffLines {
-
    pub fn new(diff: &HunkDiff) -> Self {
-
        let mut indexed = HashMap::new();
-

-
        if let Some(hunk) = diff.hunk() {
-
            for (index, line) in hunk.lines.iter().enumerate() {
-
                let line_index = match line {
-
                    Modification::Addition(addition) => DiffLineIndex {
-
                        old: None,
-
                        new: Some(addition.line_no),
-
                    },
-
                    Modification::Deletion(deletion) => DiffLineIndex {
-
                        old: Some(deletion.line_no),
-
                        new: None,
-
                    },
-
                    Modification::Context {
-
                        line: _,
-
                        line_no_old,
-
                        line_no_new,
-
                    } => DiffLineIndex {
-
                        old: Some(*line_no_old),
-
                        new: Some(*line_no_new),
-
                    },
-
                };
-

-
                indexed.insert(line_index, index as u32);
-
            }
-
        }
-

-
        Self { lines: indexed }
-
    }
-

-
    pub fn line(&self, index: DiffLineIndex) -> Option<u32> {
-
        self.lines.get(&index).copied()
-
    }
-
}
-

-
/// All comments per hunk, indexed by their merge location: start, line or end.
+
/// All comments per hunk, indexed by their merge location.
#[derive(Clone, Debug)]
pub struct HunkComments {
    /// All comments. Can be unsorted.
-
    comments: HashMap<MergeLocation, Vec<(EntryId, Comment<CodeLocation>)>>,
+
    comments: HashMap<MergeLocation, Vec<CommentEntry>>,
}

impl HunkComments {
-
    pub fn new(diff: &HunkDiff, comments: Vec<(EntryId, Comment<CodeLocation>)>) -> Self {
-
        let mut line_comments: HashMap<MergeLocation, Vec<(EntryId, Comment<CodeLocation>)>> =
-
            HashMap::new();
-
        let indexed = IndexedDiffLines::new(diff);
+
    pub fn new(diff: &HunkDiff, comments: Vec<CommentEntry>) -> Self {
+
        let mut line_comments: HashMap<MergeLocation, Vec<CommentEntry>> = HashMap::new();
+
        // let indexed = IndexedDiffLines::new(diff);

        for comment in comments {
-
            let line = if let Some(location) = comment.1.location() {
-
                if let Some(hunk) = diff.hunk() {
-
                    let ranges = DiffLineRanges::from(hunk);
-
                    let index = DiffLineIndex::from(location);
-

-
                    if index.is_start_of(&ranges) {
-
                        MergeLocation::Start
-
                    } else if index.is_end_of(&ranges) {
-
                        MergeLocation::End
-
                    } else {
-
                        let mut line = indexed
-
                            .line(index.clone())
-
                            .map(|line| MergeLocation::Line(line as usize));
-

-
                        // TODO(erikli): Properly fix index lookup rules for addition:
-
                        // old line number need to be ignored.
-
                        if line.is_none() {
-
                            line = indexed
-
                                .line(DiffLineIndex { old: None, ..index })
-
                                .map(|line| MergeLocation::Line(line as usize))
-
                        }
-
                        line.unwrap_or_default()
-
                    }
+
            let location = if let Some(location) = comment.1.location() {
+
                if diff.hunk().is_some() {
+
                    location
+
                        .code_range()
+
                        .map(|r| MergeLocation::Line(r.line_end().saturating_sub(1)))
+
                        .unwrap_or_default()
                } else {
-
                    MergeLocation::Unknown
+
                    MergeLocation::Top
                }
            } else {
-
                MergeLocation::Unknown
+
                // TODO(erikli): Check if the diff location was constructed from
+
                // a partial location.
+
                MergeLocation::Top
            };

-
            if let Some(comments) = line_comments.get_mut(&line) {
+
            // Check if there are any existing comments on that line.
+
            if let Some(comments) = line_comments.get_mut(&location) {
                comments.push(comment.clone());
            } else {
-
                line_comments.insert(line, vec![comment.clone()]);
+
                line_comments.insert(location, vec![comment.clone()]);
            }
        }

@@ -1223,7 +1088,7 @@ impl HunkComments {
        }
    }

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

@@ -1252,9 +1117,9 @@ pub struct HunkItem<'a> {
    pub comments: HunkComments,
}

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

        // TODO(erikli): Start with raw, non-highlighted lines and
@@ -1266,21 +1131,25 @@ impl From<(&Repository, &Review, &HunkDiff)> for HunkItem<'_> {
        // Filter comments and include them, if:
        // - comment has a code location
        // - comment path matches hunk path
-
        // - comment code location is inside hunk code range
+
        // - comment diff location's hunk index is none or equal to the current index
        let comments = review
            .comments()
            .filter(|(_, comment)| {
                if let Some(location) = comment.location() {
                    if location.path == *item.path() {
-
                        if let Some(hunk) = item.hunk() {
-
                            let ranges = DiffLineRanges::from(hunk);
-
                            let index = DiffLineIndex::from(location);
-

-
                            log::warn!("Checking comment {comment:?} at {index:?}");
-

-
                            return index.is_start_of(&ranges)
-
                                || index.is_inside_of(&ranges)
-
                                || index.is_end_of(&ranges);
+
                        if let Some(_) = item.hunk() {
+
                            // let ranges = DiffLineRanges::from(hunk.inner());
+
                            // let index = DiffLineIndex::from(location);
+

+
                            // log::warn!("Checking comment {comment:?} at {index:?}");
+

+
                            // return index.is_start_of(&ranges)
+
                            //     || index.is_inside_of(&ranges)
+
                            //     || index.is_end_of(&ranges);
+
                            return location
+
                                .hunk_index()
+
                                .map(|idx| idx == *index)
+
                                .unwrap_or(true);
                        } else {
                            return true;
                        }
@@ -1342,7 +1211,10 @@ impl ToRow<3> for StatefulHunkItem<'_> {
                hunk,
                _stats: _,
            } => {
-
                let stats = hunk.as_ref().map(HunkStats::from).unwrap_or_default();
+
                let stats = hunk
+
                    .as_ref()
+
                    .map(|h| HunkStats::from(h.inner()))
+
                    .unwrap_or_default();
                let stats_cell = [
                    build_stats_spans(&DiffStats::Hunk(stats)),
                    [span::default(" A ").bold().light_green().dim()].to_vec(),
@@ -1365,7 +1237,10 @@ impl ToRow<3> for StatefulHunkItem<'_> {
                hunk,
                _stats: _,
            } => {
-
                let stats = hunk.as_ref().map(HunkStats::from).unwrap_or_default();
+
                let stats = hunk
+
                    .as_ref()
+
                    .map(|h| HunkStats::from(h.inner()))
+
                    .unwrap_or_default();
                let stats_cell = [
                    build_stats_spans(&DiffStats::Hunk(stats)),
                    [span::default(" M ").bold().light_yellow().dim()].to_vec(),
@@ -1387,7 +1262,10 @@ impl ToRow<3> for StatefulHunkItem<'_> {
                hunk,
                _stats: _,
            } => {
-
                let stats = hunk.as_ref().map(HunkStats::from).unwrap_or_default();
+
                let stats = hunk
+
                    .as_ref()
+
                    .map(|h| HunkStats::from(h.inner()))
+
                    .unwrap_or_default();
                let stats_cell = [
                    build_stats_spans(&DiffStats::Hunk(stats)),
                    [span::default(" D ").bold().light_red().dim()].to_vec(),
@@ -1671,7 +1549,7 @@ impl<'a> HunkItem<'a> {
            | HunkDiff::Deleted { hunk, .. } => {
                let mut lines = hunk
                    .as_ref()
-
                    .map(|hunk| Text::from(hunk.to_text(&self.lines)));
+
                    .map(|hunk| Text::from(hunk.inner().to_text(&self.lines)));

                lines = lines.map(|lines| {
                    let divider = span::default(&"─".to_string().repeat(500)).gray().dim();
@@ -1966,6 +1844,9 @@ mod tests {

    use anyhow::Result;

+
    use radicle::cob::HunkIndex;
+
    use radicle::git;
+

    use crate::test;

    use super::*;
@@ -2035,76 +1916,6 @@ mod tests {
    }

    #[test]
-
    fn diff_line_index_checks_ranges_correctly() -> Result<()> {
-
        let commit = Oid::from_str("a32c4b93e2573fd83b15ac1ad6bf1317dc8fd760").unwrap();
-
        let path = PathBuf::from_str("main.rs").unwrap();
-

-
        // --------------------------------------------------------------------
-
        // At the top.
-
        // --------------------------------------------------------------------
-
        // @@ -3,8 +3,7 @@
-
        // 3   3     // or if you prefer to use your keyboard, you can use the "Ctrl + Enter"
-
        // 4   4     // shortcut.
-
        // 5   5
-
        // 6       - // This code is editable, feel free to hack it!
-
        // 7       - // You can always return to the original code by clicking the "Reset" button ->
-
        //     6   + // This is still a comment.
-
        // --------------------------------------------------------------------
-
        // In the middle.
-
        // --------------------------------------------------------------------
-
        // 8   7
-
        // 9   8     // This is the main function.
-
        // 10  9     fn main() {
-
        // ---------------------------------------------------------------------
-
        // At the end.
-
        // ---------------------------------------------------------------------
-
        let diff = test::fixtures::simple_modified_hunk_diff(&path, commit)?;
-
        let ranges = DiffLineRanges::from(diff.hunk().unwrap());
-

-
        let start = CodeLocation {
-
            commit,
-
            path: path.clone(),
-
            old: Some(CodeRange::Lines { range: 3..12 }),
-
            new: Some(CodeRange::Lines { range: 3..11 }),
-
        };
-
        assert!(DiffLineIndex::from(&start).is_start_of(&ranges));
-
        assert!(!DiffLineIndex::from(&start).is_inside_of(&ranges));
-
        assert!(!DiffLineIndex::from(&start).is_end_of(&ranges));
-

-
        let inside = CodeLocation {
-
            commit,
-
            path: path.clone(),
-
            old: Some(CodeRange::Lines { range: 3..8 }),
-
            new: Some(CodeRange::Lines { range: 3..7 }),
-
        };
-
        assert!(DiffLineIndex::from(&inside).is_inside_of(&ranges));
-
        assert!(!DiffLineIndex::from(&inside).is_start_of(&ranges));
-
        assert!(!DiffLineIndex::from(&inside).is_end_of(&ranges));
-

-
        let end = CodeLocation {
-
            commit,
-
            path: path.clone(),
-
            old: Some(CodeRange::Lines { range: 3..11 }),
-
            new: Some(CodeRange::Lines { range: 3..10 }),
-
        };
-
        assert!(DiffLineIndex::from(&end).is_end_of(&ranges));
-
        assert!(!DiffLineIndex::from(&end).is_start_of(&ranges));
-
        assert!(!DiffLineIndex::from(&end).is_inside_of(&ranges));
-

-
        let outside = CodeLocation {
-
            commit,
-
            path: path.clone(),
-
            old: Some(CodeRange::Lines { range: 125..127 }),
-
            new: Some(CodeRange::Lines { range: 125..128 }),
-
        };
-
        assert!(!DiffLineIndex::from(&outside).is_start_of(&ranges));
-
        assert!(!DiffLineIndex::from(&outside).is_inside_of(&ranges));
-
        assert!(!DiffLineIndex::from(&outside).is_end_of(&ranges));
-

-
        Ok(())
-
    }
-

-
    #[test]
    fn hunk_comments_on_modified_simple_are_inserted_correctly() -> Result<()> {
        let alice = test::fixtures::node_with_repo();

@@ -2138,12 +1949,11 @@ mod tests {
                *alice.node.signer.public_key(),
                "At the top.".to_string(),
                None,
-
                Some(CodeLocation {
+
                Some(DiffLocation::file_level(
+
                    git::raw::Oid::zero().into(),
                    commit,
-
                    path: path.clone(),
-
                    old: Some(CodeRange::Lines { range: 3..12 }),
-
                    new: Some(CodeRange::Lines { range: 3..11 }),
-
                }),
+
                    path.clone(),
+
                )),
                vec![],
                Timestamp::from_secs(0),
            ),
@@ -2154,12 +1964,12 @@ mod tests {
                *alice.node.signer.public_key(),
                "In the middle.".to_string(),
                None,
-
                Some(CodeLocation {
+
                Some(DiffLocation::hunk_level(
+
                    git::raw::Oid::zero().into(),
                    commit,
-
                    path: path.clone(),
-
                    old: Some(CodeRange::Lines { range: 3..8 }),
-
                    new: Some(CodeRange::Lines { range: 3..7 }),
-
                }),
+
                    path.clone(),
+
                    HunkIndex::new(0, CodeRange::lines(5..6)),
+
                )),
                vec![],
                Timestamp::from_secs(0),
            ),
@@ -2170,12 +1980,12 @@ mod tests {
                *alice.node.signer.public_key(),
                "At the end.".to_string(),
                None,
-
                Some(CodeLocation {
+
                Some(DiffLocation::hunk_level(
+
                    git::raw::Oid::zero().into(),
                    commit,
-
                    path: path.clone(),
-
                    old: Some(CodeRange::Lines { range: 3..11 }),
-
                    new: Some(CodeRange::Lines { range: 3..10 }),
-
                }),
+
                    path.clone(),
+
                    HunkIndex::new(0, CodeRange::lines(8..9)),
+
                )),
                vec![],
                Timestamp::from_secs(0),
            ),
@@ -2187,11 +1997,12 @@ mod tests {
        };

        for expected in [
-
            (top, MergeLocation::Start),
+
            (top, MergeLocation::Top),
            (middle, MergeLocation::Line(5)),
-
            (end, MergeLocation::End),
+
            (end, MergeLocation::Line(8)),
        ] {
            let (line, expected) = (expected.1, expected.0);
+
            println!("{:?}", comments.all());
            let actual = comments.all().get(&line);
            assert_ne!(actual, None, "No comment found at {line:?}");

@@ -2246,12 +2057,11 @@ mod tests {
                *alice.node.signer.public_key(),
                "At the top.".to_string(),
                None,
-
                Some(CodeLocation {
+
                Some(DiffLocation::file_level(
+
                    git::raw::Oid::zero().into(),
                    commit,
-
                    path: path.clone(),
-
                    old: Some(CodeRange::Lines { range: 1..18 }),
-
                    new: Some(CodeRange::Lines { range: 1..17 }),
-
                }),
+
                    path.clone(),
+
                )),
                vec![],
                Timestamp::from_secs(0),
            ),
@@ -2262,12 +2072,12 @@ mod tests {
                *alice.node.signer.public_key(),
                "After deletion.".to_string(),
                None,
-
                Some(CodeLocation {
+
                Some(DiffLocation::hunk_level(
+
                    git::raw::Oid::zero().into(),
                    commit,
-
                    path: path.clone(),
-
                    old: Some(CodeRange::Lines { range: 1..4 }),
-
                    new: None,
-
                }),
+
                    path.clone(),
+
                    HunkIndex::new(0, CodeRange::lines(2..3)),
+
                )),
                vec![],
                Timestamp::from_secs(0),
            ),
@@ -2278,12 +2088,12 @@ mod tests {
                *alice.node.signer.public_key(),
                "Before last line".to_string(),
                None,
-
                Some(CodeLocation {
+
                Some(DiffLocation::hunk_level(
+
                    git::raw::Oid::zero().into(),
                    commit,
-
                    path: path.clone(),
-
                    old: Some(CodeRange::Lines { range: 1..17 }),
-
                    new: Some(CodeRange::Lines { range: 1..15 }),
-
                }),
+
                    path.clone(),
+
                    HunkIndex::new(0, CodeRange::lines(17..18)),
+
                )),
                vec![],
                Timestamp::from_secs(0),
            ),
@@ -2299,7 +2109,7 @@ mod tests {
        };

        for expected in [
-
            (top, MergeLocation::Start),
+
            (top, MergeLocation::Top),
            (after_deletion, MergeLocation::Line(2)),
            (before_last_line, MergeLocation::Line(17)),
        ] {
@@ -2337,12 +2147,11 @@ mod tests {
                *alice.node.signer.public_key(),
                "At the top.".to_string(),
                None,
-
                Some(CodeLocation {
+
                Some(DiffLocation::file_level(
+
                    git::raw::Oid::zero().into(),
                    commit,
-
                    path: path.clone(),
-
                    old: Some(CodeRange::Lines { range: 1..3 }),
-
                    new: Some(CodeRange::Lines { range: 0..1 }),
-
                }),
+
                    path.clone(),
+
                )),
                vec![],
                Timestamp::from_secs(0),
            ),
@@ -2353,12 +2162,12 @@ mod tests {
                *alice.node.signer.public_key(),
                "At the end.".to_string(),
                None,
-
                Some(CodeLocation {
+
                Some(DiffLocation::hunk_level(
+
                    git::raw::Oid::zero().into(),
                    commit,
-
                    path: path.clone(),
-
                    old: Some(CodeRange::Lines { range: 1..2 }),
-
                    new: None,
-
                }),
+
                    path.clone(),
+
                    HunkIndex::new(0, CodeRange::lines(0..1)),
+
                )),
                vec![],
                Timestamp::from_secs(0),
            ),
@@ -2369,7 +2178,7 @@ mod tests {
            HunkComments::new(&diff, comments.to_vec())
        };

-
        for expected in [(top, MergeLocation::Start), (end, MergeLocation::End)] {
+
        for expected in [(top, MergeLocation::Top), (end, MergeLocation::Line(0))] {
            let (line, expected) = (expected.1, expected.0);
            let actual = comments.all().get(&line);
            assert_ne!(actual, None, "No comment found at {line:?}");
modified src/ui/utils.rs
@@ -5,11 +5,9 @@ use std::collections::HashMap;
/// the base lines.
#[derive(Default, Clone, Debug, Hash, Eq, PartialEq)]
pub enum MergeLocation {
-
    Start,
-
    Line(usize),
-
    End,
    #[default]
-
    Unknown,
+
    Top,
+
    Line(usize),
}

/// A type that can merge lines based on their merge location.
@@ -34,9 +32,7 @@ impl<T: Clone> LineMerger<T> {
        let mut merged = vec![];
        for (idx, line) in self.lines.iter().enumerate() {
            let location = if idx == 0 {
-
                MergeLocation::Start
-
            } else if idx == self.lines.len().saturating_sub(1) {
-
                MergeLocation::End
+
                MergeLocation::Top
            } else {
                let idx = idx
                    .saturating_add(start.unwrap_or_default())
@@ -44,7 +40,7 @@ impl<T: Clone> LineMerger<T> {
                MergeLocation::Line(idx)
            };

-
            if location != MergeLocation::Start {
+
            if location != MergeLocation::Top {
                merged.push(line.clone());
            }

@@ -56,7 +52,7 @@ impl<T: Clone> LineMerger<T> {
                }
            }

-
            if location == MergeLocation::Start {
+
            if location == MergeLocation::Top {
                merged.push(line.clone());
            }
        }