Radish alpha
r
rad:z39mP9rQAaGmERfUMPULfPUi473tY
Radicle terminal user interface
Radicle
Git
bin: Various improvements across all apps
Merged did:key:z6MkgFq6...nBGz opened 3 months ago
8 files changed +364 -225 b8d8e861 4eb065c1
modified bin/commands/inbox.rs
@@ -5,12 +5,13 @@ use std::ffi::OsString;

use anyhow::anyhow;

+
use radicle::node::notifications::NotificationId;
use radicle::storage::{HasRepoId, ReadRepository};

use radicle_cli::terminal::{Args, Error, Help};

use crate::terminal;
-
use crate::ui::items::notification::filter::{NotificationFilter, SortBy};
+
use crate::ui::items::notification::filter::SortBy;

use self::list::{InboxOperation, RepositoryMode};

@@ -58,7 +59,6 @@ pub enum OperationName {
#[derive(Debug, Default, Clone, PartialEq)]
pub struct ListOptions {
    mode: RepositoryMode,
-
    filter: NotificationFilter,
    sort_by: SortBy,
    json: bool,
}
@@ -179,11 +179,18 @@ pub async fn run(options: Options, ctx: impl radicle_cli::terminal::Context) ->

    match options.op {
        Operation::List { opts } => {
+
            #[derive(Default)]
+
            struct PreviousState {
+
                notif_id: Option<NotificationId>,
+
                search: Option<String>,
+
            }
+

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

+
            let mut state = PreviousState::default();
            loop {
                let profile = ctx.profile()?;
                let repository = profile.storage.repository(rid)?;
@@ -193,8 +200,9 @@ pub async fn run(options: Options, ctx: impl radicle_cli::terminal::Context) ->
                    project: repository.identity_doc()?.project()?,
                    rid: repository.rid(),
                    mode: opts.mode.clone(),
-
                    filter: opts.filter.clone(),
+
                    search: state.search.clone(),
                    sort_by: opts.sort_by,
+
                    _notif_id: state.notif_id,
                };

                let app = list::Tui::new(context);
@@ -212,14 +220,21 @@ pub async fn run(options: Options, ctx: impl radicle_cli::terminal::Context) ->
                } else if let Some(selection) = selection {
                    if let Some(operation) = selection.operation.clone() {
                        match operation {
-
                            InboxOperation::Show { id } => {
+
                            InboxOperation::Show { id, search } => {
+
                                state = PreviousState {
+
                                    notif_id: Some(id),
+
                                    search: Some(search),
+
                                };
                                terminal::run_rad(
                                    Some("inbox"),
                                    &["show".into(), id.to_string().into()],
                                )?;
-
                                break;
                            }
-
                            InboxOperation::Clear { id } => {
+
                            InboxOperation::Clear { id, search } => {
+
                                state = PreviousState {
+
                                    notif_id: Some(id),
+
                                    search: Some(search),
+
                                };
                                terminal::run_rad(
                                    Some("inbox"),
                                    &["clear".into(), id.to_string().into()],
modified bin/commands/inbox/list.rs
@@ -48,8 +48,8 @@ pub enum RepositoryMode {
/// selection widget.
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub enum InboxOperation {
-
    Show { id: NotificationId },
-
    Clear { id: NotificationId },
+
    Show { id: NotificationId, search: String },
+
    Clear { id: NotificationId, search: String },
}

type Selection = tui::Selection<InboxOperation>;
@@ -85,8 +85,9 @@ pub struct Context {
    pub project: Project,
    pub rid: RepoId,
    pub mode: RepositoryMode,
-
    pub filter: NotificationFilter,
    pub sort_by: SortBy,
+
    pub _notif_id: Option<NotificationId>,
+
    pub search: Option<String>,
}

pub struct Tui {
@@ -174,12 +175,17 @@ impl TryFrom<&Context> for App {
    type Error = anyhow::Error;

    fn try_from(context: &Context) -> Result<Self, Self::Error> {
-
        let search = {
-
            let raw = context.filter.to_string();
-
            raw.trim().to_string()
+
        let search = context.search.as_ref().map(|s| s.trim().to_string());
+
        let (search, filter) = match search {
+
            Some(search) => (
+
                search.clone(),
+
                NotificationFilter::from_str(search.trim()).unwrap_or(NotificationFilter::Invalid),
+
            ),
+
            None => {
+
                let filter = NotificationFilter::default();
+
                (filter.to_string().trim().to_string(), filter)
+
            }
        };
-
        let filter = NotificationFilter::from_str(&context.filter.to_string())
-
            .unwrap_or(NotificationFilter::Invalid);

        Ok(App {
            context: Arc::new(Mutex::new(context.clone())),
@@ -189,8 +195,8 @@ impl TryFrom<&Context> for App {
                main_group: ContainerState::new(3, Some(0)),
                patches: TableState::new(Some(0)),
                search: BufferedValue::new(TextEditState {
-
                    text: search.clone(),
-
                    cursor: search.len(),
+
                    text: search.to_string(),
+
                    cursor: search.chars().count(),
                }),
                show_search: false,
                help: TextViewState::new(Position::default()),
@@ -319,10 +325,8 @@ impl Show<Message> for App {
                        },
                    );

-
                    if !show_search {
-
                        if ui.has_input(|key| key == Key::Char('?')) {
-
                            ui.send_message(Message::Changed(Change::Page { page: Page::Help }));
-
                        }
+
                    if !show_search && ui.has_input(|key| key == Key::Char('?')) {
+
                        ui.send_message(Message::Changed(Change::Page { page: Page::Help }));
                    }
                }

@@ -423,6 +427,7 @@ impl App {
                ui.send_message(Message::Exit {
                    operation: Some(InboxOperation::Show {
                        id: notification.id,
+
                        search: self.state.search.read().text,
                    }),
                });
            }
@@ -430,6 +435,7 @@ impl App {
                ui.send_message(Message::Exit {
                    operation: Some(InboxOperation::Clear {
                        id: notification.id,
+
                        search: self.state.search.read().text,
                    }),
                });
            }
@@ -556,7 +562,8 @@ impl App {
                    Column::new(
                        Span::raw(format!(" {search} "))
                            .into_left_aligned_line()
-
                            .style(ui.theme().bar_on_black_style),
+
                            .style(ui.theme().bar_on_black_style)
+
                            .cyan(),
                        Constraint::Fill(1),
                    ),
                    Column::new(
modified bin/commands/issue.rs
@@ -292,22 +292,22 @@ pub async fn run(options: Options, ctx: impl Context) -> anyhow::Result<()> {
                } else if let Some(selection) = selection {
                    if let Some(operation) = selection.operation.clone() {
                        match operation {
-
                            IssueOperation::Show { id } => {
+
                            IssueOperation::Show { args } => {
+
                                state = PreviousState {
+
                                    issue_id: Some(args.id()),
+
                                    comment_id: None,
+
                                    search: Some(args.search()),
+
                                };
                                terminal::run_rad(
                                    Some("issue"),
-
                                    &["show".into(), id.to_string().into()],
+
                                    &["show".into(), args.id().to_string().into()],
                                )?;
-
                                break;
                            }
-
                            IssueOperation::Edit {
-
                                id,
-
                                comment_id,
-
                                search,
-
                            } => {
+
                            IssueOperation::Edit { args, comment_id } => {
                                state = PreviousState {
-
                                    issue_id: Some(id),
+
                                    issue_id: Some(args.id()),
                                    comment_id,
-
                                    search: Some(search),
+
                                    search: Some(args.search()),
                                };
                                match comment_id {
                                    Some(comment_id) => {
@@ -315,7 +315,7 @@ pub async fn run(options: Options, ctx: impl Context) -> anyhow::Result<()> {
                                            Some("issue"),
                                            &[
                                                "comment".into(),
-
                                                id.to_string().into(),
+
                                                args.id().to_string().into(),
                                                "--edit".into(),
                                                comment_id.to_string().into(),
                                            ],
@@ -324,60 +324,68 @@ pub async fn run(options: Options, ctx: impl Context) -> anyhow::Result<()> {
                                    _ => {
                                        terminal::run_rad(
                                            Some("issue"),
-
                                            &["edit".into(), id.to_string().into()],
+
                                            &["edit".into(), args.id().to_string().into()],
                                        )?;
                                    }
                                }
                            }
-
                            IssueOperation::Solve { id, search } => {
+
                            IssueOperation::Solve { args } => {
                                state = PreviousState {
-
                                    issue_id: Some(id),
+
                                    issue_id: Some(args.id()),
                                    comment_id: None,
-
                                    search: Some(search),
+
                                    search: Some(args.search()),
                                };
                                terminal::run_rad(
                                    Some("issue"),
-
                                    &["state".into(), id.to_string().into(), "--solved".into()],
+
                                    &[
+
                                        "state".into(),
+
                                        args.id().to_string().into(),
+
                                        "--solved".into(),
+
                                    ],
                                )?;
                            }
-
                            IssueOperation::Close { id, search } => {
+
                            IssueOperation::Close { args } => {
                                state = PreviousState {
-
                                    issue_id: Some(id),
+
                                    issue_id: Some(args.id()),
                                    comment_id: None,
-
                                    search: Some(search),
+
                                    search: Some(args.search()),
                                };
                                terminal::run_rad(
                                    Some("issue"),
-
                                    &["state".into(), id.to_string().into(), "--closed".into()],
+
                                    &[
+
                                        "state".into(),
+
                                        args.id().to_string().into(),
+
                                        "--closed".into(),
+
                                    ],
                                )?;
                            }
-
                            IssueOperation::Reopen { id, search } => {
+
                            IssueOperation::Reopen { args } => {
                                state = PreviousState {
-
                                    issue_id: Some(id),
+
                                    issue_id: Some(args.id()),
                                    comment_id: None,
-
                                    search: Some(search),
+
                                    search: Some(args.search()),
                                };
                                terminal::run_rad(
                                    Some("issue"),
-
                                    &["state".into(), id.to_string().into(), "--open".into()],
+
                                    &[
+
                                        "state".into(),
+
                                        args.id().to_string().into(),
+
                                        "--open".into(),
+
                                    ],
                                )?;
                            }
-
                            IssueOperation::Comment {
-
                                id,
-
                                reply_to,
-
                                search,
-
                            } => {
+
                            IssueOperation::Comment { args, reply_to } => {
                                let comment_id = comment(
                                    &tui.context().profile,
                                    &tui.context().repository,
-
                                    id,
+
                                    args.id(),
                                    Message::Edit,
                                    reply_to,
                                )?;
                                state = PreviousState {
-
                                    issue_id: Some(id),
+
                                    issue_id: Some(args.id()),
                                    comment_id: Some(comment_id),
-
                                    search: Some(search),
+
                                    search: Some(args.search()),
                                };
                            }
                        }
modified bin/commands/issue/list.rs
@@ -2,9 +2,10 @@ use std::collections::{HashMap, HashSet};
use std::str::FromStr;
use std::sync::{Arc, Mutex};

+
use radicle::cob::ObjectId;
use serde::Serialize;

-
use anyhow::{bail, Result};
+
use anyhow::{anyhow, bail, Result};

use ratatui::layout::{Alignment, Constraint, Layout, Position};
use ratatui::style::Stylize;
@@ -40,34 +41,61 @@ use crate::ui::{format, TerminalInfo};

type Selection = tui::Selection<IssueOperation>;

+
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
+
pub struct OperationArguments {
+
    id: IssueId,
+
    search: String,
+
}
+

+
impl OperationArguments {
+
    pub fn id(&self) -> ObjectId {
+
        self.id
+
    }
+

+
    pub fn search(&self) -> String {
+
        self.search.clone()
+
    }
+
}
+

+
impl TryFrom<(&Vec<Issue>, &AppState)> for OperationArguments {
+
    type Error = anyhow::Error;
+

+
    fn try_from(value: (&Vec<Issue>, &AppState)) -> Result<Self> {
+
        let (issues, state) = value;
+
        let selected = state.browser.selected();
+
        let id = selected
+
            .and_then(|s| issues.get(s))
+
            .ok_or(anyhow!("No issue selected"))?
+
            .id;
+
        let search = state.browser.search.read().text;
+

+
        Ok(Self { id, search })
+
    }
+
}
+

/// The selected issue operation returned by the operation
/// selection widget.
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub enum IssueOperation {
    Edit {
-
        id: IssueId,
+
        args: OperationArguments,
        comment_id: Option<CommentId>,
-
        search: String,
    },
    Show {
-
        id: IssueId,
+
        args: OperationArguments,
    },
    Close {
-
        id: IssueId,
-
        search: String,
+
        args: OperationArguments,
    },
    Solve {
-
        id: IssueId,
-
        search: String,
+
        args: OperationArguments,
    },
    Reopen {
-
        id: IssueId,
-
        search: String,
+
        args: OperationArguments,
    },
    Comment {
-
        id: IssueId,
+
        args: OperationArguments,
        reply_to: Option<CommentId>,
-
        search: String,
    },
}

@@ -131,7 +159,7 @@ impl Tui {
    }
}

-
mod state {
+
mod args {
    use super::*;
    use crate::ui::items::CommentItem;

@@ -238,7 +266,7 @@ mod state {

#[derive(Clone, Debug)]
pub enum Change {
-
    Page { page: state::Page },
+
    Page { page: args::Page },
    Section { state: ContainerState },
    Issue { state: TableState },
    Comment { state: TreeState<String> },
@@ -258,10 +286,10 @@ pub enum Message {

#[derive(Clone, Debug)]
pub struct AppState {
-
    page: state::Page,
+
    page: args::Page,
    sections: ContainerState,
-
    browser: state::Browser,
-
    preview: state::Preview,
+
    browser: args::Browser,
+
    preview: args::Preview,
    help: TextViewState,
    filter: IssueFilter,
}
@@ -280,9 +308,17 @@ impl TryFrom<(&Context, &TerminalInfo)> for App {
        let settings = settings::Settings::default();

        let issues = issue::all(&context.profile, &context.repository)?;
-
        let search =
-
            BufferedValue::new(context.search.clone().unwrap_or(context.filter.to_string()));
-
        let filter = IssueFilter::from_str(&search.read()).unwrap_or_default();
+
        let search = context.search.as_ref().map(|s| s.trim().to_string());
+
        let (search, filter) = match search {
+
            Some(search) => (
+
                search.clone(),
+
                IssueFilter::from_str(search.trim()).unwrap_or(IssueFilter::Invalid),
+
            ),
+
            None => {
+
                let filter = context.filter.clone();
+
                (filter.to_string().trim().to_string(), filter)
+
            }
+
        };

        let default_bundle = ThemeBundle::default();
        let theme_bundle = settings.theme.active_bundle().unwrap_or(&default_bundle);
@@ -326,7 +362,7 @@ impl TryFrom<(&Context, &TerminalInfo)> for App {
            })
            .collect();

-
        let browser = state::Browser {
+
        let browser = args::Browser {
            issues: TableState::new(Some(
                context
                    .issue
@@ -339,13 +375,13 @@ impl TryFrom<(&Context, &TerminalInfo)> for App {
                    .unwrap_or(0),
            )),
            search: BufferedValue::new(TextEditState {
-
                text: search.read().clone(),
-
                cursor: search.read().len(),
+
                text: search.clone(),
+
                cursor: search.chars().count(),
            }),
            show_search: false,
        };

-
        let preview = state::Preview {
+
        let preview = args::Preview {
            show: true,
            issue: browser
                .selected()
@@ -363,15 +399,15 @@ impl TryFrom<(&Context, &TerminalInfo)> for App {
        };

        let section = if context.comment.is_some() {
-
            state::Section::Issue
+
            args::Section::Issue
        } else {
-
            state::Section::Browser
+
            args::Section::Browser
        };

        Ok(Self {
            issues: Arc::new(Mutex::new(issues)),
            state: AppState {
-
                page: state::Page::Main,
+
                page: args::Page::Main,
                sections: ContainerState::new(3, Some(section as usize)),
                browser,
                preview,
@@ -510,7 +546,7 @@ impl Show<Message> for App {
    fn show(&self, ctx: &ui::Context<Message>, frame: &mut Frame) -> Result<()> {
        Window::default().show(ctx, |ui| {
            match self.state.page.clone() {
-
                state::Page::Main => {
+
                args::Page::Main => {
                    let show_search = self.state.browser.show_search;
                    let page_focus = if show_search { Some(1) } else { Some(0) };

@@ -546,7 +582,7 @@ impl Show<Message> for App {
                                }),
                                Some(0),
                                |ui| {
-
                                    use state::Section;
+
                                    use args::Section;
                                    if let Some(section) = focus {
                                        match Section::try_from(section).unwrap_or_default() {
                                            Section::Browser => {
@@ -578,12 +614,12 @@ impl Show<Message> for App {
                        }
                        if ui.has_input(|key| key == Key::Char('?')) {
                            ui.send_message(Message::Changed(Change::Page {
-
                                page: state::Page::Help,
+
                                page: args::Page::Help,
                            }));
                        }
                    }
                }
-
                state::Page::Help => {
+
                args::Page::Help => {
                    let layout = Layout::vertical([
                        Constraint::Length(3),
                        Constraint::Fill(1),
@@ -600,7 +636,7 @@ impl Show<Message> for App {

                    if ui.has_input(|key| key == Key::Char('?')) {
                        ui.send_message(Message::Changed(Change::Page {
-
                            page: state::Page::Main,
+
                            page: args::Page::Main,
                        }));
                    }
                }
@@ -671,47 +707,37 @@ impl App {
            }));
        }

-
        if let Some(issue) = selected.and_then(|s| issues.get(s)) {
+
        if let Ok(args) = OperationArguments::try_from((&issues, &self.state)) {
            if ui.has_input(|key| key == Key::Enter) {
                ui.send_message(Message::Exit {
-
                    operation: Some(IssueOperation::Show { id: issue.id }),
+
                    operation: Some(IssueOperation::Show { args: args.clone() }),
                });
            }

            if ui.has_input(|key| key == Key::Char('e')) {
                ui.send_message(Message::Exit {
                    operation: Some(IssueOperation::Edit {
-
                        id: issue.id,
+
                        args: args.clone(),
                        comment_id: preview.selected_comment().map(|c| c.id),
-
                        search: browser.search.read().text,
                    }),
                });
            }

            if ui.has_input(|key| key == Key::Char('s')) {
                ui.send_message(Message::Exit {
-
                    operation: Some(IssueOperation::Solve {
-
                        id: issue.id,
-
                        search: browser.search.read().text,
-
                    }),
+
                    operation: Some(IssueOperation::Solve { args: args.clone() }),
                });
            }

            if ui.has_input(|key| key == Key::Char('l')) {
                ui.send_message(Message::Exit {
-
                    operation: Some(IssueOperation::Close {
-
                        id: issue.id,
-
                        search: browser.search.read().text,
-
                    }),
+
                    operation: Some(IssueOperation::Close { args: args.clone() }),
                });
            }

            if ui.has_input(|key| key == Key::Char('o')) {
                ui.send_message(Message::Exit {
-
                    operation: Some(IssueOperation::Reopen {
-
                        id: issue.id,
-
                        search: browser.search.read().text,
-
                    }),
+
                    operation: Some(IssueOperation::Reopen { args }),
                });
            }
        }
@@ -783,7 +809,6 @@ impl App {
            let closed = solved + other;

            let filtered_counts = format!(" {}/{} ", filtered.len(), issues.len());
-

            if !self.state.filter.has_state() {
                [
                    Column::new(
@@ -791,7 +816,7 @@ impl App {
                        Constraint::Length(8),
                    ),
                    Column::new(
-
                        Span::raw(format!(" {search} "))
+
                        Span::raw(format!(" {search}"))
                            .into_left_aligned_line()
                            .style(ui.theme().bar_on_black_style)
                            .cyan()
@@ -813,12 +838,6 @@ impl App {
                        Constraint::Length(open.to_string().chars().count() as u16),
                    ),
                    Column::new(
-
                        Span::from(" ")
-
                            .style(ui.theme().bar_on_black_style)
-
                            .into_right_aligned_line(),
-
                        Constraint::Length(1),
-
                    ),
-
                    Column::new(
                        Span::raw(" ● ")
                            .style(ui.theme().bar_on_black_style)
                            .into_right_aligned_line()
@@ -855,7 +874,13 @@ impl App {
                        Constraint::Length(8),
                    ),
                    Column::new(
-
                        Span::raw(format!(" {search} "))
+
                        Span::from(" ")
+
                            .style(ui.theme().bar_on_black_style)
+
                            .into_right_aligned_line(),
+
                        Constraint::Length(1),
+
                    ),
+
                    Column::new(
+
                        Span::raw(search.to_string())
                            .into_left_aligned_line()
                            .style(ui.theme().bar_on_black_style)
                            .cyan()
@@ -922,6 +947,7 @@ impl App {
        let issues = issues
            .iter()
            .filter(|issue| self.state.filter.matches(issue))
+
            .cloned()
            .collect::<Vec<_>>();
        let issue = self.state.browser.selected().and_then(|i| issues.get(i));
        let properties = issue
@@ -992,9 +1018,6 @@ impl App {
            })
            .unwrap_or_default();

-
        let browser = &self.state.browser;
-
        let search = browser.search.read();
-

        let preview = &self.state.preview;
        let comment = preview.selected_comment();
        let root = preview.root_comments();
@@ -1041,13 +1064,12 @@ impl App {
                    }));
                }

-
                if let Some(issue) = issue {
+
                if let Ok(args) = OperationArguments::try_from((&issues, &self.state)) {
                    if ui.has_input(|key| key == Key::Char('c')) {
                        ui.send_message(Message::Exit {
                            operation: Some(IssueOperation::Comment {
-
                                id: issue.id,
+
                                args: args.clone(),
                                reply_to: comment.map(|c| c.id),
-
                                search: search.text.clone(),
                            }),
                        });
                    }
@@ -1055,9 +1077,8 @@ impl App {
                    if ui.has_input(|key| key == Key::Char('e')) {
                        ui.send_message(Message::Exit {
                            operation: Some(IssueOperation::Edit {
-
                                id: issue.id,
+
                                args,
                                comment_id: comment.map(|c| c.id),
-
                                search: search.text,
                            }),
                        });
                    }
@@ -1102,31 +1123,36 @@ impl App {
    }

    pub fn show_comment(&self, frame: &mut Frame, ui: &mut Ui<Message>) {
-
        let (text, footer, mut cursor) = {
+
        let (text, reactions, mut cursor) = {
            let comment = self.state.preview.selected_comment();
            let body: String = comment
                .map(|comment| comment.body.clone())
                .unwrap_or_default();
-
            let reactions = comment
-
                .map(|comment| {
-
                    let reactions = comment.accumulated_reactions().iter().fold(
-
                        String::new(),
-
                        |all, (r, acc)| {
-
                            if *acc > 1_usize {
-
                                [all, format!("{r}{acc} ")].concat()
-
                            } else {
-
                                [all, format!("{r} ")].concat()
-
                            }
-
                        },
-
                    );
-
                    reactions
-
                })
-
                .unwrap_or_default();
+
            let reactions = comment.and_then(|comment| {
+
                let reactions = comment.accumulated_reactions();
+
                if !reactions.is_empty() {
+
                    let reactions = reactions.iter().fold(String::new(), |all, (r, acc)| {
+
                        if *acc > 1_usize {
+
                            [all, format!("{r}{acc} ")].concat()
+
                        } else {
+
                            [all, format!("{r} ")].concat()
+
                        }
+
                    });
+
                    Some(reactions)
+
                } else {
+
                    None
+
                }
+
            });

            (body, reactions, self.state.preview.comment.clone().cursor())
        };
-
        let comment =
-
            ui.text_view_with_footer(frame, text, footer, &mut cursor, Some(Borders::All));
+
        let comment = match reactions {
+
            Some(reactions) => {
+
                ui.text_view_with_footer(frame, text, reactions, &mut cursor, Some(Borders::All))
+
            }
+
            None => ui.text_view(frame, text, &mut cursor, Some(Borders::All)),
+
        };
+

        if comment.changed {
            ui.send_message(Message::Changed(Change::CommentBody {
                state: TextViewState::new(cursor),
modified bin/commands/patch.rs
@@ -9,7 +9,6 @@ use anyhow::anyhow;

use radicle::cob::ObjectId;
use radicle::identity::RepoId;
-
use radicle::patch::cache::Patches;
use radicle::patch::{Patch, Revision, RevisionId, Status};
use radicle::prelude::Did;
use radicle::storage::git::Repository;
@@ -297,7 +296,6 @@ impl Args for Options {

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

    let (_, rid) = radicle::rad::cwd()
@@ -309,53 +307,10 @@ pub async fn run(options: Options, ctx: impl radicle_cli::terminal::Context) ->

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

-
            // Run TUI with patch list interface
-
            let selection = interface::list(opts.clone(), profile.clone(), rid).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 patch list interface..");
-

-
                eprint!("{selection}");
-
            } else if let Some(selection) = selection {
-
                if let Some(operation) = selection.operation.clone() {
-
                    match operation {
-
                        PatchOperation::Show { id } => {
-
                            terminal::run_rad(
-
                                Some("patch"),
-
                                &["show".into(), id.to_string().into()],
-
                            )?;
-
                        }
-
                        PatchOperation::Diff { id } => {
-
                            let repo = profile.storage.repository(rid)?;
-
                            let cache = profile.patches(&repo)?;
-
                            let patch = cache
-
                                .get(&id)?
-
                                .ok_or_else(|| anyhow!("unknown patch '{id}'"))?;
-
                            let range = format!("{}..{}", patch.base(), patch.head());
+
            log::info!("Starting patch selection interface in project {rid}..");

-
                            terminal::run_git(Some("diff"), &[range.into()])?;
-
                        }
-
                        PatchOperation::Checkout { id } => {
-
                            terminal::run_rad(
-
                                Some("patch"),
-
                                &["checkout".into(), id.to_string().into()],
-
                            )?;
-
                        }
-
                        PatchOperation::_Review { id } => {
-
                            let opts = ReviewOptions::default();
-
                            interface::review(opts, profile, rid, id).await?;
-
                        }
-
                    }
-
                }
-
            }
+
            let rid = options.repo.unwrap_or(rid);
+
            interface::list(opts.clone(), ctx.profile()?, rid).await?;
        }
        Operation::Review { ref opts } => {
            log::info!("Starting patch review interface in project {rid}..");
@@ -395,11 +350,8 @@ mod interface {
    use radicle::storage::ReadStorage;
    use radicle::Profile;

-
    use radicle_cli::terminal;
-

-
    use radicle_tui::Selection;
-

    use crate::cob;
+
    use crate::terminal;
    use crate::tui_patch::list;
    use crate::tui_patch::review::builder::CommentBuilder;
    use crate::tui_patch::review::ReviewAction;
@@ -409,23 +361,96 @@ mod interface {
    use super::review::builder::ReviewBuilder;
    use super::{ListOptions, ReviewOptions};

-
    pub async fn list(
-
        opts: ListOptions,
-
        profile: Profile,
-
        rid: RepoId,
-
    ) -> anyhow::Result<Option<Selection<list::PatchOperation>>> {
-
        let repository = profile.storage.repository(rid).unwrap();
+
    pub async fn list(opts: ListOptions, profile: Profile, rid: RepoId) -> anyhow::Result<()> {
        let me = profile.did();

-
        log::info!("Starting patch selection interface in project {rid}..");
+
        #[derive(Default)]
+
        struct PreviousState {
+
            patch_id: Option<PatchId>,
+
            search: Option<String>,
+
        }

-
        let context = list::Context {
-
            profile,
-
            repository,
-
            filter: (me, opts.filter.clone()).into(),
-
        };
+
        // Store issue and comment selection across app runs in order to
+
        // preselect them when re-running the app.
+
        let mut state = PreviousState::default();

-
        list::Tui::new(context).run().await
+
        loop {
+
            let context = list::Context {
+
                profile: profile.clone(),
+
                repository: profile.storage.repository(rid).unwrap(),
+
                filter: (me, opts.filter.clone()).into(),
+
                search: state.search.clone(),
+
                patch_id: state.patch_id,
+
            };
+

+
            // Run TUI with patch list interface
+
            let selection = list::Tui::new(context).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 patch list interface..");
+

+
                eprint!("{selection}");
+

+
                break;
+
            } else if let Some(selection) = selection {
+
                if let Some(operation) = selection.operation.clone() {
+
                    match operation {
+
                        list::PatchOperation::Show { args } => {
+
                            state = PreviousState {
+
                                patch_id: Some(args.id()),
+
                                search: Some(args.search()),
+
                            };
+
                            terminal::run_rad(
+
                                Some("patch"),
+
                                &["show".into(), args.id().to_string().into()],
+
                            )?;
+
                        }
+
                        list::PatchOperation::Diff { args } => {
+
                            let repo = profile.clone().storage.repository(rid)?;
+
                            let cache = profile.patches(&repo)?;
+
                            let patch = cache
+
                                .get(&args.id())?
+
                                .ok_or_else(|| anyhow!("unknown patch '{}'", args.id()))?;
+
                            let range = format!("{}..{}", patch.base(), patch.head());
+

+
                            state = PreviousState {
+
                                patch_id: Some(args.id()),
+
                                search: Some(args.search()),
+
                            };
+

+
                            terminal::run_git(Some("diff"), &[range.into()])?;
+
                        }
+
                        list::PatchOperation::Checkout { args } => {
+
                            state = PreviousState {
+
                                patch_id: Some(args.id()),
+
                                search: Some(args.search()),
+
                            };
+
                            terminal::run_rad(
+
                                Some("patch"),
+
                                &["checkout".into(), args.id().to_string().into()],
+
                            )?;
+
                        }
+
                        list::PatchOperation::_Review { args } => {
+
                            state = PreviousState {
+
                                patch_id: Some(args.id()),
+
                                search: Some(args.search()),
+
                            };
+
                            let opts = ReviewOptions::default();
+
                            review(opts, profile.clone(), rid, args.id()).await?;
+
                        }
+
                    }
+
                }
+
            } else {
+
                break;
+
            }
+
        }
+

+
        Ok(())
    }

    pub async fn review(
@@ -434,6 +459,8 @@ mod interface {
        rid: RepoId,
        patch_id: PatchId,
    ) -> anyhow::Result<()> {
+
        use radicle_cli::terminal;
+

        let repo = profile.storage.repository(rid)?;
        let signer = terminal::signer(&profile)?;
        let cache = profile.patches(&repo)?;
modified bin/commands/patch/list.rs
@@ -1,7 +1,7 @@
use std::str::FromStr;
use std::sync::{Arc, Mutex};

-
use anyhow::Result;
+
use anyhow::{anyhow, Result};

use serde::Serialize;

@@ -53,17 +53,49 @@ const HELP: &str = r#"# Generic keybindings

# Searching

-
Pattern:    is:<state> | is:authored | authors:[<did>, <did>] | <search>
-
Example:    is:open is:authored improve"#;
+
Examples:   state=open bugfix
+
            state=merged author=(did:key:... or did:key:...)"#;
+

+
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
+
pub struct OperationArguments {
+
    id: PatchId,
+
    search: String,
+
}
+

+
impl OperationArguments {
+
    pub fn id(&self) -> PatchId {
+
        self.id
+
    }
+

+
    pub fn search(&self) -> String {
+
        self.search.clone()
+
    }
+
}
+

+
impl TryFrom<(&Vec<Patch>, &AppState)> for OperationArguments {
+
    type Error = anyhow::Error;
+

+
    fn try_from(value: (&Vec<Patch>, &AppState)) -> Result<Self> {
+
        let (patches, state) = value;
+
        let selected = state.patches.selected();
+
        let id = selected
+
            .and_then(|s| patches.get(s))
+
            .ok_or(anyhow!("No patch selected"))?
+
            .id;
+
        let search = state.search.read().text;
+

+
        Ok(Self { id, search })
+
    }
+
}

/// The selected patch operation returned by the operation
/// selection widget.
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub enum PatchOperation {
-
    Checkout { id: PatchId },
-
    Diff { id: PatchId },
-
    Show { id: PatchId },
-
    _Review { id: PatchId },
+
    Checkout { args: OperationArguments },
+
    Diff { args: OperationArguments },
+
    Show { args: OperationArguments },
+
    _Review { args: OperationArguments },
}

type Selection = tui::Selection<PatchOperation>;
@@ -72,6 +104,8 @@ pub struct Context {
    pub profile: Profile,
    pub repository: Repository,
    pub filter: PatchFilter,
+
    pub patch_id: Option<PatchId>,
+
    pub search: Option<String>,
}

pub struct Tui {
@@ -155,9 +189,16 @@ impl TryFrom<&Context> for App {
            .collect::<Vec<_>>();
        patches.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));

-
        let search = {
-
            let raw = context.filter.to_string();
-
            raw.trim().to_string()
+
        let search = context.search.as_ref().map(|s| s.trim().to_string());
+
        let (search, filter) = match search {
+
            Some(search) => (
+
                search.clone(),
+
                PatchFilter::from_str(search.trim()).unwrap_or(PatchFilter::Invalid),
+
            ),
+
            None => {
+
                let filter = context.filter.clone();
+
                (filter.to_string().trim().to_string(), filter)
+
            }
        };

        Ok(App {
@@ -165,14 +206,24 @@ impl TryFrom<&Context> for App {
            state: AppState {
                page: Page::Main,
                main_group: ContainerState::new(3, Some(0)),
-
                patches: TableState::new(Some(0)),
+
                patches: TableState::new(Some(
+
                    context
+
                        .patch_id
+
                        .and_then(|id| {
+
                            patches
+
                                .iter()
+
                                .filter(|item| filter.matches(item))
+
                                .position(|item| item.id == id)
+
                        })
+
                        .unwrap_or(0),
+
                )),
                search: BufferedValue::new(TextEditState {
                    text: search.clone(),
                    cursor: search.len(),
                }),
                show_search: false,
                help: TextViewState::new(Position::default()),
-
                filter: context.filter.clone(),
+
                filter,
            },
        })
    }
@@ -274,10 +325,8 @@ impl Show<Message> for App {
                        },
                    );

-
                    if !show_search {
-
                        if ui.has_input(|key| key == Key::Char('?')) {
-
                            ui.send_message(Message::Changed(Change::Page { page: Page::Help }));
-
                        }
+
                    if !show_search && ui.has_input(|key| key == Key::Char('?')) {
+
                        ui.send_message(Message::Changed(Change::Page { page: Page::Help }));
                    }
                }

@@ -363,20 +412,20 @@ impl App {
            ui.send_message(Message::ShowSearch);
        }

-
        if let Some(patch) = selected.and_then(|s| patches.get(s)) {
+
        if let Ok(args) = OperationArguments::try_from((&patches, &self.state)) {
            if ui.has_input(|key| key == Key::Enter) {
                ui.send_message(Message::Exit {
-
                    operation: Some(PatchOperation::Show { id: patch.id }),
+
                    operation: Some(PatchOperation::Show { args: args.clone() }),
                });
            }
            if ui.has_input(|key| key == Key::Char('d')) {
                ui.send_message(Message::Exit {
-
                    operation: Some(PatchOperation::Diff { id: patch.id }),
+
                    operation: Some(PatchOperation::Diff { args: args.clone() }),
                });
            }
            if ui.has_input(|key| key == Key::Char('c')) {
                ui.send_message(Message::Exit {
-
                    operation: Some(PatchOperation::Checkout { id: patch.id }),
+
                    operation: Some(PatchOperation::Checkout { args }),
                });
            }
        }
@@ -451,7 +500,7 @@ impl App {
                        } => (counts.0, counts.1, counts.2, counts.3 + 1),
                    });

-
            if self.state.filter.is_default() {
+
            if !self.state.filter.has_state() {
                let draft = format!(" {} ", state_counts.0);
                let open = format!(" {} ", state_counts.1);
                let archived = format!(" {} ", state_counts.2);
modified bin/ui/items/patch.rs
@@ -170,6 +170,16 @@ pub mod filter {
        pub fn is_default(&self) -> bool {
            *self == PatchFilter::default()
        }
+

+
        pub fn has_state(&self) -> bool {
+
            match self {
+
                PatchFilter::State(_) => true,
+
                PatchFilter::And(filters) => {
+
                    filters.iter().any(|f| matches!(f, PatchFilter::State(_)))
+
                }
+
                _ => false,
+
            }
+
        }
    }

    impl fmt::Display for PatchFilter {
modified src/ui/widget.rs
@@ -980,9 +980,6 @@ impl Widget for TextView<'_> {
            ui.theme.border_style
        };
        let length = self.text.lines.len();
-
        // let virtual_length = length * ((length as f64).log2() as usize) / 100;
-
        // let content_length = area.height as usize + virtual_length;
-
        // let content_length = length;
        let content_length = area.height as usize;

        let area = render_block(frame, area, self.borders, border_style);