mirror of
https://seed.radicle.dev/rad:z39mP9rQAaGmERfUMPULfPUi473tY.git
synced 2026-11-12 14:40:42 +08:00
317 lines
8.9 KiB
Rust
317 lines
8.9 KiB
Rust
use std::fmt::Debug;
|
|
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 radicle::cob::thread::CommentId;
|
|
use radicle::cob::{Label, ObjectId, Timestamp};
|
|
use radicle::issue::{CloseReason, IssueId};
|
|
use radicle::prelude::Did;
|
|
use radicle::Profile;
|
|
|
|
use ratatui::style::{Style, Stylize};
|
|
use ratatui::widgets::Cell;
|
|
|
|
use radicle_tui as tui;
|
|
|
|
use tui::ui::span;
|
|
use tui::ui::ToRow;
|
|
|
|
use crate::ui::format;
|
|
use crate::ui::items::filter::Filter;
|
|
use crate::ui::items::{AuthorItem, CommentItem, HasId};
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct Issue {
|
|
/// Issue OID.
|
|
pub id: IssueId,
|
|
/// Issue state.
|
|
pub state: radicle::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 Issue {
|
|
pub fn new(
|
|
profile: &Profile,
|
|
issue: (IssueId, radicle::issue::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 Issue {
|
|
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 Issue {
|
|
fn id(&self) -> ObjectId {
|
|
self.id
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Default, Debug, Eq, PartialEq)]
|
|
pub(crate) struct IssueFilter {
|
|
pub(crate) state: Option<radicle::issue::State>,
|
|
pub(crate) authored: bool,
|
|
pub(crate) authors: Vec<Did>,
|
|
pub(crate) assigned: bool,
|
|
pub(crate) assignees: Vec<Did>,
|
|
pub(crate) search: Option<String>,
|
|
}
|
|
|
|
impl IssueFilter {
|
|
pub fn state(&self) -> Option<radicle::issue::State> {
|
|
self.state
|
|
}
|
|
}
|
|
|
|
impl Filter<Issue> for IssueFilter {
|
|
fn matches(&self, issue: &Issue) -> bool {
|
|
use fuzzy_matcher::skim::SkimMatcherV2;
|
|
use fuzzy_matcher::FuzzyMatcher;
|
|
use radicle::issue::State;
|
|
|
|
let matcher = SkimMatcherV2::default();
|
|
|
|
let matches_state = match self.state {
|
|
Some(State::Closed {
|
|
reason: CloseReason::Other,
|
|
}) => matches!(issue.state, 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 IssueFilter {
|
|
type Err = anyhow::Error;
|
|
|
|
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
|
use radicle::issue::State;
|
|
|
|
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("]"),
|
|
),
|
|
)
|
|
.parse(input)
|
|
};
|
|
|
|
let mut assignees_parser = |input| -> IResult<&str, Vec<&str>> {
|
|
preceded(
|
|
tag("assignees:"),
|
|
delimited(
|
|
tag("["),
|
|
separated_list0(tag(","), take(56_usize)),
|
|
tag("]"),
|
|
),
|
|
)
|
|
.parse(input)
|
|
};
|
|
|
|
let parts = value.split(' ');
|
|
for part in parts {
|
|
match part {
|
|
"is:open" => state = Some(State::Open),
|
|
"is:closed" => {
|
|
state = Some(State::Closed {
|
|
reason: CloseReason::Other,
|
|
})
|
|
}
|
|
"is:solved" => {
|
|
state = Some(State::Closed {
|
|
reason: 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),
|
|
})
|
|
}
|
|
}
|