Radish alpha
r
rad:z39mP9rQAaGmERfUMPULfPUi473tY
Radicle terminal user interface
Radicle
Git
bin/issue: Allow adding comments from list app
Merged did:key:z6MkgFq6...nBGz opened 4 months ago
6 files changed +375 -92 4944ab36 e2242112
modified bin/commands/issue.rs
@@ -9,14 +9,20 @@ use anyhow::anyhow;

use lazy_static::lazy_static;

+
use radicle::cob::thread::CommentId;
use radicle::identity::RepoId;
-
use radicle::issue;
+
use radicle::issue::IssueId;
+
use radicle::{issue, storage, Profile};

-
use radicle_cli::terminal;
-
use radicle_cli::terminal::{Args, Error, Help};
+
use radicle_cli as cli;
+

+
use cli::terminal::patch::Message;
+
use cli::terminal::Context;
+
use cli::terminal::{Args, Error, Help};

use crate::cob;
use crate::commands::tui_issue::common::IssueOperation;
+
use crate::terminal;
use crate::ui::TerminalInfo;

lazy_static! {
@@ -116,8 +122,9 @@ impl Args for Options {
                }
                Long("assigned") if op == OperationName::List => {
                    if let Ok(val) = parser.value() {
-
                        list_opts.filter =
-
                            list_opts.filter.with_assginee(terminal::args::did(&val)?);
+
                        list_opts.filter = list_opts
+
                            .filter
+
                            .with_assginee(cli::terminal::args::did(&val)?);
                    } else {
                        list_opts.filter = list_opts.filter.with_assgined(true);
                    }
@@ -125,7 +132,7 @@ impl Args for Options {

                Long("repo") => {
                    let val = parser.value()?;
-
                    let rid = terminal::args::rid(&val)?;
+
                    let rid = cli::terminal::args::rid(&val)?;

                    repo = Some(rid);
                }
@@ -172,7 +179,7 @@ impl Args for Options {
}

#[tokio::main]
-
pub async fn run(options: Options, ctx: impl terminal::Context) -> anyhow::Result<()> {
+
pub async fn run(options: Options, ctx: impl Context) -> anyhow::Result<()> {
    use radicle::storage::ReadStorage;

    let (_, rid) = radicle::rad::cwd()
@@ -182,48 +189,85 @@ pub async fn run(options: Options, ctx: impl terminal::Context) -> anyhow::Resul

    match options.op {
        Operation::List { opts } => {
-
            let profile = ctx.profile()?;
-
            let rid = options.repo.unwrap_or(rid);
-
            let repository = profile.storage.repository(rid)?;
-

            if let Err(err) = crate::log::enable() {
                println!("{err}");
            }
            log::info!("Starting issue listing interface in project {rid}..");

-
            let context = list::Context {
-
                profile,
-
                repository,
-
                filter: opts.filter.clone(),
-
            };
-

-
            let selection = list::App::new(context, terminal_info).run().await?;
-

-
            if opts.json {
-
                let selection = selection
-
                    .map(|o| serde_json::to_string(&o).unwrap_or_default())
-
                    .unwrap_or_default();
-

-
                log::info!("About to print to `stderr`: {selection}");
-
                log::info!("Exiting issue listing interface..");
-

-
                eprint!("{selection}");
-
            } else if let Some(selection) = selection {
-
                if let Some(operation) = selection.operation.clone() {
-
                    match operation {
-
                        IssueOperation::Show { id } => {
-
                            let _ = crate::terminal::run_rad(
-
                                Some("issue"),
-
                                &[OsString::from("show"), OsString::from(id.to_string())],
-
                            );
-
                        }
-
                        IssueOperation::Edit { id } => {
-
                            let _ = crate::terminal::run_rad(
-
                                Some("issue"),
-
                                &[OsString::from("edit"), OsString::from(id.to_string())],
-
                            );
+
            #[derive(Default)]
+
            struct PreviousState {
+
                issue_id: Option<IssueId>,
+
                comment_id: Option<CommentId>,
+
                search: Option<String>,
+
            }
+

+
            // Store issue and comment selection across app runs in order to
+
            // preselect them when re-running the app.
+
            let mut state = PreviousState::default();
+

+
            loop {
+
                let profile = ctx.profile()?;
+
                let rid = options.repo.unwrap_or(rid);
+
                let repository = profile.storage.repository(rid)?;
+

+
                let context = list::Context {
+
                    profile,
+
                    repository,
+
                    filter: opts.filter.clone(),
+
                    search: state.search.clone(),
+
                    issue: state.issue_id,
+
                    comment: state.comment_id,
+
                };
+

+
                let app = list::App::new(context, terminal_info.clone());
+
                let selection = app.run().await?;
+

+
                if opts.json {
+
                    let selection = selection
+
                        .map(|o| serde_json::to_string(&o).unwrap_or_default())
+
                        .unwrap_or_default();
+

+
                    log::info!("Exiting issue listing interface..");
+

+
                    eprint!("{selection}");
+
                } else if let Some(selection) = selection {
+
                    if let Some(operation) = selection.operation.clone() {
+
                        match operation {
+
                            IssueOperation::Show { id } => {
+
                                let _ = terminal::run_rad(
+
                                    Some("issue"),
+
                                    &["show".into(), id.to_string().into()],
+
                                );
+
                                break;
+
                            }
+
                            IssueOperation::Edit { id } => {
+
                                let _ = terminal::run_rad(
+
                                    Some("issue"),
+
                                    &["edit".into(), id.to_string().into(), "--quiet".into()],
+
                                );
+
                            }
+
                            IssueOperation::Comment {
+
                                id,
+
                                reply_to,
+
                                search,
+
                            } => {
+
                                let comment_id = comment(
+
                                    &app.context().profile,
+
                                    &app.context().repository,
+
                                    id,
+
                                    Message::Edit,
+
                                    reply_to,
+
                                )?;
+
                                state = PreviousState {
+
                                    issue_id: Some(id),
+
                                    comment_id: Some(comment_id),
+
                                    search: Some(search),
+
                                };
+
                            }
                        }
                    }
+
                } else {
+
                    break;
                }
            }
        }
@@ -238,8 +282,25 @@ pub async fn run(options: Options, ctx: impl terminal::Context) -> anyhow::Resul
    Ok(())
}

+
fn comment(
+
    profile: &Profile,
+
    repo: &storage::git::Repository,
+
    issue_id: IssueId,
+
    message: Message,
+
    reply_to: Option<CommentId>,
+
) -> Result<CommentId, anyhow::Error> {
+
    let mut issues = profile.issues_mut(repo)?;
+
    let signer = cli::terminal::signer(profile)?;
+
    let mut issue = issues.get_mut(&issue_id)?;
+
    let (root_comment_id, _) = issue.root();
+
    let body = terminal::prompt_comment(message, issue.thread(), reply_to, None)?;
+
    let comment_id = issue.comment(body, reply_to.unwrap_or(*root_comment_id), vec![], &signer)?;
+

+
    Ok(comment_id)
+
}
+

#[cfg(test)]
-
mod cli {
+
mod test {
    use radicle_cli::terminal::args::Error;
    use radicle_cli::terminal::Args;

modified bin/commands/issue/common.rs
@@ -1,11 +1,20 @@
use serde::Serialize;

-
use radicle::issue::IssueId;
+
use radicle::{cob::thread::CommentId, issue::IssueId};

/// The selected issue operation returned by the operation
/// selection widget.
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub enum IssueOperation {
-
    Edit { id: IssueId },
-
    Show { id: IssueId },
+
    Edit {
+
        id: IssueId,
+
    },
+
    Show {
+
        id: IssueId,
+
    },
+
    Comment {
+
        id: IssueId,
+
        reply_to: Option<CommentId>,
+
        search: String,
+
    },
}
modified bin/commands/issue/list.rs
@@ -53,6 +53,9 @@ pub struct Context {
    pub profile: Profile,
    pub repository: Repository,
    pub filter: issue::Filter,
+
    pub search: Option<String>,
+
    pub issue: Option<IssueId>,
+
    pub comment: Option<CommentId>,
}

pub struct App {
@@ -168,7 +171,8 @@ impl TryFrom<(&Context, &TerminalInfo)> for State {
        let settings = settings::Settings::default();

        let issues = issue::all(&context.profile, &context.repository)?;
-
        let search = BufferedValue::new(context.filter.to_string());
+
        let search =
+
            BufferedValue::new(context.search.clone().unwrap_or(context.filter.to_string()));
        let filter = IssueItemFilter::from_str(&search.read()).unwrap_or_default();

        let default_bundle = ThemeBundle::default();
@@ -186,38 +190,52 @@ impl TryFrom<(&Context, &TerminalInfo)> for State {
        };

        // Convert into UI items
-
        let mut items: Vec<_> = issues
+
        let mut issues: Vec<_> = issues
            .into_iter()
            .flat_map(|issue| IssueItem::new(&context.profile, issue).ok())
            .collect();

-
        items.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
+
        issues.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));

-
        // Pre-select first comment
-
        let selected_comments: HashMap<_, _> = items
+
        // Pre-select comments per issue. If a comment to pre-select is given,
+
        // find identifier path needed for selection. Select root comment
+
        // otherwise.
+
        let selected_comments: HashMap<_, _> = issues
            .iter()
-
            .map(|item| {
-
                let id = item.id;
-
                let comm = item
-
                    .root_comments()
-
                    .first()
-
                    .map(|c| vec![c.id])
-
                    .unwrap_or_default();
-

-
                (id, comm)
+
            .map(|issue| {
+
                let comment_ids = match context.comment {
+
                    Some(comment_id) if issue.has_comment(&comment_id) => {
+
                        issue.path_to_comment(&comment_id).unwrap_or_default()
+
                    }
+
                    _ => issue
+
                        .root_comments()
+
                        .first()
+
                        .map(|c| vec![c.id])
+
                        .unwrap_or_default(),
+
                };
+
                (issue.id, comment_ids)
            })
            .collect();

+
        let browser = BrowserState::build(issues, context.issue, filter, search);
+
        let preview = PreviewState {
+
            show: true,
+
            issue: browser.selected_item().cloned(),
+
            selected_comments,
+
            comment: TextViewState::default(),
+
        };
+

+
        let section = if context.comment.is_some() {
+
            Some(Section::Details)
+
        } else {
+
            Some(Section::Browser)
+
        };
+

        Ok(Self {
            pages: PageStack::new(vec![AppPage::Browser]),
-
            browser: BrowserState::build(items.clone(), filter, search),
-
            preview: PreviewState {
-
                show: true,
-
                issue: items.first().cloned(),
-
                selected_comments,
-
                comment: TextViewState::default(),
-
            },
-
            section: Some(Section::Browser),
+
            browser,
+
            preview,
+
            section,
            help: HelpState {
                text: TextViewState::default().content(help_text()),
            },
@@ -230,6 +248,7 @@ impl TryFrom<(&Context, &TerminalInfo)> for State {
pub enum RequestedIssueOperation {
    Edit,
    Show,
+
    Reply,
}

#[derive(Clone, Debug)]
@@ -271,13 +290,21 @@ impl store::Update<Message> for State {
        match message {
            Message::Quit => Some(Exit { value: None }),
            Message::Exit { operation } => {
-
                let selected = self.browser.selected_item();
+
                let issue = self.browser.selected_item();
+
                let comment = self.preview.selected_comment();
                let operation = match operation {
                    Some(RequestedIssueOperation::Show) => {
-
                        selected.map(|issue| IssueOperation::Show { id: issue.id })
+
                        issue.map(|issue| IssueOperation::Show { id: issue.id })
                    }
                    Some(RequestedIssueOperation::Edit) => {
-
                        selected.map(|issue| IssueOperation::Edit { id: issue.id })
+
                        issue.map(|issue| IssueOperation::Edit { id: issue.id })
+
                    }
+
                    Some(RequestedIssueOperation::Reply) => {
+
                        issue.map(|issue| IssueOperation::Comment {
+
                            id: issue.id,
+
                            reply_to: comment.map(|c| c.id),
+
                            search: self.browser.read_search(),
+
                        })
                    }
                    _ => None,
                };
@@ -387,6 +414,10 @@ impl App {
        )
        .await
    }
+

+
    pub fn context(&self) -> &Context {
+
        &self.context
+
    }
}

fn browser_page(channel: &Channel<Message>) -> Widget<State, Message> {
@@ -398,9 +429,16 @@ fn browser_page(channel: &Channel<Message>) -> Widget<State, Message> {
            let shortcuts = if state.browser.is_search_shown() {
                vec![("esc", "cancel"), ("enter", "apply")]
            } else {
-
                let mut shortcuts = vec![("enter", "show"), ("e", "edit")];
+
                let mut shortcuts = vec![];
                if state.section == Some(Section::Browser) {
-
                    shortcuts = [shortcuts, [("/", "search")].to_vec()].concat()
+
                    shortcuts = [
+
                        shortcuts,
+
                        [("enter", "show"), ("e", "edit"), ("/", "search")].to_vec(),
+
                    ]
+
                    .concat()
+
                }
+
                if state.section != Some(Section::Browser) {
+
                    shortcuts = [shortcuts, [("c", "reply")].to_vec()].concat()
                }
                [shortcuts, [("p", "toggle preview"), ("?", "help")].to_vec()].concat()
            };
@@ -453,12 +491,6 @@ fn browser_page(channel: &Channel<Message>) -> Widget<State, Message> {
                        Key::Char('q') | Key::Ctrl('c') => Some(Message::Quit),
                        Key::Char('p') => Some(Message::TogglePreview),
                        Key::Char('?') => Some(Message::OpenHelp),
-
                        Key::Enter => Some(Message::Exit {
-
                            operation: Some(RequestedIssueOperation::Show),
-
                        }),
-
                        Key::Char('e') => Some(Message::Exit {
-
                            operation: Some(RequestedIssueOperation::Edit),
-
                        }),
                        _ => None,
                    }
                } else {
@@ -482,6 +514,21 @@ fn browser(channel: &Channel<Message>) -> Widget<State, Message> {
    Browser::new(tx.clone())
        .to_widget(tx.clone())
        .on_update(|state| BrowserProps::from(state).to_boxed_any().into())
+
        .on_event(|event, _, _| {
+
            if let Event::Key(key) = event {
+
                match key {
+
                    Key::Enter => Some(Message::Exit {
+
                        operation: Some(RequestedIssueOperation::Show),
+
                    }),
+
                    Key::Char('e') => Some(Message::Exit {
+
                        operation: Some(RequestedIssueOperation::Edit),
+
                    }),
+
                    _ => None,
+
                }
+
            } else {
+
                None
+
            }
+
        })
}

fn issue(channel: &Channel<Message>) -> Widget<State, Message> {
@@ -521,14 +568,6 @@ fn comment_tree(channel: &Channel<Message>) -> Widget<State, Message> {

    Tree::<State, Message, CommentItem, String>::default()
        .to_widget(tx.clone())
-
        .on_event(|_, s, _| {
-
            Some(Message::SelectComment {
-
                selected: s.and_then(|s| {
-
                    s.unwrap_tree()
-
                        .map(|tree| tree.iter().map(|id| Oid::from_str(id).unwrap()).collect())
-
                }),
-
            })
-
        })
        .on_update(|state| {
            let root = &state.preview.root_comments();
            let opened = &state.preview.opened_comments();
@@ -542,6 +581,17 @@ fn comment_tree(channel: &Channel<Message>) -> Widget<State, Message> {
                .to_boxed_any()
                .into()
        })
+
        .on_event(|event, s, _| match event {
+
            Event::Key(Key::Char('c')) => Some(Message::Exit {
+
                operation: Some(RequestedIssueOperation::Reply),
+
            }),
+
            _ => Some(Message::SelectComment {
+
                selected: s.and_then(|s| {
+
                    s.unwrap_tree()
+
                        .map(|tree| tree.iter().map(|id| Oid::from_str(id).unwrap()).collect())
+
                }),
+
            }),
+
        })
}

fn comment(channel: &Channel<Message>) -> Widget<State, Message> {
@@ -593,6 +643,17 @@ fn comment(channel: &Channel<Message>) -> Widget<State, Message> {
                .to_boxed_any()
                .into()
        })
+
        .on_event(|event, s, _| match event {
+
            Event::Key(Key::Char('c')) => Some(Message::Exit {
+
                operation: Some(RequestedIssueOperation::Reply),
+
            }),
+
            _ => Some(Message::SelectComment {
+
                selected: s.and_then(|s| {
+
                    s.unwrap_tree()
+
                        .map(|tree| tree.iter().map(|id| Oid::from_str(id).unwrap()).collect())
+
                }),
+
            }),
+
        })
}

fn help_page(channel: &Channel<Message>) -> Widget<State, Message> {
@@ -686,6 +747,7 @@ fn help_text() -> String {

`Enter`:    Show issue
`e`:        Edit issue
+
`c`:        Reply to comment
`p`:        Toggle issue preview
`/`:        Search
`?`:        Show help
modified bin/terminal.rs
@@ -3,9 +3,13 @@ use std::process;

use thiserror::Error;

+
use radicle::cob::thread;
+
use radicle::git;
+

use radicle_cli::terminal;
use radicle_cli::terminal::args;
use radicle_cli::terminal::io;
+
use radicle_cli::terminal::patch::Message;
use radicle_cli::terminal::{Args, Command, DefaultContext, Error, Help};

#[derive(Error, Debug)]
@@ -97,3 +101,94 @@ where
        }
    }
}
+

+
/// Get a comment from the user.
+
pub fn prompt_comment(
+
    message: Message,
+
    thread: &thread::Thread,
+
    mut reply_to: Option<git::Oid>,
+
    edit: Option<&str>,
+
) -> anyhow::Result<String> {
+
    let (chase, missing) = {
+
        let mut chase = Vec::with_capacity(thread.len());
+
        let mut missing = None;
+
        while let Some(id) = reply_to {
+
            if let Some(comment) = thread.comment(&id) {
+
                chase.push(comment);
+
                reply_to = comment.reply_to();
+
            } else {
+
                missing = reply_to;
+
                break;
+
            }
+
        }
+

+
        (chase, missing)
+
    };
+

+
    let quotes = if chase.is_empty() {
+
        ""
+
    } else {
+
        "Quotes (lines starting with '>') will be preserved. Please remove those that you do not intend to keep.\n"
+
    };
+

+
    let mut buffer = terminal::format::html::commented(format!("HTML comments, such as this one, are deleted before posting.\n{quotes}Saving an empty file aborts the operation.").as_str());
+
    buffer.push('\n');
+

+
    for comment in chase.iter().rev() {
+
        buffer.reserve(2);
+
        buffer.push('\n');
+
        comment_quoted(comment, &mut buffer);
+
    }
+

+
    if let Some(id) = missing {
+
        buffer.push('\n');
+
        buffer.push_str(
+
            terminal::format::html::commented(
+
                format!("The comment with ID {id} that was replied to could not be found.")
+
                    .as_str(),
+
            )
+
            .as_str(),
+
        );
+
    }
+

+
    if let Some(edit) = edit {
+
        if !chase.is_empty() {
+
            buffer.push_str(
+
                "\n<!-- The contents of the comment you are editing follow below this line. -->\n",
+
            );
+
        }
+

+
        buffer.reserve(2 + edit.len());
+
        buffer.push('\n');
+
        buffer.push_str(edit);
+
    }
+

+
    let body = message.get(&buffer)?;
+
    if body.is_empty() {
+
        anyhow::bail!("aborting operation due to empty comment");
+
    }
+

+
    Ok(body)
+
}
+

+
fn comment_quoted(comment: &thread::Comment, buffer: &mut String) {
+
    let body = comment.body();
+
    let lines = body.lines();
+
    let hint = {
+
        let (lower, upper) = lines.size_hint();
+
        upper.unwrap_or(lower)
+
    };
+

+
    buffer.push_str(format!("{} wrote:\n", comment.author()).as_str());
+
    buffer.reserve(body.len() + hint * 2);
+

+
    for line in lines {
+
        buffer.push('>');
+
        if !line.is_empty() {
+
            buffer.push(' ');
+
        }
+

+
        buffer.push_str(line);
+
        buffer.push('\n');
+
    }
+
}
modified bin/ui/items.rs
@@ -14,7 +14,7 @@ use nom::{IResult, Parser};
use ansi_to_tui::IntoText;

use radicle::cob::thread::{Comment, CommentId};
-
use radicle::cob::{CodeLocation, CodeRange, EntryId, Label, Timestamp};
+
use radicle::cob::{CodeLocation, CodeRange, EntryId, Label, ObjectId, Timestamp};
use radicle::git::Oid;
use radicle::identity::Did;
use radicle::issue;
@@ -131,6 +131,10 @@ pub mod filter {
    }
}

+
pub trait HasId {
+
    fn id(&self) -> ObjectId;
+
}
+

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AuthorItem {
    pub nid: Option<NodeId>,
@@ -208,6 +212,22 @@ impl IssueItem {
            .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 IssueItem {
@@ -257,6 +277,12 @@ impl ToRow<8> for IssueItem {
    }
}

+
impl HasId for IssueItem {
+
    fn id(&self) -> ObjectId {
+
        self.id
+
    }
+
}
+

#[derive(Clone, Default, Debug, Eq, PartialEq)]
pub struct IssueItemFilter {
    state: Option<issue::State>,
@@ -680,6 +706,23 @@ impl CommentItem {

        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 {
modified bin/ui/rm.rs
@@ -1,6 +1,7 @@
use std::marker::PhantomData;
use std::str::FromStr;

+
use radicle::cob::ObjectId;
use radicle::issue::{self, CloseReason};
use ratatui::layout::{Constraint, Layout};
use ratatui::style::Stylize;
@@ -18,6 +19,7 @@ use super::format;
use super::items::IssueItem;

use crate::ui::items::filter::Filter;
+
use crate::ui::items::HasId;

/// A `BrowserState` represents the internal state of a browser widget.
/// A browser widget would consist of 2 child widgets: a list of items and a
@@ -50,11 +52,22 @@ where

impl<I, F> BrowserState<I, F>
where
-
    I: Clone,
+
    I: Clone + HasId,
    F: Filter<I> + Default + FromStr,
{
-
    pub fn build(items: Vec<I>, filter: F, search: BufferedValue<String>) -> Self {
-
        let selected = items.first().map(|_| 0);
+
    pub fn build(
+
        items: Vec<I>,
+
        selected: Option<ObjectId>,
+
        filter: F,
+
        search: BufferedValue<String>,
+
    ) -> Self {
+
        let selected = match selected {
+
            Some(id) => items
+
                .iter()
+
                .filter(|item| filter.matches(item))
+
                .position(|item| item.id() == id),
+
            None => items.first().map(|_| 0),
+
        };

        Self {
            items,
@@ -72,7 +85,7 @@ where
    pub fn items_ref(&self) -> Vec<&I> {
        self.items
            .iter()
-
            .filter(|patch| self.filter.matches(patch))
+
            .filter(|item| self.filter.matches(item))
            .collect()
    }