Files
radicle-tui/bin/ui/items.rs
T
2025-12-13 01:03:43 +01:00

2081 lines
69 KiB
Rust

pub mod notification;
use std::collections::HashMap;
use std::fmt;
use std::fmt::Debug;
use std::ops::Range;
use std::str::FromStr;
use nom::bytes::complete::{tag, take};
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::{CodeLocation, CodeRange, EntryId, Label, ObjectId, Timestamp};
use radicle::git::Oid;
use radicle::identity::Did;
use radicle::issue;
use radicle::issue::{CloseReason, Issue, IssueId};
use radicle::node::{Alias, AliasStore, NodeId};
use radicle::patch;
use radicle::patch::{Patch, PatchId, Review};
use radicle::storage::git::Repository;
use radicle::storage::WriteRepository;
use radicle::Profile;
use radicle_surf::diff;
use radicle_surf::diff::{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::utils::{LineMerger, MergeLocation};
use tui::ui::{span, Column};
use tui::ui::{ToRow, ToTree};
use crate::git::{Blobs, DiffStats, HunkDiff, HunkState, HunkStats};
use crate::ui;
use super::super::git;
use super::format;
pub mod filter {
use std::fmt::{self, Write};
use std::str::FromStr;
use nom::bytes::complete::{tag_no_case, take};
use nom::character::complete::{char, multispace0};
use nom::combinator::map;
use nom::multi::separated_list1;
use nom::sequence::{delimited, tuple};
use nom::IResult;
use radicle::prelude::Did;
/// A generic filter that needs be implemented for item filters in order to
/// apply it.
pub trait Filter<T> {
fn matches(&self, item: &T) -> bool;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DidFilter {
Single(Did),
Or(Vec<Did>),
}
impl fmt::Display for DidFilter {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
DidFilter::Single(did) => write!(f, "{did}")?,
DidFilter::Or(dids) => {
let mut it = dids.iter().peekable();
f.write_char('(')?;
while let Some(did) = it.next() {
write!(f, "{did}")?;
if it.peek().is_none() {
write!(f, " or ")?;
}
}
f.write_char(')')?;
}
}
Ok(())
}
}
fn parse_did(input: &str) -> IResult<&str, Did> {
match Did::from_str(input) {
Ok(did) => IResult::Ok(("", did)),
Err(_) => IResult::Err(nom::Err::Error(nom::error::Error::new(
input,
nom::error::ErrorKind::Verify,
))),
}
}
pub fn parse_did_single(input: &str) -> IResult<&str, DidFilter> {
map(parse_did, DidFilter::Single)(input)
}
pub fn parse_did_or(input: &str) -> IResult<&str, DidFilter> {
map(
delimited(
tuple((multispace0, char('('), multispace0)),
separated_list1(
delimited(multispace0, tag_no_case("or"), multispace0),
take(56_usize),
),
tuple((multispace0, char(')'), multispace0)),
),
|dids: Vec<&str>| {
DidFilter::Or(
dids.iter()
.filter_map(|did| Did::from_str(did).ok())
.collect::<Vec<_>>(),
)
},
)(input)
}
}
pub trait HasId {
fn id(&self) -> ObjectId;
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AuthorItem {
pub nid: Option<NodeId>,
pub human_nid: Option<String>,
pub alias: Option<Alias>,
pub you: bool,
}
impl AuthorItem {
pub fn new(nid: Option<NodeId>, profile: &Profile) -> Self {
let alias = match nid {
Some(nid) => profile.alias(&nid),
None => None,
};
let you = nid.map(|nid| nid == *profile.id()).unwrap_or_default();
let human_nid = nid.map(|nid| format::did(&Did::from(nid)));
Self {
nid,
human_nid,
alias,
you,
}
}
}
#[derive(Clone, Debug)]
pub struct IssueItem {
/// Issue OID.
pub id: IssueId,
/// Issue state.
pub state: issue::State,
/// Issue title.
pub title: String,
/// Issue author.
pub author: AuthorItem,
/// Issue labels.
pub labels: Vec<Label>,
/// Issue assignees.
pub assignees: Vec<AuthorItem>,
/// Time when issue was opened.
pub timestamp: Timestamp,
/// Comment timeline
pub comments: Vec<CommentItem>,
}
impl IssueItem {
pub fn new(profile: &Profile, issue: (IssueId, Issue)) -> Result<Self, anyhow::Error> {
let (id, issue) = issue;
Ok(Self {
id,
state: *issue.state(),
title: issue.title().into(),
author: AuthorItem::new(Some(*issue.author().id), profile),
labels: issue.labels().cloned().collect(),
assignees: issue
.assignees()
.map(|did| AuthorItem::new(Some(**did), profile))
.collect::<Vec<_>>(),
timestamp: issue.timestamp(),
comments: issue
.comments()
.map(|(comment_id, comment)| {
CommentItem::new(profile, (id, issue.clone()), (*comment_id, comment.clone()))
})
.collect(),
})
}
pub fn root_comments(&self) -> Vec<CommentItem> {
self.comments
.iter()
.filter(|comment| comment.reply_to.is_none())
.cloned()
.collect::<Vec<_>>()
}
pub fn has_comment(&self, comment_id: &CommentId) -> bool {
self.comments
.iter()
.any(|comment| comment.id == *comment_id)
}
pub fn path_to_comment(&self, comment_id: &CommentId) -> Option<Vec<CommentId>> {
for comment in &self.comments {
let mut path = Vec::new();
if comment.path_to(comment_id, &mut path) {
return Some(path);
}
}
None
}
}
impl ToRow<8> for IssueItem {
fn to_row(&self) -> [Cell<'_>; 8] {
let (state, state_color) = format::issue_state(&self.state);
let state = span::default(&state).style(Style::default().fg(state_color));
let id = span::primary(&format::cob(&self.id));
let title = span::default(&self.title.clone());
let author = match &self.author.alias {
Some(alias) => {
if self.author.you {
span::alias(&format!("{alias} (you)"))
} else {
span::alias(alias)
}
}
None => match &self.author.human_nid {
Some(nid) => span::alias(nid).dim(),
None => span::blank(),
},
};
let did = match &self.author.human_nid {
Some(nid) => span::alias(nid).dim(),
None => span::blank(),
};
let labels = span::labels(&format::labels(&self.labels));
let assignees = self
.assignees
.iter()
.map(|author| (author.nid, author.alias.clone(), author.you))
.collect::<Vec<_>>();
let assignees = span::alias(&format::assignees(&assignees));
let opened = span::timestamp(&format::timestamp(&self.timestamp));
[
state.into(),
id.into(),
title.into(),
author.into(),
did.into(),
labels.into(),
assignees.into(),
opened.into(),
]
}
}
impl HasId for IssueItem {
fn id(&self) -> ObjectId {
self.id
}
}
#[derive(Clone, Default, Debug, Eq, PartialEq)]
pub struct IssueItemFilter {
state: Option<issue::State>,
authored: bool,
authors: Vec<Did>,
assigned: bool,
assignees: Vec<Did>,
search: Option<String>,
}
impl IssueItemFilter {
pub fn state(&self) -> Option<issue::State> {
self.state
}
}
impl filter::Filter<IssueItem> for IssueItemFilter {
fn matches(&self, issue: &IssueItem) -> bool {
use fuzzy_matcher::skim::SkimMatcherV2;
use fuzzy_matcher::FuzzyMatcher;
let matcher = SkimMatcherV2::default();
let matches_state = match self.state {
Some(issue::State::Closed {
reason: CloseReason::Other,
}) => matches!(issue.state, issue::State::Closed { .. }),
Some(state) => issue.state == state,
None => true,
};
let matches_authored = if self.authored {
issue.author.you
} else {
true
};
let matches_authors = if !self.authors.is_empty() {
{
self.authors
.iter()
.any(|other| issue.author.nid == Some(**other))
}
} else {
true
};
let matches_assigned = if self.assigned {
issue.assignees.iter().any(|assignee| assignee.you)
} else {
true
};
let matches_assignees = if !self.assignees.is_empty() {
{
self.assignees.iter().any(|other| {
issue
.assignees
.iter()
.filter_map(|author| author.nid)
.collect::<Vec<_>>()
.contains(other)
})
}
} else {
true
};
let matches_search = match &self.search {
Some(search) => match matcher.fuzzy_match(&issue.title, search) {
Some(score) => score == 0 || score > 60,
_ => false,
},
None => true,
};
matches_state
&& matches_authored
&& matches_authors
&& matches_assigned
&& matches_assignees
&& matches_search
}
}
impl FromStr for IssueItemFilter {
type Err = anyhow::Error;
fn from_str(value: &str) -> Result<Self, Self::Err> {
let mut state = None;
let mut search = String::new();
let mut authored = false;
let mut authors = vec![];
let mut assigned = false;
let mut assignees = vec![];
let mut authors_parser = |input| -> IResult<&str, Vec<&str>> {
preceded(
tag("authors:"),
delimited(
tag("["),
separated_list0(tag(","), take(56_usize)),
tag("]"),
),
)(input)
};
let mut assignees_parser = |input| -> IResult<&str, Vec<&str>> {
preceded(
tag("assignees:"),
delimited(
tag("["),
separated_list0(tag(","), take(56_usize)),
tag("]"),
),
)(input)
};
let parts = value.split(' ');
for part in parts {
match part {
"is:open" => state = Some(issue::State::Open),
"is:closed" => {
state = Some(issue::State::Closed {
reason: issue::CloseReason::Other,
})
}
"is:solved" => {
state = Some(issue::State::Closed {
reason: issue::CloseReason::Solved,
})
}
"is:authored" => authored = true,
"is:assigned" => assigned = true,
other => {
if let Ok((_, dids)) = assignees_parser.parse(other) {
for did in dids {
assignees.push(Did::from_str(did)?);
}
} else if let Ok((_, dids)) = authors_parser.parse(other) {
for did in dids {
authors.push(Did::from_str(did)?);
}
} else {
search.push_str(other);
}
}
}
}
Ok(Self {
state,
authored,
authors,
assigned,
assignees,
search: Some(search),
})
}
}
#[derive(Clone, Debug)]
pub struct PatchItem {
/// Patch OID.
pub id: PatchId,
/// Patch state.
pub state: patch::State,
/// Patch title.
pub title: String,
/// Author of the latest revision.
pub author: AuthorItem,
/// Head of the latest revision.
pub head: Oid,
/// Lines added by the latest revision.
pub added: u16,
/// Lines removed by the latest revision.
pub removed: u16,
/// Time when patch was opened.
pub timestamp: Timestamp,
}
impl PatchItem {
pub fn new(
profile: &Profile,
repository: &Repository,
patch: (PatchId, Patch),
) -> Result<Self, anyhow::Error> {
let (id, patch) = patch;
let (_, revision) = patch.latest();
let (from, to) = revision.range();
let stats = git::diff_stats(repository.raw(), &from, &to)?;
Ok(Self {
id,
state: patch.state().clone(),
title: patch.title().into(),
author: AuthorItem::new(Some(*patch.author().id), profile),
head: revision.head(),
added: stats.insertions() as u16,
removed: stats.deletions() as u16,
timestamp: patch.updated_at(),
})
}
}
impl ToRow<9> for PatchItem {
fn to_row(&self) -> [Cell<'_>; 9] {
let (state, color) = format::patch_state(&self.state);
let state = span::default(&state).style(Style::default().fg(color));
let id = span::primary(&format::cob(&self.id));
let title = span::default(&self.title.clone());
let author = match &self.author.alias {
Some(alias) => {
if self.author.you {
span::alias(&format!("{alias} (you)"))
} else {
span::alias(alias)
}
}
None => match &self.author.human_nid {
Some(nid) => span::alias(nid).dim(),
None => span::blank(),
},
};
let did = match &self.author.human_nid {
Some(nid) => span::alias(nid).dim(),
None => span::blank(),
};
let head = span::ternary(&format::oid(self.head));
let added = span::positive(&format!("+{}", self.added));
let removed = span::negative(&format!("-{}", self.removed));
let updated = span::timestamp(&format::timestamp(&self.timestamp));
[
state.into(),
id.into(),
title.into(),
author.into(),
did.into(),
head.into(),
added.into(),
removed.into(),
updated.into(),
]
}
}
#[derive(Clone, Default, Debug, Eq, PartialEq)]
pub struct PatchItemFilter {
status: Option<patch::Status>,
authored: bool,
authors: Vec<Did>,
search: Option<String>,
}
impl PatchItemFilter {
pub fn is_default(&self) -> bool {
*self == PatchItemFilter::default()
}
}
impl filter::Filter<PatchItem> for PatchItemFilter {
fn matches(&self, patch: &PatchItem) -> bool {
use fuzzy_matcher::skim::SkimMatcherV2;
use fuzzy_matcher::FuzzyMatcher;
let matcher = SkimMatcherV2::default();
let matches_state = match self.status {
Some(patch::Status::Draft) => matches!(patch.state, patch::State::Draft),
Some(patch::Status::Open) => matches!(patch.state, patch::State::Open { .. }),
Some(patch::Status::Merged) => matches!(patch.state, patch::State::Merged { .. }),
Some(patch::Status::Archived) => matches!(patch.state, patch::State::Archived),
None => true,
};
let matches_authored = if self.authored {
patch.author.you
} else {
true
};
let matches_authors = if !self.authors.is_empty() {
{
self.authors
.iter()
.any(|other| patch.author.nid == Some(**other))
}
} else {
true
};
let matches_search = match &self.search {
Some(search) => match matcher.fuzzy_match(&patch.title, search) {
Some(score) => score == 0 || score > 60,
_ => false,
},
None => true,
};
matches_state && matches_authored && matches_authors && matches_search
}
}
impl FromStr for PatchItemFilter {
type Err = anyhow::Error;
fn from_str(value: &str) -> Result<Self, Self::Err> {
let mut status = None;
let mut search = String::new();
let mut authored = false;
let mut authors = vec![];
let mut authors_parser = |input| -> IResult<&str, Vec<&str>> {
preceded(
tag("authors:"),
delimited(
tag("["),
separated_list0(tag(","), take(56_usize)),
tag("]"),
),
)(input)
};
let parts = value.split(' ');
for part in parts {
match part {
"is:open" => status = Some(patch::Status::Open),
"is:merged" => status = Some(patch::Status::Merged),
"is:archived" => status = Some(patch::Status::Archived),
"is:draft" => status = Some(patch::Status::Draft),
"is:authored" => authored = true,
other => match authors_parser.parse(other) {
Ok((_, dids)) => {
for did in dids {
authors.push(Did::from_str(did)?);
}
}
_ => search.push_str(other),
},
}
}
let search = if search.is_empty() {
None
} else {
Some(search)
};
Ok(Self {
status,
authored,
authors,
search,
})
}
}
/// A `CommentItem` represents a comment COB and is constructed from an `Issue` and
/// a `Comment`.
#[derive(Clone, Debug)]
pub struct CommentItem {
/// Comment OID.
pub id: CommentId,
/// Author of this comment.
pub author: AuthorItem,
/// The content of this comment.
pub body: String,
/// Reactions to this comment.
pub reactions: Vec<char>,
/// Time when patch was opened.
pub timestamp: Timestamp,
/// The parent OID if this is a reply.
pub reply_to: Option<CommentId>,
/// Replies to this comment.
pub replies: Vec<CommentItem>,
}
impl CommentItem {
pub fn new(profile: &Profile, issue: (IssueId, Issue), comment: (CommentId, Comment)) -> Self {
let (issue_id, issue) = issue;
let (comment_id, comment) = comment;
Self {
id: comment_id,
author: AuthorItem::new(Some(NodeId::from(*comment.author().0)), profile),
body: comment.body().to_string(),
reactions: comment.reactions().iter().map(|r| r.0.emoji()).collect(),
timestamp: comment.timestamp(),
reply_to: comment.reply_to(),
replies: issue
.thread()
.replies(&comment_id)
.map(|(reply_id, reply)| {
CommentItem::new(
profile,
(issue_id, issue.clone()),
(*reply_id, reply.clone()),
)
})
.collect(),
}
}
pub fn accumulated_reactions(&self) -> Vec<(char, usize)> {
let mut accumulated: HashMap<char, usize> = HashMap::new();
for reaction in &self.reactions {
if let Some(count) = accumulated.get_mut(reaction) {
*count = count.saturating_add(1);
} else {
accumulated.insert(*reaction, 1_usize);
}
}
let mut sorted = accumulated.into_iter().collect::<Vec<_>>();
sorted.sort();
sorted
}
pub fn path_to(&self, target_id: &CommentId, path: &mut Vec<CommentId>) -> bool {
path.push(self.id);
if self.id == *target_id {
return true;
}
for reply in &self.replies {
if reply.path_to(target_id, path) {
return true;
}
}
path.pop();
false
}
}
impl ToTree<String> for CommentItem {
fn rows(&self) -> Vec<TreeItem<'_, String>> {
let children = self.replies.iter().flat_map(CommentItem::rows).collect();
let author = match &self.author.alias {
Some(alias) => {
if self.author.you {
span::alias(&format!("{alias} (you)"))
} else {
span::alias(alias)
}
}
None => match &self.author.human_nid {
Some(nid) => span::alias(nid).dim(),
None => span::blank(),
},
};
let action = if self.reply_to.is_none() {
"opened"
} else {
"commented"
};
let timestamp = span::timestamp(&format::timestamp(&self.timestamp));
let text = Text::from(Line::from(
[author, " ".into(), action.into(), " ".into(), timestamp].to_vec(),
));
let item = TreeItem::new(self.id.to_string(), text, children)
.expect("Identifiers need to be unique");
vec![item]
}
}
pub struct TermLine(terminal::Line);
impl From<TermLine> for Line<'_> {
fn from(val: TermLine) -> Self {
Line::raw(val.0.to_string())
}
}
/// 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.
#[derive(Clone, Debug)]
pub struct HunkComments {
/// All comments. Can be unsorted.
comments: HashMap<MergeLocation, Vec<(EntryId, Comment<CodeLocation>)>>,
}
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);
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()
}
} else {
MergeLocation::Unknown
}
} else {
MergeLocation::Unknown
};
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,
}
}
pub fn all(&self) -> &HashMap<MergeLocation, 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
})
}
}
/// A [`HunkItem`] that can be rendered. Hunk items are indexed sequentially and
/// provide access to the underlying hunk type.
#[derive(Clone)]
pub struct HunkItem<'a> {
/// The underlying hunk type and its current state (accepted / rejected).
pub diff: HunkDiff,
/// 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 From<(&Repository, &Review, &HunkDiff)> for HunkItem<'_> {
fn from(value: (&Repository, &Review, &HunkDiff)) -> Self {
let (repo, review, item) = value;
let hi = Highlighter::default();
// 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.clone().blobs(repo.raw());
let lines = blobs.highlight(hi);
// Filter comments and include them, if:
// - comment has a code location
// - comment path matches hunk path
// - comment code location is inside hunk code range
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);
} else {
return true;
}
}
}
false
})
.map(|(id, comment)| (*id, comment.clone()))
.collect::<Vec<_>>();
Self {
diff: item.clone(),
lines,
comments: HunkComments::new(item, comments),
}
}
}
impl ToRow<3> for StatefulHunkItem<'_> {
fn to_row(&self) -> [Cell<'_>; 3] {
let build_stats_spans = |stats: &DiffStats| -> Vec<Span<'_>> {
let mut cell = vec![];
let comments = &self.inner().comments;
if !comments.is_empty() {
cell.push(
span::default(&format!(" {} ", 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().diff {
HunkDiff::Added {
path,
header: _,
new: _,
hunk,
_stats: _,
} => {
let stats = hunk.as_ref().map(HunkStats::from).unwrap_or_default();
let stats_cell = [
build_stats_spans(&DiffStats::Hunk(stats)),
[span::default(" A ").bold().light_green().dim()].to_vec(),
]
.concat();
[
Line::from(ui::span::hunk_state(self.state()))
.right_aligned()
.into(),
Line::from(ui::span::pretty_path(path, false, false)).into(),
Line::from(stats_cell).right_aligned().into(),
]
}
HunkDiff::Modified {
path,
header: _,
old: _,
new: _,
hunk,
_stats: _,
} => {
let stats = hunk.as_ref().map(HunkStats::from).unwrap_or_default();
let stats_cell = [
build_stats_spans(&DiffStats::Hunk(stats)),
[span::default(" M ").bold().light_yellow().dim()].to_vec(),
]
.concat();
[
Line::from(ui::span::hunk_state(self.state()))
.right_aligned()
.into(),
Line::from(ui::span::pretty_path(path, false, false)).into(),
Line::from(stats_cell).right_aligned().into(),
]
}
HunkDiff::Deleted {
path,
header: _,
old: _,
hunk,
_stats: _,
} => {
let stats = hunk.as_ref().map(HunkStats::from).unwrap_or_default();
let stats_cell = [
build_stats_spans(&DiffStats::Hunk(stats)),
[span::default(" D ").bold().light_red().dim()].to_vec(),
]
.concat();
[
Line::from(ui::span::hunk_state(self.state()))
.right_aligned()
.into(),
Line::from(ui::span::pretty_path(path, false, false)).into(),
Line::from(stats_cell).right_aligned().into(),
]
}
HunkDiff::Copied { copied } => {
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();
[
Line::from(ui::span::hunk_state(self.state()))
.right_aligned()
.into(),
Line::from(ui::span::pretty_path(&copied.new_path, false, false)).into(),
Line::from(stats_cell).right_aligned().into(),
]
}
HunkDiff::Moved { moved } => {
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();
[
Line::from(ui::span::hunk_state(self.state()))
.right_aligned()
.into(),
Line::from(ui::span::pretty_path(&moved.new_path, false, false)).into(),
Line::from(stats_cell).right_aligned().into(),
]
}
HunkDiff::EofChanged {
path,
header: _,
old: _,
new: _,
_eof: _,
} => [
Line::from(ui::span::hunk_state(self.state()))
.right_aligned()
.into(),
Line::from(ui::span::pretty_path(path, false, false)).into(),
Line::from(span::default("EOF ").light_blue())
.right_aligned()
.into(),
],
HunkDiff::ModeChanged {
path,
header: _,
old: _,
new: _,
} => [
Line::from(ui::span::hunk_state(self.state()))
.right_aligned()
.into(),
Line::from(ui::span::pretty_path(path, false, false)).into(),
Line::from(span::default("FM ").light_blue())
.right_aligned()
.into(),
],
}
}
}
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!(" {count} comments "))
.dim()
.reversed()
}
} else {
span::blank()
};
match &self.diff {
HunkDiff::Added {
path,
header: _,
new: _,
hunk: _,
_stats: _,
} => {
let path = Line::from(ui::span::pretty_path(path, false, true));
let header = [
Column::new("", Constraint::Length(0)),
Column::new(path.clone(), Constraint::Length(path.width() as u16)),
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()
}
HunkDiff::Modified {
path,
header: _,
old: _,
new: _,
hunk: _,
_stats: _,
} => {
let path = Line::from(ui::span::pretty_path(path, false, true));
let header = [
Column::new("", Constraint::Length(0)),
Column::new(path.clone(), Constraint::Length(path.width() as u16)),
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()
}
HunkDiff::Deleted {
path,
header: _,
old: _,
hunk: _,
_stats: _,
} => {
let path = Line::from(ui::span::pretty_path(path, true, true));
let header = [
Column::new("", Constraint::Length(0)),
Column::new(path.clone(), Constraint::Length(path.width() as u16)),
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()
}
HunkDiff::Copied { copied } => {
let path = Line::from(
[
ui::span::pretty_path(&copied.old_path, false, true),
[span::default(" -> ")].to_vec(),
ui::span::pretty_path(&copied.new_path, false, true),
]
.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()
}
HunkDiff::Moved { moved } => {
let path = Line::from(
[
ui::span::pretty_path(&moved.old_path, false, true),
[span::default(" -> ")].to_vec(),
ui::span::pretty_path(&moved.new_path, false, true),
]
.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()
}
HunkDiff::EofChanged {
path,
header: _,
old: _,
new: _,
_eof: _,
} => {
let path = Line::from(ui::span::pretty_path(path, false, true));
let header = [
Column::new("", Constraint::Length(0)),
Column::new(path.clone(), Constraint::Length(path.width() as u16)),
Column::new(
span::default(" eof ")
.dim()
.reversed()
.into_right_aligned_line(),
Constraint::Fill(1),
),
];
header.to_vec()
}
HunkDiff::ModeChanged {
path,
header: _,
old: _,
new: _,
} => {
let path = Line::from(ui::span::pretty_path(path, false, true));
let header = [
Column::new("", Constraint::Length(0)),
Column::new(path.clone(), Constraint::Length(path.width() as u16)),
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>> {
match &self.diff {
HunkDiff::Added { hunk, .. }
| HunkDiff::Modified { hunk, .. }
| HunkDiff::Deleted { hunk, .. } => {
let mut lines = hunk
.as_ref()
.map(|hunk| Text::from(hunk.to_text(&self.lines)));
lines = lines.map(|lines| {
let divider = span::default(&"─".to_string().repeat(500)).gray().dim();
let mut merge = HashMap::new();
for (line, comments) in self.comments.all() {
merge.insert(
line.clone(),
comments
.iter()
.enumerate()
.map(|(idx, comment)| {
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())
}));
// 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::new(lines.lines).merge(merge, None);
Text::from(merged)
});
lines
}
_ => None,
}
}
}
impl Debug for HunkItem<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("HunkItem")
.field("inner", &self.diff)
.field("comments", &self.comments)
.finish()
}
}
#[derive(Clone, Debug)]
pub struct StatefulHunkItem<'a>(HunkItem<'a>, HunkState);
impl<'a> StatefulHunkItem<'a> {
pub fn new(inner: HunkItem<'a>, state: HunkState) -> Self {
Self(inner, state)
}
pub fn inner(&self) -> &HunkItem<'a> {
&self.0
}
pub fn state(&self) -> &HunkState {
&self.1
}
pub fn update_state(&mut self, state: &HunkState) {
self.1 = state.clone();
}
}
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 std::path::PathBuf;
use anyhow::Result;
use crate::test;
use super::*;
#[test]
fn patch_item_filter_from_str_should_succeed() -> Result<()> {
let search = r#"is:open is:authored authors:[did:key:z6MkkpTPzcq1ybmjQyQpyre15JUeMvZY6toxoZVpLZ8YarsB,did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx] cli"#;
let actual = PatchItemFilter::from_str(search)?;
let expected = PatchItemFilter {
status: Some(patch::Status::Open),
authored: true,
authors: vec![
Did::from_str("did:key:z6MkkpTPzcq1ybmjQyQpyre15JUeMvZY6toxoZVpLZ8YarsB")?,
Did::from_str("did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx")?,
],
search: Some("cli".to_string()),
};
assert_eq!(expected, actual);
Ok(())
}
#[test]
fn issue_item_filter_from_str_should_succeed() -> Result<()> {
let search = r#"is:open is:assigned assignees:[did:key:z6MkkpTPzcq1ybmjQyQpyre15JUeMvZY6toxoZVpLZ8YarsB,did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx] is:authored authors:[did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx] cli"#;
let actual = IssueItemFilter::from_str(search)?;
let expected = IssueItemFilter {
state: Some(issue::State::Open),
authors: vec![Did::from_str(
"did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx",
)?],
authored: true,
assigned: true,
assignees: vec![
Did::from_str("did:key:z6MkkpTPzcq1ybmjQyQpyre15JUeMvZY6toxoZVpLZ8YarsB")?,
Did::from_str("did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx")?,
],
search: Some("cli".to_string()),
};
assert_eq!(expected, actual);
Ok(())
}
#[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();
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 top = (
Oid::from_str("05ac6202655dcde6c2613702fec07c2e2fe8f382").unwrap(),
Comment::new(
*alice.node.signer.public_key(),
"At the top.".to_string(),
None,
Some(CodeLocation {
commit,
path: path.clone(),
old: Some(CodeRange::Lines { range: 3..12 }),
new: Some(CodeRange::Lines { range: 3..11 }),
}),
vec![],
Timestamp::from_secs(0),
),
);
let middle = (
Oid::from_str("2d09104bf2d6ad328aa72594b679d2d6c5a61865").unwrap(),
Comment::new(
*alice.node.signer.public_key(),
"In the middle.".to_string(),
None,
Some(CodeLocation {
commit,
path: path.clone(),
old: Some(CodeRange::Lines { range: 3..8 }),
new: Some(CodeRange::Lines { range: 3..7 }),
}),
vec![],
Timestamp::from_secs(0),
),
);
let end = (
Oid::from_str("8280317b308ba1bf2cef04533efb15d920431e86").unwrap(),
Comment::new(
*alice.node.signer.public_key(),
"At the end.".to_string(),
None,
Some(CodeLocation {
commit,
path: path.clone(),
old: Some(CodeRange::Lines { range: 3..11 }),
new: Some(CodeRange::Lines { range: 3..10 }),
}),
vec![],
Timestamp::from_secs(0),
),
);
let comments = {
let comments = [top.clone(), middle.clone(), end.clone()];
HunkComments::new(&diff, comments.to_vec())
};
for expected in [
(top, MergeLocation::Start),
(middle, MergeLocation::Line(5)),
(end, MergeLocation::End),
] {
let (line, expected) = (expected.1, expected.0);
let actual = comments.all().get(&line);
assert_ne!(actual, None, "No comment found at {line:?}");
let actual = actual.unwrap().first().unwrap();
assert_eq!(actual.0, expected.0);
}
Ok(())
}
#[test]
fn hunk_comments_on_modified_complex_are_inserted_correctly() -> Result<()> {
let alice = test::fixtures::node_with_repo();
let commit = Oid::from_str("a32c4b93e2573fd83b15ac1ad6bf1317dc8fd760").unwrap();
let path = PathBuf::from_str("main.rs").unwrap();
// --------------------------------------------------------------------
// At the top.
// --------------------------------------------------------------------
// @@ -1,17 +1,15 @@
// 1 - use radicle::issue::IssueId;
// 2 - use tui::ui::state::ItemState;
// 3 - use tui::SelectionExit;
// --------------------------------------------------------------------
// After deletion.
// --------------------------------------------------------------------
// 4 1 use tuirealm::command::{Cmd, CmdResult, Direction as MoveDirection};
// 5 2 use tuirealm::event::{Event, Key, KeyEvent};
// 6 3 use tuirealm::{MockComponent, NoUserEvent};
// 7 4
// 8 5 use radicle_tui as tui;
// 9 6
// 7 + use tui::ui::state::ItemState;
// 10 8 use tui::ui::widget::container::{AppHeader, GlobalListener, LabeledContainer};
// 11 9 use tui::ui::widget::context::{ContextBar, Shortcuts};
// 12 10 use tui::ui::widget::list::PropertyList;
// 13 -
// 14 11 use tui::ui::widget::Widget;
// 12 + use tui::{Id, SelectionExit};
// 15 13
// 16 14 use super::ui::{IdSelect, OperationSelect};
// --------------------------------------------------------------------
// Before last line.
// --------------------------------------------------------------------
// 17 15 use super::{IssueOperation, Message};
let diff = test::fixtures::complex_modified_hunk_diff(&path, commit)?;
let top = (
Oid::from_str("05ac6202655dcde6c2613702fec07c2e2fe8f382").unwrap(),
Comment::new(
*alice.node.signer.public_key(),
"At the top.".to_string(),
None,
Some(CodeLocation {
commit,
path: path.clone(),
old: Some(CodeRange::Lines { range: 1..18 }),
new: Some(CodeRange::Lines { range: 1..17 }),
}),
vec![],
Timestamp::from_secs(0),
),
);
let after_deletion = (
Oid::from_str("2d09104bf2d6ad328aa72594b679d2d6c5a61865").unwrap(),
Comment::new(
*alice.node.signer.public_key(),
"After deletion.".to_string(),
None,
Some(CodeLocation {
commit,
path: path.clone(),
old: Some(CodeRange::Lines { range: 1..4 }),
new: None,
}),
vec![],
Timestamp::from_secs(0),
),
);
let before_last_line = (
Oid::from_str("60972bca0c9e686e76b0a5123acb3c8c60c38b1e").unwrap(),
Comment::new(
*alice.node.signer.public_key(),
"Before last line".to_string(),
None,
Some(CodeLocation {
commit,
path: path.clone(),
old: Some(CodeRange::Lines { range: 1..17 }),
new: Some(CodeRange::Lines { range: 1..15 }),
}),
vec![],
Timestamp::from_secs(0),
),
);
let comments = {
let comments = [
top.clone(),
after_deletion.clone(),
before_last_line.clone(),
];
HunkComments::new(&diff, comments.to_vec())
};
for expected in [
(top, MergeLocation::Start),
(after_deletion, MergeLocation::Line(2)),
(before_last_line, MergeLocation::Line(17)),
] {
let (line, expected) = (expected.1, expected.0);
let actual = comments.all().get(&line);
assert_ne!(actual, None, "No comment found at {line:?}");
let actual = actual.unwrap().first().unwrap();
assert_eq!(actual.0, expected.0);
}
Ok(())
}
#[test]
fn hunk_comments_on_deleted_simple_are_inserted_correctly() -> Result<()> {
let alice = test::fixtures::node_with_repo();
let commit = Oid::from_str("a32c4b93e2573fd83b15ac1ad6bf1317dc8fd760").unwrap();
let path = PathBuf::from_str("README.md").unwrap();
// --------------------------------------------------------------------
// At the top.
// --------------------------------------------------------------------
// @@ -1,1 +0,0 @@
// -TBD
// --------------------------------------------------------------------
// At the end.
// --------------------------------------------------------------------
let diff = test::fixtures::deleted_hunk_diff(&path, commit)?;
let top = (
Oid::from_str("05ac6202655dcde6c2613702fec07c2e2fe8f382").unwrap(),
Comment::new(
*alice.node.signer.public_key(),
"At the top.".to_string(),
None,
Some(CodeLocation {
commit,
path: path.clone(),
old: Some(CodeRange::Lines { range: 1..3 }),
new: Some(CodeRange::Lines { range: 0..1 }),
}),
vec![],
Timestamp::from_secs(0),
),
);
let end = (
Oid::from_str("8280317b308ba1bf2cef04533efb15d920431e86").unwrap(),
Comment::new(
*alice.node.signer.public_key(),
"At the end.".to_string(),
None,
Some(CodeLocation {
commit,
path: path.clone(),
old: Some(CodeRange::Lines { range: 1..2 }),
new: None,
}),
vec![],
Timestamp::from_secs(0),
),
);
let comments = {
let comments = [top.clone(), end.clone()];
HunkComments::new(&diff, comments.to_vec())
};
for expected in [(top, MergeLocation::Start), (end, MergeLocation::End)] {
let (line, expected) = (expected.1, expected.0);
let actual = comments.all().get(&line);
assert_ne!(actual, None, "No comment found at {line:?}");
let actual = actual.unwrap().first().unwrap();
assert_eq!(actual.0, expected.0);
}
Ok(())
}
}