use std::collections::HashMap; use std::fmt; use std::fmt::Debug; use std::ops::Range; use ansi_to_tui::IntoText; use radicle::cob::thread::Comment; use radicle::cob::{CodeLocation, CodeRange, EntryId, Timestamp}; use radicle::git::Oid; use radicle::patch::{PatchId, Review}; use radicle::prelude::Did; 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::highlight::Highlighter; use ratatui::layout::Constraint; use ratatui::style::{Color, Style, Stylize}; use ratatui::text::{Line, Span, Text}; use ratatui::widgets::Cell; use radicle_tui as tui; use tui::ui::span; use tui::ui::utils::LineMerger; use tui::ui::utils::MergeLocation; use tui::ui::widget::Column; use tui::ui::ToRow; use crate::git::{self, Blobs, DiffStats, HunkDiff, HunkState, HunkStats}; use crate::ui; use super::format; use super::AuthorItem; #[derive(Clone, Debug)] pub struct Patch { /// Patch OID. pub id: PatchId, /// Patch state. pub state: radicle::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: Option, /// Lines removed by the latest revision. pub removed: Option, /// Time when patch was opened. pub timestamp: Timestamp, } impl Patch { pub fn new( profile: &Profile, repository: &Repository, patch: (PatchId, radicle::patch::Patch), ) -> Result { 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: Some(stats.insertions()), removed: Some(stats.deletions()), timestamp: patch.updated_at(), }) } pub fn without_stats( profile: &Profile, patch: (PatchId, radicle::patch::Patch), ) -> Result { let (id, patch) = patch; let (_, revision) = patch.latest(); Ok(Self { id, state: patch.state().clone(), title: patch.title().into(), author: AuthorItem::new(Some(*patch.author().id), profile), head: revision.head(), added: None, removed: None, timestamp: patch.updated_at(), }) } } impl ToRow<9> for Patch { 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.map(|a| a.to_string()).unwrap_or("?".to_string()) )); let removed = span::negative(&format!( "+{}", self.removed .map(|r| r.to_string()) .unwrap_or("?".to_string()) )); 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(), ] } } pub mod filter { use std::fmt; use std::fmt::Debug; use std::fmt::Write as _; use std::str::FromStr; use nom::branch::alt; use nom::bytes::complete::{tag_no_case, take_while1}; use nom::character::complete::multispace0; use nom::combinator::{map, value}; use nom::multi::many0; use nom::sequence::preceded; use nom::IResult; use radicle::patch::Status; use crate::ui::items::filter; use crate::ui::items::filter::DidFilter; use crate::ui::items::filter::Filter; use super::Patch; #[derive(Clone, Debug, Eq, PartialEq)] pub enum PatchFilter { State(Status), Author(DidFilter), Search(String), And(Vec), Empty, Invalid, } impl Default for PatchFilter { fn default() -> Self { PatchFilter::State(Status::Open) } } impl PatchFilter { pub fn is_default(&self) -> bool { *self == PatchFilter::default() } pub fn has_state(&self) -> bool { match self { PatchFilter::State(_) => true, PatchFilter::And(filters) => { filters.iter().any(|f| matches!(f, PatchFilter::State(_))) } _ => false, } } } impl fmt::Display for PatchFilter { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { PatchFilter::State(state) => { write!(f, "state={state}")?; f.write_char(' ')?; } PatchFilter::Author(filter) => { write!(f, "author={filter}")?; f.write_char(' ')?; } PatchFilter::Search(search) => { write!(f, "{search}")?; f.write_char(' ')?; } PatchFilter::And(filters) => { let mut it = filters.iter().peekable(); while let Some(filter) = it.next() { write!(f, "{filter}")?; if it.peek().is_none() { f.write_char(' ')?; } } } PatchFilter::Empty | PatchFilter::Invalid => {} } Ok(()) } } impl Filter for PatchFilter { fn matches(&self, patch: &Patch) -> bool { use fuzzy_matcher::skim::SkimMatcherV2; use fuzzy_matcher::FuzzyMatcher; let matcher = SkimMatcherV2::default(); match self { PatchFilter::State(state) => Status::from(&patch.state) == *state, PatchFilter::Author(author_filter) => match author_filter { DidFilter::Single(author) => patch.author.nid == Some(**author), DidFilter::Or(authors) => authors .iter() .any(|other| patch.author.nid == Some(**other)), }, PatchFilter::Search(search) => { match matcher.fuzzy_match( &format!( "{} {} {}", &patch.id.to_string(), &patch.title, &patch .author .alias .as_ref() .map(|a| a.to_string()) .unwrap_or_default() ), search, ) { Some(score) => score == 0 || score > filter::FUZZY_MIN_SCORE, _ => false, } } PatchFilter::And(filters) => filters.iter().all(|f| f.matches(patch)), PatchFilter::Empty => true, PatchFilter::Invalid => false, } } } impl FromStr for PatchFilter { type Err = anyhow::Error; fn from_str(filter_exp: &str) -> Result { use nom::Parser; fn parse_state(input: &str) -> IResult<&str, Status> { alt(( value(Status::Open, tag_no_case("open")), value(Status::Merged, tag_no_case("merged")), value(Status::Draft, tag_no_case("draft")), value(Status::Archived, tag_no_case("archived")), )) .parse(input) } fn parse_state_filter(input: &str) -> IResult<&str, PatchFilter> { map( preceded( ( tag_no_case("state"), multispace0, tag_no_case("="), multispace0, ), parse_state, ), PatchFilter::State, ) .parse(input) } fn parse_author_filter(input: &str) -> IResult<&str, PatchFilter> { map( preceded( ( tag_no_case("author"), multispace0, tag_no_case("="), multispace0, ), alt((filter::parse_did_single, filter::parse_did_or)), ), PatchFilter::Author, ) .parse(input) } fn parse_search_filter(input: &str) -> IResult<&str, PatchFilter> { map( take_while1(|c: char| c.is_alphanumeric() || c == '_' || c == '-'), |s: &str| PatchFilter::Search(s.to_string()), ) .parse(input) } fn parse_single_filter(input: &str) -> IResult<&str, PatchFilter> { alt((parse_state_filter, parse_author_filter, parse_search_filter)).parse(input) } fn parse_filters(input: &str) -> IResult<&str, Vec> { many0(preceded(multispace0, parse_single_filter)).parse(input) } let parse_filter_expression = |input: &str| -> Result { match parse_filters(input) { Ok((remaining, filters)) => { let remaining = remaining.trim(); if !remaining.is_empty() { return Err(format!("Unparsed input remaining: '{remaining}'")); } if filters.is_empty() { return Ok(PatchFilter::Empty); } if filters.len() == 1 { Ok(filters.into_iter().next().unwrap()) } else { Ok(PatchFilter::And(filters)) } } Err(e) => Err(format!("Parse error: {e}")), } }; parse_filter_expression(filter_exp).map_err(|err| anyhow::format_err!(err)) } } } pub struct TermLine(radicle_cli::terminal::Line); impl From 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, new: Range, } impl From<&Hunk> for DiffLineRanges { fn from(hunk: &Hunk) -> 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, new: Option, } 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, } 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 { 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)>>, } impl HunkComments { pub fn new(diff: &HunkDiff, comments: Vec<(EntryId, Comment)>) -> Self { let mut line_comments: HashMap)>> = 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)>> { &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>>, /// 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::debug!("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::>(); 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> { 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> { 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> { 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> 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> 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>; /// 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>>; fn to_text(&'a self, blobs: &Blobs>>) -> 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 { type Output = Vec>; type Context = Blobs>>; fn to_text(&'a self, blobs: &Self::Context) -> Self::Output { let mut lines: Vec> = 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::>(), [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::>(), [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 std::str::FromStr; use anyhow::Result; use radicle::cob::patch; use crate::test; use crate::ui::items::filter::DidFilter; use crate::ui::items::patch::filter::PatchFilter; use super::*; #[test] fn patch_filter_with_all_should_succeed() -> Result<()> { let search = r#"state=open author=(did:key:z6MkkpTPzcq1ybmjQyQpyre15JUeMvZY6toxoZVpLZ8YarsB or did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx) cli"#; let expected = PatchFilter::And(vec![ PatchFilter::State(patch::Status::Open), PatchFilter::Author(DidFilter::Or(vec![ Did::from_str("did:key:z6MkkpTPzcq1ybmjQyQpyre15JUeMvZY6toxoZVpLZ8YarsB")?, Did::from_str("did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx")?, ])), PatchFilter::Search("cli".to_string()), ]); let actual = PatchFilter::from_str(search)?; assert_eq!(expected, actual); Ok(()) } #[test] fn patch_filter_with_all_shuffled_should_succeed() -> Result<()> { let search = r#"author=did:key:z6MkkpTPzcq1ybmjQyQpyre15JUeMvZY6toxoZVpLZ8YarsB state=open cli"#; let expected = PatchFilter::And(vec![ PatchFilter::Author(DidFilter::Single(Did::from_str( "did:key:z6MkkpTPzcq1ybmjQyQpyre15JUeMvZY6toxoZVpLZ8YarsB", )?)), PatchFilter::State(patch::Status::Open), PatchFilter::Search("cli".to_string()), ]); let actual = PatchFilter::from_str(search)?; 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(()) } }