mirror of
https://seed.radicle.dev/rad:z39mP9rQAaGmERfUMPULfPUi473tY.git
synced 2026-10-26 06:40:51 +08:00
1691 lines
59 KiB
Rust
1691 lines
59 KiB
Rust
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<usize>,
|
|
/// Lines removed by the latest revision.
|
|
pub removed: Option<usize>,
|
|
/// Time when patch was opened.
|
|
pub timestamp: Timestamp,
|
|
}
|
|
|
|
impl Patch {
|
|
pub fn new(
|
|
profile: &Profile,
|
|
repository: &Repository,
|
|
patch: (PatchId, radicle::patch::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: Some(stats.insertions()),
|
|
removed: Some(stats.deletions()),
|
|
timestamp: patch.updated_at(),
|
|
})
|
|
}
|
|
|
|
pub fn without_stats(
|
|
profile: &Profile,
|
|
patch: (PatchId, radicle::patch::Patch),
|
|
) -> Result<Self, anyhow::Error> {
|
|
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<PatchFilter>),
|
|
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<Patch> 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<Self, Self::Err> {
|
|
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<PatchFilter>> {
|
|
many0(preceded(multispace0, parse_single_filter)).parse(input)
|
|
}
|
|
|
|
let parse_filter_expression = |input: &str| -> Result<PatchFilter, String> {
|
|
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<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::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::<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 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(())
|
|
}
|
|
}
|