cli: Organize patch functions better
Move patch showing and timeline to a shared library so we can use it for `rad inbox`.
This commit is contained in:
parent
8928c5ee26
commit
bb06663d1c
|
|
@ -6,8 +6,6 @@ mod assign;
|
||||||
mod checkout;
|
mod checkout;
|
||||||
#[path = "patch/comment.rs"]
|
#[path = "patch/comment.rs"]
|
||||||
mod comment;
|
mod comment;
|
||||||
#[path = "patch/common.rs"]
|
|
||||||
mod common;
|
|
||||||
#[path = "patch/delete.rs"]
|
#[path = "patch/delete.rs"]
|
||||||
mod delete;
|
mod delete;
|
||||||
#[path = "patch/diff.rs"]
|
#[path = "patch/diff.rs"]
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,7 @@
|
||||||
use std::collections::BTreeSet;
|
use std::collections::BTreeSet;
|
||||||
use std::iter;
|
|
||||||
|
|
||||||
use radicle::cob;
|
|
||||||
use radicle::cob::patch;
|
use radicle::cob::patch;
|
||||||
use radicle::cob::patch::{Patch, PatchId, Patches, Verdict};
|
use radicle::cob::patch::{Patch, PatchId, Patches};
|
||||||
use radicle::git;
|
|
||||||
use radicle::patch::{Merge, Review, Revision, RevisionId};
|
|
||||||
use radicle::prelude::*;
|
use radicle::prelude::*;
|
||||||
use radicle::profile::Profile;
|
use radicle::profile::Profile;
|
||||||
use radicle::storage::git::Repository;
|
use radicle::storage::git::Repository;
|
||||||
|
|
@ -14,8 +10,8 @@ use term::format::Author;
|
||||||
use term::table::{Table, TableOptions};
|
use term::table::{Table, TableOptions};
|
||||||
use term::Element as _;
|
use term::Element as _;
|
||||||
|
|
||||||
use super::common;
|
|
||||||
use crate::terminal as term;
|
use crate::terminal as term;
|
||||||
|
use crate::terminal::patch as common;
|
||||||
|
|
||||||
/// List patches.
|
/// List patches.
|
||||||
pub fn run(
|
pub fn run(
|
||||||
|
|
@ -128,321 +124,3 @@ pub fn row(
|
||||||
.into(),
|
.into(),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn timeline<'a>(
|
|
||||||
profile: &'a Profile,
|
|
||||||
patch: &'a Patch,
|
|
||||||
) -> impl Iterator<Item = term::Line> + 'a {
|
|
||||||
Timeline::build(profile, patch).into_lines(profile)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The timeline of a [`Patch`].
|
|
||||||
///
|
|
||||||
/// A `Patch` will always have opened with a root revision and may
|
|
||||||
/// have a series of revisions that update the patch.
|
|
||||||
///
|
|
||||||
/// The function, [`timeline`], builds a `Timeline` and converts it
|
|
||||||
/// into a series of [`term::Line`]s.
|
|
||||||
struct Timeline<'a> {
|
|
||||||
opened: Opened<'a>,
|
|
||||||
revisions: Vec<RevisionEntry<'a>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> Timeline<'a> {
|
|
||||||
fn build(profile: &Profile, patch: &'a Patch) -> Self {
|
|
||||||
let opened = Opened::from_patch(patch, profile);
|
|
||||||
let mut revisions = patch
|
|
||||||
.revisions()
|
|
||||||
.skip(1) // skip the root revision since it's handled in `Opened::from_patch`
|
|
||||||
.map(|(id, revision)| {
|
|
||||||
(
|
|
||||||
revision.timestamp(),
|
|
||||||
RevisionEntry::from_revision(patch, id, revision, profile),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
revisions.sort_by_key(|(t, _)| *t);
|
|
||||||
Timeline {
|
|
||||||
opened,
|
|
||||||
revisions: revisions.into_iter().map(|(_, e)| e).collect(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn into_lines(self, profile: &'a Profile) -> impl Iterator<Item = term::Line> + 'a {
|
|
||||||
self.opened.into_lines(profile).chain(
|
|
||||||
self.revisions
|
|
||||||
.into_iter()
|
|
||||||
.flat_map(|r| r.into_lines(profile)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The root `Revision` of the `Patch`.
|
|
||||||
struct Opened<'a> {
|
|
||||||
/// The `Author` of the patch.
|
|
||||||
author: Author<'a>,
|
|
||||||
/// When the patch was created.
|
|
||||||
timestamp: cob::Timestamp,
|
|
||||||
/// The commit head of the `Revision`.
|
|
||||||
head: git::Oid,
|
|
||||||
/// Any updates performed on the root `Revision`.
|
|
||||||
updates: Vec<Update<'a>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> Opened<'a> {
|
|
||||||
fn from_patch(patch: &'a Patch, profile: &Profile) -> Self {
|
|
||||||
let (root, revision) = patch.root();
|
|
||||||
let mut updates = Vec::new();
|
|
||||||
updates.extend(revision.reviews().map(|(_, review)| {
|
|
||||||
(
|
|
||||||
review.timestamp(),
|
|
||||||
Update::Reviewed {
|
|
||||||
review: review.clone(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}));
|
|
||||||
updates.extend(patch.merges().filter_map(|(_, merge)| {
|
|
||||||
if merge.revision == root {
|
|
||||||
Some((
|
|
||||||
merge.timestamp,
|
|
||||||
Update::Merged {
|
|
||||||
author: Author::new(&revision.author().id, profile),
|
|
||||||
merge: merge.clone(),
|
|
||||||
},
|
|
||||||
))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
updates.sort_by_key(|(t, _)| *t);
|
|
||||||
Opened {
|
|
||||||
author: Author::new(&patch.author().id, profile),
|
|
||||||
timestamp: patch.timestamp(),
|
|
||||||
head: revision.head(),
|
|
||||||
updates: updates.into_iter().map(|(_, up)| up).collect(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn into_lines(self, profile: &'a Profile) -> impl Iterator<Item = term::Line> + 'a {
|
|
||||||
iter::once(
|
|
||||||
term::Line::spaced([
|
|
||||||
term::format::positive("●").into(),
|
|
||||||
term::format::default("opened by").into(),
|
|
||||||
])
|
|
||||||
.space()
|
|
||||||
.extend(self.author.line())
|
|
||||||
.space()
|
|
||||||
.extend(term::Line::spaced([
|
|
||||||
term::format::parens(term::format::secondary(term::format::oid(self.head))).into(),
|
|
||||||
term::format::dim(term::format::timestamp(self.timestamp)).into(),
|
|
||||||
])),
|
|
||||||
)
|
|
||||||
.chain(self.updates.into_iter().map(|up| {
|
|
||||||
term::Line::spaced([term::Label::space(), term::Label::from("└─ ")])
|
|
||||||
.extend(up.into_line(profile))
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A revision entry in the [`Timeline`].
|
|
||||||
enum RevisionEntry<'a> {
|
|
||||||
/// An `Updated` entry means that the original author of the
|
|
||||||
/// `Patch` created a new revision.
|
|
||||||
Updated {
|
|
||||||
/// When the `Revision` was created.
|
|
||||||
timestamp: cob::Timestamp,
|
|
||||||
/// The id of the `Revision`.
|
|
||||||
id: RevisionId,
|
|
||||||
/// The commit head of the `Revision`.
|
|
||||||
head: git::Oid,
|
|
||||||
/// All [`Update`]s that occurred on the `Revision`.
|
|
||||||
updates: Vec<Update<'a>>,
|
|
||||||
},
|
|
||||||
/// A `Revised` entry means that an author other than the original
|
|
||||||
/// author of the `Patch` created a new revision.
|
|
||||||
Revised {
|
|
||||||
/// The `Author` that created the `Revision` (that is not the
|
|
||||||
/// `Patch` author).
|
|
||||||
author: Author<'a>,
|
|
||||||
/// When the `Revision` was created.
|
|
||||||
timestamp: cob::Timestamp,
|
|
||||||
/// The id of the `Revision`.
|
|
||||||
id: RevisionId,
|
|
||||||
/// The commit head of the `Revision`.
|
|
||||||
head: git::Oid,
|
|
||||||
/// All [`Update`]s that occurred on the `Revision`.
|
|
||||||
updates: Vec<Update<'a>>,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> RevisionEntry<'a> {
|
|
||||||
fn from_revision(
|
|
||||||
patch: &Patch,
|
|
||||||
id: RevisionId,
|
|
||||||
revision: &'a Revision,
|
|
||||||
profile: &Profile,
|
|
||||||
) -> Self {
|
|
||||||
let mut updates = Vec::new();
|
|
||||||
updates.extend(revision.reviews().map(|(_, review)| {
|
|
||||||
(
|
|
||||||
review.timestamp(),
|
|
||||||
Update::Reviewed {
|
|
||||||
review: review.clone(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}));
|
|
||||||
updates.extend(patch.merges().filter_map(|(_, merge)| {
|
|
||||||
if merge.revision == id {
|
|
||||||
Some((
|
|
||||||
merge.timestamp,
|
|
||||||
Update::Merged {
|
|
||||||
author: Author::new(&revision.author().id, profile),
|
|
||||||
merge: merge.clone(),
|
|
||||||
},
|
|
||||||
))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
updates.sort_by_key(|(t, _)| *t);
|
|
||||||
|
|
||||||
if revision.author() == patch.author() {
|
|
||||||
RevisionEntry::Updated {
|
|
||||||
timestamp: revision.timestamp(),
|
|
||||||
id,
|
|
||||||
head: revision.head(),
|
|
||||||
updates: updates.into_iter().map(|(_, up)| up).collect(),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
RevisionEntry::Revised {
|
|
||||||
author: Author::new(&revision.author().id, profile),
|
|
||||||
timestamp: revision.timestamp(),
|
|
||||||
id,
|
|
||||||
head: revision.head(),
|
|
||||||
updates: updates.into_iter().map(|(_, up)| up).collect(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn into_lines(self, profile: &'a Profile) -> Vec<term::Line> {
|
|
||||||
match self {
|
|
||||||
RevisionEntry::Updated {
|
|
||||||
timestamp,
|
|
||||||
id,
|
|
||||||
head,
|
|
||||||
updates,
|
|
||||||
} => Self::updated(profile, timestamp, id, head, updates).collect(),
|
|
||||||
RevisionEntry::Revised {
|
|
||||||
author,
|
|
||||||
timestamp,
|
|
||||||
id,
|
|
||||||
head,
|
|
||||||
updates,
|
|
||||||
} => Self::revised(profile, author, timestamp, id, head, updates).collect(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn updated(
|
|
||||||
profile: &'a Profile,
|
|
||||||
timestamp: cob::Timestamp,
|
|
||||||
id: RevisionId,
|
|
||||||
head: git::Oid,
|
|
||||||
updates: Vec<Update<'a>>,
|
|
||||||
) -> impl Iterator<Item = term::Line> + 'a {
|
|
||||||
iter::once(term::Line::spaced([
|
|
||||||
term::format::tertiary("↑").into(),
|
|
||||||
term::format::default("updated to").into(),
|
|
||||||
term::format::dim(id).into(),
|
|
||||||
term::format::parens(term::format::secondary(term::format::oid(head))).into(),
|
|
||||||
term::format::dim(term::format::timestamp(timestamp)).into(),
|
|
||||||
]))
|
|
||||||
.chain(updates.into_iter().map(|up| {
|
|
||||||
term::Line::spaced([term::Label::space(), term::Label::from("└─ ")])
|
|
||||||
.extend(up.into_line(profile))
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn revised(
|
|
||||||
profile: &'a Profile,
|
|
||||||
author: Author<'a>,
|
|
||||||
timestamp: cob::Timestamp,
|
|
||||||
id: RevisionId,
|
|
||||||
head: git::Oid,
|
|
||||||
updates: Vec<Update<'a>>,
|
|
||||||
) -> impl Iterator<Item = term::Line> + 'a {
|
|
||||||
let (alias, nid) = author.labels();
|
|
||||||
iter::once(term::Line::spaced([
|
|
||||||
term::format::tertiary("*").into(),
|
|
||||||
term::format::default("revised by").into(),
|
|
||||||
alias,
|
|
||||||
nid,
|
|
||||||
term::format::default("in").into(),
|
|
||||||
term::format::dim(term::format::oid(id)).into(),
|
|
||||||
term::format::parens(term::format::secondary(term::format::oid(head))).into(),
|
|
||||||
term::format::dim(term::format::timestamp(timestamp)).into(),
|
|
||||||
]))
|
|
||||||
.chain(updates.into_iter().map(|up| {
|
|
||||||
term::Line::spaced([term::Label::space(), term::Label::from("└─ ")])
|
|
||||||
.extend(up.into_line(profile))
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// An update in the [`Patch`]'s timeline.
|
|
||||||
enum Update<'a> {
|
|
||||||
/// A revision of the patch was reviewed.
|
|
||||||
Reviewed { review: Review },
|
|
||||||
/// A revision of the patch was merged.
|
|
||||||
Merged { author: Author<'a>, merge: Merge },
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> Update<'a> {
|
|
||||||
fn timestamp(&self) -> cob::Timestamp {
|
|
||||||
match self {
|
|
||||||
Update::Reviewed { review } => review.timestamp(),
|
|
||||||
Update::Merged { merge, .. } => merge.timestamp,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn into_line(self, profile: &Profile) -> term::Line {
|
|
||||||
let timestamp = self.timestamp();
|
|
||||||
let mut line = match self {
|
|
||||||
Update::Reviewed { review } => {
|
|
||||||
let verdict = review.verdict();
|
|
||||||
let verdict_symbol = match verdict {
|
|
||||||
Some(Verdict::Accept) => term::format::positive("✓"),
|
|
||||||
Some(Verdict::Reject) => term::format::negative("✗"),
|
|
||||||
None => term::format::dim("⋄"),
|
|
||||||
};
|
|
||||||
let verdict_verb = match verdict {
|
|
||||||
Some(Verdict::Accept) => term::format::default("accepted"),
|
|
||||||
Some(Verdict::Reject) => term::format::default("rejected"),
|
|
||||||
None => term::format::default("reviewed"),
|
|
||||||
};
|
|
||||||
term::Line::spaced([
|
|
||||||
verdict_symbol.into(),
|
|
||||||
verdict_verb.into(),
|
|
||||||
term::format::default("by").into(),
|
|
||||||
])
|
|
||||||
.space()
|
|
||||||
.extend(Author::new(&review.author().id.into(), profile).line())
|
|
||||||
}
|
|
||||||
Update::Merged { author, merge } => {
|
|
||||||
let (alias, nid) = author.labels();
|
|
||||||
term::Line::spaced([
|
|
||||||
term::format::primary("✓").bold().into(),
|
|
||||||
term::format::default("merged by").into(),
|
|
||||||
alias,
|
|
||||||
nid,
|
|
||||||
term::format::default("at revision").into(),
|
|
||||||
term::format::dim(term::format::oid(merge.revision)).into(),
|
|
||||||
term::format::parens(term::format::secondary(term::format::oid(merge.commit)))
|
|
||||||
.into(),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
};
|
|
||||||
line.push(term::Label::space());
|
|
||||||
line.push(term::format::dim(term::format::timestamp(timestamp)));
|
|
||||||
line
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,6 @@ use std::process;
|
||||||
use radicle::cob::patch;
|
use radicle::cob::patch;
|
||||||
use radicle::git;
|
use radicle::git;
|
||||||
use radicle::storage::git::Repository;
|
use radicle::storage::git::Repository;
|
||||||
use radicle_term::{
|
|
||||||
table::{Table, TableOptions},
|
|
||||||
textarea, Element, VStack,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::terminal as term;
|
use crate::terminal as term;
|
||||||
|
|
||||||
|
|
@ -27,32 +23,6 @@ fn show_patch_diff(patch: &patch::Patch, stored: &Repository) -> anyhow::Result<
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn patch_commits(patch: &patch::Patch, stored: &Repository) -> anyhow::Result<Vec<term::Line>> {
|
|
||||||
let (from, to) = patch.range()?;
|
|
||||||
let range = format!("{}..{}", from, to);
|
|
||||||
|
|
||||||
let mut revwalk = stored.revwalk(*patch.head())?;
|
|
||||||
let mut lines = Vec::new();
|
|
||||||
|
|
||||||
revwalk.push_range(&range)?;
|
|
||||||
|
|
||||||
for commit in revwalk {
|
|
||||||
let commit = commit?;
|
|
||||||
let commit = stored.raw().find_commit(commit)?;
|
|
||||||
|
|
||||||
lines.push(term::Line::spaced([
|
|
||||||
term::label(term::format::secondary::<String>(
|
|
||||||
term::format::oid(commit.id()).into(),
|
|
||||||
)),
|
|
||||||
term::label(term::format::default(
|
|
||||||
commit.summary().unwrap_or_default().to_owned(),
|
|
||||||
)),
|
|
||||||
]));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(lines)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn run(
|
pub fn run(
|
||||||
patch_id: &PatchId,
|
patch_id: &PatchId,
|
||||||
diff: bool,
|
diff: bool,
|
||||||
|
|
@ -72,90 +42,7 @@ pub fn run(
|
||||||
println!("{:#?}", patch);
|
println!("{:#?}", patch);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
term::patch::show(&patch, patch_id, verbose, stored, Some(workdir), profile)?;
|
||||||
let (_, revision) = patch.latest();
|
|
||||||
let state = patch.state();
|
|
||||||
let branches = common::branches(&revision.head(), workdir)?;
|
|
||||||
let ahead_behind = common::ahead_behind(
|
|
||||||
stored.raw(),
|
|
||||||
*revision.head(),
|
|
||||||
*patch.target().head(stored)?,
|
|
||||||
)?;
|
|
||||||
let author = patch.author();
|
|
||||||
let author = term::format::Author::new(author.id(), profile);
|
|
||||||
let labels = patch.labels().map(|l| l.to_string()).collect::<Vec<_>>();
|
|
||||||
|
|
||||||
let mut attrs = Table::<2, term::Line>::new(TableOptions {
|
|
||||||
spacing: 2,
|
|
||||||
..TableOptions::default()
|
|
||||||
});
|
|
||||||
attrs.push([
|
|
||||||
term::format::tertiary("Title".to_owned()).into(),
|
|
||||||
term::format::bold(patch.title().to_owned()).into(),
|
|
||||||
]);
|
|
||||||
attrs.push([
|
|
||||||
term::format::tertiary("Patch".to_owned()).into(),
|
|
||||||
term::format::default(patch_id.to_string()).into(),
|
|
||||||
]);
|
|
||||||
attrs.push([
|
|
||||||
term::format::tertiary("Author".to_owned()).into(),
|
|
||||||
author.line(),
|
|
||||||
]);
|
|
||||||
if !labels.is_empty() {
|
|
||||||
attrs.push([
|
|
||||||
term::format::tertiary("Labels".to_owned()).into(),
|
|
||||||
term::format::secondary(labels.join(", ")).into(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
attrs.push([
|
|
||||||
term::format::tertiary("Head".to_owned()).into(),
|
|
||||||
term::format::secondary(revision.head().to_string()).into(),
|
|
||||||
]);
|
|
||||||
if verbose {
|
|
||||||
attrs.push([
|
|
||||||
term::format::tertiary("Base".to_owned()).into(),
|
|
||||||
term::format::secondary(revision.base().to_string()).into(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
if !branches.is_empty() {
|
|
||||||
attrs.push([
|
|
||||||
term::format::tertiary("Branches".to_owned()).into(),
|
|
||||||
term::format::yellow(branches.join(", ")).into(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
attrs.push([
|
|
||||||
term::format::tertiary("Commits".to_owned()).into(),
|
|
||||||
ahead_behind,
|
|
||||||
]);
|
|
||||||
attrs.push([
|
|
||||||
term::format::tertiary("Status".to_owned()).into(),
|
|
||||||
match state {
|
|
||||||
patch::State::Open { .. } => term::format::positive(state.to_string()),
|
|
||||||
patch::State::Draft => term::format::dim(state.to_string()),
|
|
||||||
patch::State::Archived => term::format::yellow(state.to_string()),
|
|
||||||
patch::State::Merged { .. } => term::format::primary(state.to_string()),
|
|
||||||
}
|
|
||||||
.into(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
let commits = patch_commits(&patch, stored)?;
|
|
||||||
let description = patch.description().trim();
|
|
||||||
let mut widget = VStack::default()
|
|
||||||
.border(Some(term::colors::FAINT))
|
|
||||||
.child(attrs)
|
|
||||||
.children(if !description.is_empty() {
|
|
||||||
vec![term::Label::blank().boxed(), textarea(description).boxed()]
|
|
||||||
} else {
|
|
||||||
vec![]
|
|
||||||
})
|
|
||||||
.divider()
|
|
||||||
.children(commits.into_iter().map(|l| l.boxed()))
|
|
||||||
.divider();
|
|
||||||
|
|
||||||
for line in list::timeline(profile, &patch) {
|
|
||||||
widget.push(line);
|
|
||||||
}
|
|
||||||
widget.print();
|
|
||||||
|
|
||||||
if diff {
|
if diff {
|
||||||
term::blank();
|
term::blank();
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@ use radicle::git;
|
||||||
use radicle::prelude::*;
|
use radicle::prelude::*;
|
||||||
use radicle::storage::git::Repository;
|
use radicle::storage::git::Repository;
|
||||||
|
|
||||||
use super::common::*;
|
|
||||||
use crate::terminal as term;
|
use crate::terminal as term;
|
||||||
|
use crate::terminal::patch::*;
|
||||||
|
|
||||||
/// Run patch update.
|
/// Run patch update.
|
||||||
pub fn run(
|
pub fn run(
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,6 @@
|
||||||
|
mod common;
|
||||||
|
mod timeline;
|
||||||
|
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::fmt::Write;
|
use std::fmt::Write;
|
||||||
use std::io;
|
use std::io;
|
||||||
|
|
@ -8,10 +11,16 @@ use thiserror::Error;
|
||||||
use radicle::cob;
|
use radicle::cob;
|
||||||
use radicle::cob::patch;
|
use radicle::cob::patch;
|
||||||
use radicle::git;
|
use radicle::git;
|
||||||
|
use radicle::patch::{Patch, PatchId};
|
||||||
|
use radicle::prelude::Profile;
|
||||||
|
use radicle::storage::git::Repository;
|
||||||
|
use radicle::storage::WriteRepository as _;
|
||||||
|
|
||||||
use crate::terminal as term;
|
use crate::terminal as term;
|
||||||
use crate::terminal::Element;
|
use crate::terminal::Element;
|
||||||
|
|
||||||
|
pub use common::*;
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum Error {
|
pub enum Error {
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
|
|
@ -329,6 +338,128 @@ pub fn print_commits_ahead_behind(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn show(
|
||||||
|
patch: &Patch,
|
||||||
|
id: &PatchId,
|
||||||
|
verbose: bool,
|
||||||
|
stored: &Repository,
|
||||||
|
workdir: Option<&git::raw::Repository>,
|
||||||
|
profile: &Profile,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let (_, revision) = patch.latest();
|
||||||
|
let state = patch.state();
|
||||||
|
let branches = if let Some(wd) = workdir {
|
||||||
|
common::branches(&revision.head(), wd)?
|
||||||
|
} else {
|
||||||
|
vec![]
|
||||||
|
};
|
||||||
|
let ahead_behind = common::ahead_behind(
|
||||||
|
stored.raw(),
|
||||||
|
*revision.head(),
|
||||||
|
*patch.target().head(stored)?,
|
||||||
|
)?;
|
||||||
|
let author = patch.author();
|
||||||
|
let author = term::format::Author::new(author.id(), profile);
|
||||||
|
let labels = patch.labels().map(|l| l.to_string()).collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let mut attrs = term::Table::<2, term::Line>::new(term::TableOptions {
|
||||||
|
spacing: 2,
|
||||||
|
..term::TableOptions::default()
|
||||||
|
});
|
||||||
|
attrs.push([
|
||||||
|
term::format::tertiary("Title".to_owned()).into(),
|
||||||
|
term::format::bold(patch.title().to_owned()).into(),
|
||||||
|
]);
|
||||||
|
attrs.push([
|
||||||
|
term::format::tertiary("Patch".to_owned()).into(),
|
||||||
|
term::format::default(id.to_string()).into(),
|
||||||
|
]);
|
||||||
|
attrs.push([
|
||||||
|
term::format::tertiary("Author".to_owned()).into(),
|
||||||
|
author.line(),
|
||||||
|
]);
|
||||||
|
if !labels.is_empty() {
|
||||||
|
attrs.push([
|
||||||
|
term::format::tertiary("Labels".to_owned()).into(),
|
||||||
|
term::format::secondary(labels.join(", ")).into(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
attrs.push([
|
||||||
|
term::format::tertiary("Head".to_owned()).into(),
|
||||||
|
term::format::secondary(revision.head().to_string()).into(),
|
||||||
|
]);
|
||||||
|
if verbose {
|
||||||
|
attrs.push([
|
||||||
|
term::format::tertiary("Base".to_owned()).into(),
|
||||||
|
term::format::secondary(revision.base().to_string()).into(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
if !branches.is_empty() {
|
||||||
|
attrs.push([
|
||||||
|
term::format::tertiary("Branches".to_owned()).into(),
|
||||||
|
term::format::yellow(branches.join(", ")).into(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
attrs.push([
|
||||||
|
term::format::tertiary("Commits".to_owned()).into(),
|
||||||
|
ahead_behind,
|
||||||
|
]);
|
||||||
|
attrs.push([
|
||||||
|
term::format::tertiary("Status".to_owned()).into(),
|
||||||
|
match state {
|
||||||
|
patch::State::Open { .. } => term::format::positive(state.to_string()),
|
||||||
|
patch::State::Draft => term::format::dim(state.to_string()),
|
||||||
|
patch::State::Archived => term::format::yellow(state.to_string()),
|
||||||
|
patch::State::Merged { .. } => term::format::primary(state.to_string()),
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let commits = patch_commit_lines(patch, stored)?;
|
||||||
|
let description = patch.description().trim();
|
||||||
|
let mut widget = term::VStack::default()
|
||||||
|
.border(Some(term::colors::FAINT))
|
||||||
|
.child(attrs)
|
||||||
|
.children(if !description.is_empty() {
|
||||||
|
vec![
|
||||||
|
term::Label::blank().boxed(),
|
||||||
|
term::textarea(description).boxed(),
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
vec![]
|
||||||
|
})
|
||||||
|
.divider()
|
||||||
|
.children(commits.into_iter().map(|l| l.boxed()))
|
||||||
|
.divider();
|
||||||
|
|
||||||
|
for line in timeline::timeline(profile, patch) {
|
||||||
|
widget.push(line);
|
||||||
|
}
|
||||||
|
widget.print();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn patch_commit_lines(
|
||||||
|
patch: &patch::Patch,
|
||||||
|
stored: &Repository,
|
||||||
|
) -> anyhow::Result<Vec<term::Line>> {
|
||||||
|
let (from, to) = patch.range()?;
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
|
||||||
|
for commit in patch_commits(stored.raw(), &from, &to)? {
|
||||||
|
lines.push(term::Line::spaced([
|
||||||
|
term::label(term::format::secondary::<String>(
|
||||||
|
term::format::oid(commit.id()).into(),
|
||||||
|
)),
|
||||||
|
term::label(term::format::default(
|
||||||
|
commit.summary().unwrap_or_default().to_owned(),
|
||||||
|
)),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
Ok(lines)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod test {
|
mod test {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,328 @@
|
||||||
|
use std::iter;
|
||||||
|
|
||||||
|
use radicle::cob;
|
||||||
|
use radicle::cob::patch::{Patch, Verdict};
|
||||||
|
use radicle::git;
|
||||||
|
use radicle::patch::{Merge, Review, Revision, RevisionId};
|
||||||
|
use radicle::profile::Profile;
|
||||||
|
|
||||||
|
use crate::terminal as term;
|
||||||
|
use crate::terminal::format::Author;
|
||||||
|
|
||||||
|
pub fn timeline<'a>(
|
||||||
|
profile: &'a Profile,
|
||||||
|
patch: &'a Patch,
|
||||||
|
) -> impl Iterator<Item = term::Line> + 'a {
|
||||||
|
Timeline::build(profile, patch).into_lines(profile)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The timeline of a [`Patch`].
|
||||||
|
///
|
||||||
|
/// A `Patch` will always have opened with a root revision and may
|
||||||
|
/// have a series of revisions that update the patch.
|
||||||
|
///
|
||||||
|
/// The function, [`timeline`], builds a `Timeline` and converts it
|
||||||
|
/// into a series of [`term::Line`]s.
|
||||||
|
struct Timeline<'a> {
|
||||||
|
opened: Opened<'a>,
|
||||||
|
revisions: Vec<RevisionEntry<'a>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Timeline<'a> {
|
||||||
|
fn build(profile: &Profile, patch: &'a Patch) -> Self {
|
||||||
|
let opened = Opened::from_patch(patch, profile);
|
||||||
|
let mut revisions = patch
|
||||||
|
.revisions()
|
||||||
|
.skip(1) // skip the root revision since it's handled in `Opened::from_patch`
|
||||||
|
.map(|(id, revision)| {
|
||||||
|
(
|
||||||
|
revision.timestamp(),
|
||||||
|
RevisionEntry::from_revision(patch, id, revision, profile),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
revisions.sort_by_key(|(t, _)| *t);
|
||||||
|
Timeline {
|
||||||
|
opened,
|
||||||
|
revisions: revisions.into_iter().map(|(_, e)| e).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn into_lines(self, profile: &'a Profile) -> impl Iterator<Item = term::Line> + 'a {
|
||||||
|
self.opened.into_lines(profile).chain(
|
||||||
|
self.revisions
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|r| r.into_lines(profile)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The root `Revision` of the `Patch`.
|
||||||
|
struct Opened<'a> {
|
||||||
|
/// The `Author` of the patch.
|
||||||
|
author: Author<'a>,
|
||||||
|
/// When the patch was created.
|
||||||
|
timestamp: cob::Timestamp,
|
||||||
|
/// The commit head of the `Revision`.
|
||||||
|
head: git::Oid,
|
||||||
|
/// Any updates performed on the root `Revision`.
|
||||||
|
updates: Vec<Update<'a>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Opened<'a> {
|
||||||
|
fn from_patch(patch: &'a Patch, profile: &Profile) -> Self {
|
||||||
|
let (root, revision) = patch.root();
|
||||||
|
let mut updates = Vec::new();
|
||||||
|
updates.extend(revision.reviews().map(|(_, review)| {
|
||||||
|
(
|
||||||
|
review.timestamp(),
|
||||||
|
Update::Reviewed {
|
||||||
|
review: review.clone(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
updates.extend(patch.merges().filter_map(|(_, merge)| {
|
||||||
|
if merge.revision == root {
|
||||||
|
Some((
|
||||||
|
merge.timestamp,
|
||||||
|
Update::Merged {
|
||||||
|
author: Author::new(&revision.author().id, profile),
|
||||||
|
merge: merge.clone(),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
updates.sort_by_key(|(t, _)| *t);
|
||||||
|
Opened {
|
||||||
|
author: Author::new(&patch.author().id, profile),
|
||||||
|
timestamp: patch.timestamp(),
|
||||||
|
head: revision.head(),
|
||||||
|
updates: updates.into_iter().map(|(_, up)| up).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn into_lines(self, profile: &'a Profile) -> impl Iterator<Item = term::Line> + 'a {
|
||||||
|
iter::once(
|
||||||
|
term::Line::spaced([
|
||||||
|
term::format::positive("●").into(),
|
||||||
|
term::format::default("opened by").into(),
|
||||||
|
])
|
||||||
|
.space()
|
||||||
|
.extend(self.author.line())
|
||||||
|
.space()
|
||||||
|
.extend(term::Line::spaced([
|
||||||
|
term::format::parens(term::format::secondary(term::format::oid(self.head))).into(),
|
||||||
|
term::format::dim(term::format::timestamp(self.timestamp)).into(),
|
||||||
|
])),
|
||||||
|
)
|
||||||
|
.chain(self.updates.into_iter().map(|up| {
|
||||||
|
term::Line::spaced([term::Label::space(), term::Label::from("└─ ")])
|
||||||
|
.extend(up.into_line(profile))
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A revision entry in the [`Timeline`].
|
||||||
|
enum RevisionEntry<'a> {
|
||||||
|
/// An `Updated` entry means that the original author of the
|
||||||
|
/// `Patch` created a new revision.
|
||||||
|
Updated {
|
||||||
|
/// When the `Revision` was created.
|
||||||
|
timestamp: cob::Timestamp,
|
||||||
|
/// The id of the `Revision`.
|
||||||
|
id: RevisionId,
|
||||||
|
/// The commit head of the `Revision`.
|
||||||
|
head: git::Oid,
|
||||||
|
/// All [`Update`]s that occurred on the `Revision`.
|
||||||
|
updates: Vec<Update<'a>>,
|
||||||
|
},
|
||||||
|
/// A `Revised` entry means that an author other than the original
|
||||||
|
/// author of the `Patch` created a new revision.
|
||||||
|
Revised {
|
||||||
|
/// The `Author` that created the `Revision` (that is not the
|
||||||
|
/// `Patch` author).
|
||||||
|
author: Author<'a>,
|
||||||
|
/// When the `Revision` was created.
|
||||||
|
timestamp: cob::Timestamp,
|
||||||
|
/// The id of the `Revision`.
|
||||||
|
id: RevisionId,
|
||||||
|
/// The commit head of the `Revision`.
|
||||||
|
head: git::Oid,
|
||||||
|
/// All [`Update`]s that occurred on the `Revision`.
|
||||||
|
updates: Vec<Update<'a>>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> RevisionEntry<'a> {
|
||||||
|
fn from_revision(
|
||||||
|
patch: &Patch,
|
||||||
|
id: RevisionId,
|
||||||
|
revision: &'a Revision,
|
||||||
|
profile: &Profile,
|
||||||
|
) -> Self {
|
||||||
|
let mut updates = Vec::new();
|
||||||
|
updates.extend(revision.reviews().map(|(_, review)| {
|
||||||
|
(
|
||||||
|
review.timestamp(),
|
||||||
|
Update::Reviewed {
|
||||||
|
review: review.clone(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
updates.extend(patch.merges().filter_map(|(_, merge)| {
|
||||||
|
if merge.revision == id {
|
||||||
|
Some((
|
||||||
|
merge.timestamp,
|
||||||
|
Update::Merged {
|
||||||
|
author: Author::new(&revision.author().id, profile),
|
||||||
|
merge: merge.clone(),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
updates.sort_by_key(|(t, _)| *t);
|
||||||
|
|
||||||
|
if revision.author() == patch.author() {
|
||||||
|
RevisionEntry::Updated {
|
||||||
|
timestamp: revision.timestamp(),
|
||||||
|
id,
|
||||||
|
head: revision.head(),
|
||||||
|
updates: updates.into_iter().map(|(_, up)| up).collect(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
RevisionEntry::Revised {
|
||||||
|
author: Author::new(&revision.author().id, profile),
|
||||||
|
timestamp: revision.timestamp(),
|
||||||
|
id,
|
||||||
|
head: revision.head(),
|
||||||
|
updates: updates.into_iter().map(|(_, up)| up).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn into_lines(self, profile: &'a Profile) -> Vec<term::Line> {
|
||||||
|
match self {
|
||||||
|
RevisionEntry::Updated {
|
||||||
|
timestamp,
|
||||||
|
id,
|
||||||
|
head,
|
||||||
|
updates,
|
||||||
|
} => Self::updated(profile, timestamp, id, head, updates).collect(),
|
||||||
|
RevisionEntry::Revised {
|
||||||
|
author,
|
||||||
|
timestamp,
|
||||||
|
id,
|
||||||
|
head,
|
||||||
|
updates,
|
||||||
|
} => Self::revised(profile, author, timestamp, id, head, updates).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn updated(
|
||||||
|
profile: &'a Profile,
|
||||||
|
timestamp: cob::Timestamp,
|
||||||
|
id: RevisionId,
|
||||||
|
head: git::Oid,
|
||||||
|
updates: Vec<Update<'a>>,
|
||||||
|
) -> impl Iterator<Item = term::Line> + 'a {
|
||||||
|
iter::once(term::Line::spaced([
|
||||||
|
term::format::tertiary("↑").into(),
|
||||||
|
term::format::default("updated to").into(),
|
||||||
|
term::format::dim(id).into(),
|
||||||
|
term::format::parens(term::format::secondary(term::format::oid(head))).into(),
|
||||||
|
term::format::dim(term::format::timestamp(timestamp)).into(),
|
||||||
|
]))
|
||||||
|
.chain(updates.into_iter().map(|up| {
|
||||||
|
term::Line::spaced([term::Label::space(), term::Label::from("└─ ")])
|
||||||
|
.extend(up.into_line(profile))
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn revised(
|
||||||
|
profile: &'a Profile,
|
||||||
|
author: Author<'a>,
|
||||||
|
timestamp: cob::Timestamp,
|
||||||
|
id: RevisionId,
|
||||||
|
head: git::Oid,
|
||||||
|
updates: Vec<Update<'a>>,
|
||||||
|
) -> impl Iterator<Item = term::Line> + 'a {
|
||||||
|
let (alias, nid) = author.labels();
|
||||||
|
iter::once(term::Line::spaced([
|
||||||
|
term::format::tertiary("*").into(),
|
||||||
|
term::format::default("revised by").into(),
|
||||||
|
alias,
|
||||||
|
nid,
|
||||||
|
term::format::default("in").into(),
|
||||||
|
term::format::dim(term::format::oid(id)).into(),
|
||||||
|
term::format::parens(term::format::secondary(term::format::oid(head))).into(),
|
||||||
|
term::format::dim(term::format::timestamp(timestamp)).into(),
|
||||||
|
]))
|
||||||
|
.chain(updates.into_iter().map(|up| {
|
||||||
|
term::Line::spaced([term::Label::space(), term::Label::from("└─ ")])
|
||||||
|
.extend(up.into_line(profile))
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An update in the [`Patch`]'s timeline.
|
||||||
|
enum Update<'a> {
|
||||||
|
/// A revision of the patch was reviewed.
|
||||||
|
Reviewed { review: Review },
|
||||||
|
/// A revision of the patch was merged.
|
||||||
|
Merged { author: Author<'a>, merge: Merge },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Update<'a> {
|
||||||
|
fn timestamp(&self) -> cob::Timestamp {
|
||||||
|
match self {
|
||||||
|
Update::Reviewed { review } => review.timestamp(),
|
||||||
|
Update::Merged { merge, .. } => merge.timestamp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn into_line(self, profile: &Profile) -> term::Line {
|
||||||
|
let timestamp = self.timestamp();
|
||||||
|
let mut line = match self {
|
||||||
|
Update::Reviewed { review } => {
|
||||||
|
let verdict = review.verdict();
|
||||||
|
let verdict_symbol = match verdict {
|
||||||
|
Some(Verdict::Accept) => term::format::positive("✓"),
|
||||||
|
Some(Verdict::Reject) => term::format::negative("✗"),
|
||||||
|
None => term::format::dim("⋄"),
|
||||||
|
};
|
||||||
|
let verdict_verb = match verdict {
|
||||||
|
Some(Verdict::Accept) => term::format::default("accepted"),
|
||||||
|
Some(Verdict::Reject) => term::format::default("rejected"),
|
||||||
|
None => term::format::default("reviewed"),
|
||||||
|
};
|
||||||
|
term::Line::spaced([
|
||||||
|
verdict_symbol.into(),
|
||||||
|
verdict_verb.into(),
|
||||||
|
term::format::default("by").into(),
|
||||||
|
])
|
||||||
|
.space()
|
||||||
|
.extend(Author::new(&review.author().id.into(), profile).line())
|
||||||
|
}
|
||||||
|
Update::Merged { author, merge } => {
|
||||||
|
let (alias, nid) = author.labels();
|
||||||
|
term::Line::spaced([
|
||||||
|
term::format::primary("✓").bold().into(),
|
||||||
|
term::format::default("merged by").into(),
|
||||||
|
alias,
|
||||||
|
nid,
|
||||||
|
term::format::default("at revision").into(),
|
||||||
|
term::format::dim(term::format::oid(merge.revision)).into(),
|
||||||
|
term::format::parens(term::format::secondary(term::format::oid(merge.commit)))
|
||||||
|
.into(),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
};
|
||||||
|
line.push(term::Label::space());
|
||||||
|
line.push(term::format::dim(term::format::timestamp(timestamp)));
|
||||||
|
line
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue