Radish alpha
r
rad:z39mP9rQAaGmERfUMPULfPUi473tY
Radicle terminal user interface
Radicle
Git
radicle-tui bin ui items.rs
pub mod issue;
pub mod notification;
pub mod patch;

use std::collections::HashMap;
use std::fmt::Debug;

use radicle::cob::thread::{Comment, CommentId};
use radicle::cob::{ObjectId, Timestamp};

use radicle::identity::Did;
use radicle::issue::{Issue, IssueId};
use radicle::node::{Alias, AliasStore, NodeId};
use radicle::Profile;

use ratatui::prelude::*;
use ratatui::style::Stylize;

use tui_tree_widget::TreeItem;

use radicle_tui as tui;

use tui::ui::span;
use tui::ui::ToTree;

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;
    use nom::{IResult, Parser};

    use radicle::prelude::Did;

    pub const FUZZY_MIN_SCORE: i64 = 50;

    /// 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(())
        }
    }

    pub fn parse_did_single(input: &str) -> IResult<&str, DidFilter> {
        let (input, did) = take(56_usize)(input)?;

        match Did::from_str(did) {
            Ok(did) => IResult::Ok((input, DidFilter::Single(did))),
            Err(_) => IResult::Err(nom::Err::Error(nom::error::Error::new(
                input,
                nom::error::ErrorKind::Verify,
            ))),
        }
    }

    pub fn parse_did_or(input: &str) -> IResult<&str, DidFilter> {
        map(
            delimited(
                (multispace0, char('('), multispace0),
                separated_list1(
                    delimited(multispace0, tag_no_case("or"), multispace0),
                    take(56_usize),
                ),
                (multispace0, char(')'), multispace0),
            ),
            |dids: Vec<&str>| {
                DidFilter::Or(
                    dids.iter()
                        .filter_map(|did| Did::from_str(did).ok())
                        .collect::<Vec<_>>(),
                )
            },
        )
        .parse(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,
        }
    }
}

/// 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]
    }
}