From bb06663d1c9388479c1638624e293a19fceaba22 Mon Sep 17 00:00:00 2001 From: cloudhead Date: Fri, 2 Feb 2024 16:42:37 +0100 Subject: [PATCH] cli: Organize patch functions better Move patch showing and timeline to a shared library so we can use it for `rad inbox`. --- radicle-cli/src/commands/patch.rs | 2 - radicle-cli/src/commands/patch/list.rs | 326 +---------------- radicle-cli/src/commands/patch/show.rs | 115 +----- radicle-cli/src/commands/patch/update.rs | 2 +- radicle-cli/src/terminal/patch.rs | 131 +++++++ .../{commands => terminal}/patch/common.rs | 0 radicle-cli/src/terminal/patch/timeline.rs | 328 ++++++++++++++++++ 7 files changed, 463 insertions(+), 441 deletions(-) rename radicle-cli/src/{commands => terminal}/patch/common.rs (100%) create mode 100644 radicle-cli/src/terminal/patch/timeline.rs diff --git a/radicle-cli/src/commands/patch.rs b/radicle-cli/src/commands/patch.rs index 863df98b..3fe7b47c 100644 --- a/radicle-cli/src/commands/patch.rs +++ b/radicle-cli/src/commands/patch.rs @@ -6,8 +6,6 @@ mod assign; mod checkout; #[path = "patch/comment.rs"] mod comment; -#[path = "patch/common.rs"] -mod common; #[path = "patch/delete.rs"] mod delete; #[path = "patch/diff.rs"] diff --git a/radicle-cli/src/commands/patch/list.rs b/radicle-cli/src/commands/patch/list.rs index b8891d23..16adfe20 100644 --- a/radicle-cli/src/commands/patch/list.rs +++ b/radicle-cli/src/commands/patch/list.rs @@ -1,11 +1,7 @@ use std::collections::BTreeSet; -use std::iter; -use radicle::cob; use radicle::cob::patch; -use radicle::cob::patch::{Patch, PatchId, Patches, Verdict}; -use radicle::git; -use radicle::patch::{Merge, Review, Revision, RevisionId}; +use radicle::cob::patch::{Patch, PatchId, Patches}; use radicle::prelude::*; use radicle::profile::Profile; use radicle::storage::git::Repository; @@ -14,8 +10,8 @@ use term::format::Author; use term::table::{Table, TableOptions}; use term::Element as _; -use super::common; use crate::terminal as term; +use crate::terminal::patch as common; /// List patches. pub fn run( @@ -128,321 +124,3 @@ pub fn row( .into(), ]) } - -pub fn timeline<'a>( - profile: &'a Profile, - patch: &'a Patch, -) -> impl Iterator + '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>, -} - -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::>(); - 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 + '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>, -} - -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 + '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>, - }, - /// 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>, - }, -} - -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 { - 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>, - ) -> impl Iterator + '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>, - ) -> impl Iterator + '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 - } -} diff --git a/radicle-cli/src/commands/patch/show.rs b/radicle-cli/src/commands/patch/show.rs index fef09291..6be728e4 100644 --- a/radicle-cli/src/commands/patch/show.rs +++ b/radicle-cli/src/commands/patch/show.rs @@ -3,10 +3,6 @@ use std::process; use radicle::cob::patch; use radicle::git; use radicle::storage::git::Repository; -use radicle_term::{ - table::{Table, TableOptions}, - textarea, Element, VStack, -}; use crate::terminal as term; @@ -27,32 +23,6 @@ fn show_patch_diff(patch: &patch::Patch, stored: &Repository) -> anyhow::Result< Ok(()) } -fn patch_commits(patch: &patch::Patch, stored: &Repository) -> anyhow::Result> { - 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::( - term::format::oid(commit.id()).into(), - )), - term::label(term::format::default( - commit.summary().unwrap_or_default().to_owned(), - )), - ])); - } - - Ok(lines) -} - pub fn run( patch_id: &PatchId, diff: bool, @@ -72,90 +42,7 @@ pub fn run( println!("{:#?}", patch); return Ok(()); } - - 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::>(); - - 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(); + term::patch::show(&patch, patch_id, verbose, stored, Some(workdir), profile)?; if diff { term::blank(); diff --git a/radicle-cli/src/commands/patch/update.rs b/radicle-cli/src/commands/patch/update.rs index 0acff228..62537705 100644 --- a/radicle-cli/src/commands/patch/update.rs +++ b/radicle-cli/src/commands/patch/update.rs @@ -3,8 +3,8 @@ use radicle::git; use radicle::prelude::*; use radicle::storage::git::Repository; -use super::common::*; use crate::terminal as term; +use crate::terminal::patch::*; /// Run patch update. pub fn run( diff --git a/radicle-cli/src/terminal/patch.rs b/radicle-cli/src/terminal/patch.rs index 05b6c4b5..2ee4f1a5 100644 --- a/radicle-cli/src/terminal/patch.rs +++ b/radicle-cli/src/terminal/patch.rs @@ -1,3 +1,6 @@ +mod common; +mod timeline; + use std::fmt; use std::fmt::Write; use std::io; @@ -8,10 +11,16 @@ use thiserror::Error; use radicle::cob; use radicle::cob::patch; 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::Element; +pub use common::*; + #[derive(Debug, Error)] pub enum Error { #[error(transparent)] @@ -329,6 +338,128 @@ pub fn print_commits_ahead_behind( 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::>(); + + 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> { + 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::( + term::format::oid(commit.id()).into(), + )), + term::label(term::format::default( + commit.summary().unwrap_or_default().to_owned(), + )), + ])); + } + Ok(lines) +} + #[cfg(test)] mod test { use super::*; diff --git a/radicle-cli/src/commands/patch/common.rs b/radicle-cli/src/terminal/patch/common.rs similarity index 100% rename from radicle-cli/src/commands/patch/common.rs rename to radicle-cli/src/terminal/patch/common.rs diff --git a/radicle-cli/src/terminal/patch/timeline.rs b/radicle-cli/src/terminal/patch/timeline.rs new file mode 100644 index 00000000..668e6e12 --- /dev/null +++ b/radicle-cli/src/terminal/patch/timeline.rs @@ -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 + '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>, +} + +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::>(); + 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 + '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>, +} + +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 + '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>, + }, + /// 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>, + }, +} + +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 { + 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>, + ) -> impl Iterator + '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>, + ) -> impl Iterator + '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 + } +}