Radish alpha
r
rad:z39mP9rQAaGmERfUMPULfPUi473tY
Radicle terminal user interface
Radicle
Git
lib: Cleanup app return types
Merged did:key:z6MkgFq6...nBGz opened 4 months ago

Clarify an app’s return type by removing the intermediate string representation. This also sunsets the selection mode, that allowed for either returning just an ID or an operation, across all apps.

10 files changed +185 -366 6451daca 4944ab36
modified bin/commands/inbox.rs
@@ -12,8 +12,9 @@ use radicle::storage::{HasRepoId, ReadRepository};
use radicle_cli::terminal;
use radicle_cli::terminal::{Args, Error, Help};

-
use self::common::{Mode, RepositoryMode, SelectionMode};
+
use self::common::RepositoryMode;

+
use crate::commands::tui_inbox::common::InboxOperation;
use crate::ui::items::notification::filter::{NotificationFilter, SortBy};

pub const HELP: Help = Help {
@@ -27,14 +28,10 @@ Usage

List options

-
    --mode <MODE>           Set selection mode; see MODE below (default: operation)
-
    --json                  Return JSON on stderr instead of calling `rad`
-

    --sort-by <field>       Sort by `id` or `timestamp` (default: timestamp)
    --reverse, -r           Reverse the list

-
    The MODE argument can be 'operation' or 'id'. 'operation' selects a notification id and
-
    an operation, whereas 'id' selects a notification id only.
+
    --json                  Return JSON on stderr instead of calling `rad`

Other options

@@ -63,7 +60,7 @@ pub enum OperationName {

#[derive(Debug, Default, Clone, PartialEq)]
pub struct ListOptions {
-
    mode: Mode,
+
    mode: RepositoryMode,
    filter: NotificationFilter,
    sort_by: SortBy,
    json: bool,
@@ -100,19 +97,6 @@ impl Args for Options {
                    };
                }

-
                // list options.
-
                Long("mode") | Short('m') if op == OperationName::List => {
-
                    let val = parser.value()?;
-
                    let val = val.to_str().unwrap_or_default();
-

-
                    let selection_mode = match val {
-
                        "operation" => SelectionMode::Operation,
-
                        "id" => SelectionMode::Id,
-
                        unknown => anyhow::bail!("unknown mode '{}'", unknown),
-
                    };
-
                    list_opts.mode = list_opts.mode.with_selection(selection_mode)
-
                }
-

                Long("reverse") | Short('r') => {
                    reverse = Some(true);
                }
@@ -164,9 +148,7 @@ impl Args for Options {
            return Err(Error::Help.into());
        }

-
        list_opts.mode = list_opts
-
            .mode
-
            .with_repository(repository_mode.unwrap_or_default());
+
        list_opts.mode = repository_mode.unwrap_or_default();
        list_opts.sort_by = if let Some(field) = field {
            SortBy {
                field,
@@ -226,17 +208,22 @@ pub async fn run(options: Options, ctx: impl terminal::Context) -> anyhow::Resul

                eprint!("{selection}");
            } else if let Some(selection) = selection {
-
                let mut args = vec![];
-

-
                if let Some(operation) = selection.operation {
-
                    args.push(operation.to_string());
-
                }
-
                if let Some(id) = selection.ids.first() {
-
                    args.push(format!("{id}"));
+
                if let Some(operation) = selection.operation.clone() {
+
                    match operation {
+
                        InboxOperation::Show { id } => {
+
                            let _ = crate::terminal::run_rad(
+
                                Some("inbox"),
+
                                &[OsString::from("show"), OsString::from(id.to_string())],
+
                            );
+
                        }
+
                        InboxOperation::Clear { id } => {
+
                            let _ = crate::terminal::run_rad(
+
                                Some("inbox"),
+
                                &[OsString::from("clear"), OsString::from(id.to_string())],
+
                            );
+
                        }
+
                    }
                }
-

-
                let args = args.into_iter().map(OsString::from).collect::<Vec<_>>();
-
                let _ = crate::terminal::run_rad(Some("inbox"), &args);
            }
        }
        Operation::Other { args } => {
modified bin/commands/inbox/common.rs
@@ -1,19 +1,6 @@
-
use std::fmt::Display;
-

use serde::Serialize;

-
use radicle::identity::RepoId;
-

-
/// The application's subject. It tells the application
-
/// which widgets to render and which output to produce.
-
///
-
/// Depends on CLI arguments given by the user.
-
#[derive(Debug, Default, Clone, PartialEq, Eq)]
-
pub enum SelectionMode {
-
    Id,
-
    #[default]
-
    Operation,
-
}
+
use radicle::{identity::RepoId, node::notifications::NotificationId};

#[derive(Clone, Default, Debug, PartialEq, Eq)]
pub enum RepositoryMode {
@@ -23,45 +10,10 @@ pub enum RepositoryMode {
    ByRepo((RepoId, Option<String>)),
}

-
#[derive(Clone, Default, Debug, PartialEq, Eq)]
-
pub struct Mode {
-
    selection: SelectionMode,
-
    repository: RepositoryMode,
-
}
-

-
impl Mode {
-
    pub fn with_selection(mut self, selection: SelectionMode) -> Self {
-
        self.selection = selection;
-
        self
-
    }
-

-
    pub fn with_repository(mut self, repository: RepositoryMode) -> Self {
-
        self.repository = repository;
-
        self
-
    }
-

-
    pub fn repository(&self) -> &RepositoryMode {
-
        &self.repository
-
    }
-
}
-

/// The selected issue operation returned by the operation
/// selection widget.
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub enum InboxOperation {
-
    Show,
-
    Clear,
-
}
-

-
impl Display for InboxOperation {
-
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-
        match self {
-
            InboxOperation::Show => {
-
                write!(f, "show")
-
            }
-
            InboxOperation::Clear => {
-
                write!(f, "clear")
-
            }
-
        }
-
    }
+
    Show { id: NotificationId },
+
    Clear { id: NotificationId },
}
modified bin/commands/inbox/list.rs
@@ -1,5 +1,6 @@
use std::str::FromStr;
use std::sync::{Arc, Mutex};
+
use std::vec;

use anyhow::Result;

@@ -10,7 +11,6 @@ use ratatui::text::Span;
use ratatui::{Frame, Viewport};

use radicle::identity::Project;
-
use radicle::node::notifications::NotificationId;
use radicle::prelude::RepoId;
use radicle::storage::ReadStorage;
use radicle::Profile;
@@ -26,13 +26,13 @@ use tui::ui::im::{Borders, Show};
use tui::ui::{BufferedValue, Column, Spacing};
use tui::{Channel, Exit};

-
use super::common::{Mode, RepositoryMode};
+
use super::common::RepositoryMode;
use crate::commands::tui_inbox::common::InboxOperation;
use crate::ui::items::filter::Filter;
use crate::ui::items::notification::filter::{NotificationFilter, SortBy};
use crate::ui::items::notification::Notification;

-
type Selection = tui::Selection<NotificationId>;
+
type Selection = tui::Selection<InboxOperation>;

const HELP: &str = r#"# Generic keybindings

@@ -47,7 +47,6 @@ const HELP: &str = r#"# Generic keybindings

# Specific keybindings

-
`enter`:    Select notification (if --mode id)
`enter`:    Show notification
`r`:        Reload notifications
`c`:        Clear notification
@@ -65,7 +64,7 @@ pub struct Context {
    pub profile: Profile,
    pub project: Project,
    pub rid: RepoId,
-
    pub mode: Mode,
+
    pub mode: RepositoryMode,
    pub filter: NotificationFilter,
    pub sort_by: SortBy,
}
@@ -194,10 +193,9 @@ impl store::Update<Message> for App {
                None
            }
            Message::Quit => Some(Exit { value: None }),
-
            Message::Exit { operation } => self.selected_notification().map(|issue| Exit {
+
            Message::Exit { operation } => Some(Exit {
                value: Some(Selection {
-
                    operation: operation.map(|op| op.to_string()),
-
                    ids: vec![issue.id],
+
                    operation,
                    args: vec![],
                }),
            }),
@@ -353,7 +351,7 @@ impl App {
            Column::new(Span::raw("ID").bold(), Constraint::Length(8)).hide_medium(),
            Column::new(Span::raw("Summary").bold(), Constraint::Fill(1)),
            Column::new(Span::raw("Repository").bold(), Constraint::Length(16))
-
                .skip(*context.mode.repository() != RepositoryMode::All),
+
                .skip(context.mode != RepositoryMode::All),
            Column::new(Span::raw("OID").bold(), Constraint::Length(8)).hide_medium(),
            Column::new(Span::raw("Kind").bold(), Constraint::Length(20)).hide_small(),
            Column::new(Span::raw("Change").bold(), Constraint::Length(8)).hide_small(),
@@ -397,19 +395,26 @@ impl App {
            },
        );

-
        if ui.has_input(|key| key == Key::Enter) {
-
            ui.send_message(Message::Exit {
-
                operation: Some(InboxOperation::Show),
-
            });
-
        }
-
        if ui.has_input(|key| key == Key::Char('c')) {
-
            ui.send_message(Message::Exit {
-
                operation: Some(InboxOperation::Clear),
-
            });
-
        }
        if ui.has_input(|key| key == Key::Char('r')) {
            ui.send_message(Message::Reload);
        }
+

+
        if let Some(notification) = selected.and_then(|s| notifs.get(s)) {
+
            if ui.has_input(|key| key == Key::Enter) {
+
                ui.send_message(Message::Exit {
+
                    operation: Some(InboxOperation::Show {
+
                        id: notification.id,
+
                    }),
+
                });
+
            }
+
            if ui.has_input(|key| key == Key::Char('c')) {
+
                ui.send_message(Message::Exit {
+
                    operation: Some(InboxOperation::Clear {
+
                        id: notification.id,
+
                    }),
+
                });
+
            }
+
        }
    }

    fn show_browser_footer(&self, frame: &mut Frame, ui: &mut im::Ui<Message>) {
@@ -639,20 +644,6 @@ impl App {
            Some(Borders::None),
        );
    }
-

-
    pub fn selected_notification(&self) -> Option<Notification> {
-
        let patches = self.notifications.lock().unwrap();
-
        match self.state.patches.selected() {
-
            Some(selected) => patches
-
                .iter()
-
                .filter(|patch| self.state.filter.matches(patch))
-
                .collect::<Vec<_>>()
-
                .get(selected)
-
                .cloned()
-
                .cloned(),
-
            _ => None,
-
        }
-
    }
}

impl App {
@@ -675,19 +666,16 @@ impl App {
        }

        // Set project name
-
        let mode = match context.mode.repository() {
+
        let mode = match context.mode {
            RepositoryMode::ByRepo((rid, _)) => {
                let name = context.project.name().to_string();
-
                context
-
                    .mode
-
                    .clone()
-
                    .with_repository(RepositoryMode::ByRepo((*rid, Some(name))))
+
                RepositoryMode::ByRepo((rid, Some(name)))
            }
            _ => context.mode.clone(),
        };

        // Sort by project if all notifications are shown
-
        if let RepositoryMode::All = mode.repository() {
+
        if let RepositoryMode::All = mode {
            items.sort_by(|a, b| a.project.cmp(&b.project));
        }
    }
@@ -719,7 +707,7 @@ impl Task for NotificationLoader {
    type Return = Message;

    fn run(&self) -> anyhow::Result<Vec<Self::Return>> {
-
        let notifications = match self.context.mode.repository() {
+
        let notifications = match self.context.mode {
            RepositoryMode::All => {
                let notifs = self.context.profile.notifications_mut()?;
                let all = notifs.all()?;
@@ -751,7 +739,7 @@ impl Task for NotificationLoader {
                    .collect::<Vec<_>>()
            }
            RepositoryMode::ByRepo((rid, _)) => {
-
                let repo = self.context.profile.storage.repository(*rid)?;
+
                let repo = self.context.profile.storage.repository(rid)?;
                let project = repo.project()?;
                let notifs = self.context.profile.notifications_mut()?;
                let by_repo = notifs.by_repo(&repo.id, "timestamp")?;
modified bin/commands/issue.rs
@@ -16,6 +16,7 @@ use radicle_cli::terminal;
use radicle_cli::terminal::{Args, Error, Help};

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

lazy_static! {
@@ -35,12 +36,8 @@ Usage

List options

-
    --mode <MODE>       Set selection mode; see MODE below (default: operation)
    --json              Return JSON on stderr instead of calling `rad`

-
    The MODE argument can be 'operation' or 'id'. 'operation' selects an issue id and
-
    an operation, whereas 'id' selects an issue id only.
-

Other options

    --no-forward        Don't forward command to `rad` (default: true)
@@ -69,7 +66,6 @@ pub enum OperationName {

#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct ListOptions {
-
    mode: common::Mode,
    filter: cob::issue::Filter,
    json: bool,
}
@@ -102,18 +98,6 @@ impl Args for Options {
                        _ => Some(true),
                    };
                }
-

-
                // select options.
-
                Long("mode") | Short('m') if op == OperationName::List => {
-
                    let val = parser.value()?;
-
                    let val = val.to_str().unwrap_or_default();
-

-
                    list_opts.mode = match val {
-
                        "operation" => common::Mode::Operation,
-
                        "id" => common::Mode::Id,
-
                        unknown => anyhow::bail!("unknown mode '{unknown}'"),
-
                    };
-
                }
                Long("all") if op == OperationName::List => {
                    list_opts.filter = list_opts.filter.with_state(None);
                }
@@ -210,7 +194,6 @@ pub async fn run(options: Options, ctx: impl terminal::Context) -> anyhow::Resul
            let context = list::Context {
                profile,
                repository,
-
                mode: opts.mode,
                filter: opts.filter.clone(),
            };

@@ -226,16 +209,22 @@ pub async fn run(options: Options, ctx: impl terminal::Context) -> anyhow::Resul

                eprint!("{selection}");
            } else if let Some(selection) = selection {
-
                let args = [
-
                    selection.operation.as_ref().cloned(),
-
                    selection.ids.first().map(ToString::to_string),
-
                ]
-
                .into_iter()
-
                .flatten()
-
                .map(OsString::from)
-
                .collect::<Vec<_>>();
-

-
                let _ = crate::terminal::run_rad(Some("issue"), &args);
+
                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())],
+
                            );
+
                        }
+
                    }
+
                }
            }
        }
        Operation::Other { args } => {
modified bin/commands/issue/common.rs
@@ -1,34 +1,11 @@
-
use std::fmt::Display;
-

use serde::Serialize;

-
/// The application's mode. It tells the application
-
/// which widgets to render and which output to produce.
-
/// Depends on CLI arguments given by the user.
-
#[derive(Clone, Default, Debug, Eq, PartialEq)]
-
pub enum Mode {
-
    #[default]
-
    Operation,
-
    Id,
-
}
+
use radicle::issue::IssueId;

/// The selected issue operation returned by the operation
/// selection widget.
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub enum IssueOperation {
-
    Edit,
-
    Show,
-
}
-

-
impl Display for IssueOperation {
-
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-
        match self {
-
            IssueOperation::Edit => {
-
                write!(f, "edit")
-
            }
-
            IssueOperation::Show => {
-
                write!(f, "show")
-
            }
-
        }
-
    }
+
    Edit { id: IssueId },
+
    Show { id: IssueId },
}
modified bin/commands/issue/list.rs
@@ -45,14 +45,13 @@ use crate::ui::TerminalInfo;

use self::ui::{Browser, BrowserProps};

-
use super::common::{IssueOperation, Mode};
+
use super::common::IssueOperation;

-
type Selection = tui::Selection<IssueId>;
+
type Selection = tui::Selection<IssueOperation>;

pub struct Context {
    pub profile: Profile,
    pub repository: Repository,
-
    pub mode: Mode,
    pub filter: issue::Filter,
}

@@ -153,7 +152,6 @@ pub struct HelpState {

#[derive(Clone, Debug)]
pub struct State {
-
    mode: Mode,
    pages: PageStack<AppPage>,
    browser: BrowserState<IssueItem, IssueItemFilter>,
    preview: PreviewState,
@@ -211,7 +209,6 @@ impl TryFrom<(&Context, &TerminalInfo)> for State {
            .collect();

        Ok(Self {
-
            mode: context.mode.clone(),
            pages: PageStack::new(vec![AppPage::Browser]),
            browser: BrowserState::build(items.clone(), filter, search),
            preview: PreviewState {
@@ -230,22 +227,41 @@ impl TryFrom<(&Context, &TerminalInfo)> for State {
}

#[derive(Clone, Debug)]
+
pub enum RequestedIssueOperation {
+
    Edit,
+
    Show,
+
}
+

+
#[derive(Clone, Debug)]
pub enum Message {
    Quit,
-
    Exit { operation: Option<IssueOperation> },
-
    ExitFromMode,
-
    SelectIssue { selected: Option<usize> },
+
    Exit {
+
        operation: Option<RequestedIssueOperation>,
+
    },
+
    SelectIssue {
+
        selected: Option<usize>,
+
    },
    OpenSearch,
-
    UpdateSearch { value: String },
+
    UpdateSearch {
+
        value: String,
+
    },
    ApplySearch,
    CloseSearch,
    TogglePreview,
-
    FocusSection { section: Option<Section> },
-
    SelectComment { selected: Option<Vec<CommentId>> },
-
    ScrollComment { state: TextViewState },
+
    FocusSection {
+
        section: Option<Section>,
+
    },
+
    SelectComment {
+
        selected: Option<Vec<CommentId>>,
+
    },
+
    ScrollComment {
+
        state: TextViewState,
+
    },
    OpenHelp,
    LeavePage,
-
    ScrollHelp { state: TextViewState },
+
    ScrollHelp {
+
        state: TextViewState,
+
    },
}

impl store::Update<Message> for State {
@@ -254,23 +270,20 @@ impl store::Update<Message> for State {
    fn update(&mut self, message: Message) -> Option<Exit<Selection>> {
        match message {
            Message::Quit => Some(Exit { value: None }),
-
            Message::Exit { operation } => self.browser.selected_item().map(|issue| Exit {
-
                value: Some(Selection {
-
                    operation: operation.map(|op| op.to_string()),
-
                    ids: vec![issue.id],
-
                    args: vec![],
-
                }),
-
            }),
-
            Message::ExitFromMode => {
-
                let operation = match self.mode {
-
                    Mode::Operation => Some(IssueOperation::Show.to_string()),
-
                    Mode::Id => None,
+
            Message::Exit { operation } => {
+
                let selected = self.browser.selected_item();
+
                let operation = match operation {
+
                    Some(RequestedIssueOperation::Show) => {
+
                        selected.map(|issue| IssueOperation::Show { id: issue.id })
+
                    }
+
                    Some(RequestedIssueOperation::Edit) => {
+
                        selected.map(|issue| IssueOperation::Edit { id: issue.id })
+
                    }
+
                    _ => None,
                };
-

-
                self.browser.selected_item().map(|issue| Exit {
+
                Some(Exit {
                    value: Some(Selection {
                        operation,
-
                        ids: vec![issue.id],
                        args: vec![],
                    }),
                })
@@ -385,10 +398,7 @@ 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 = match state.mode {
-
                    Mode::Id => vec![("enter", "select")],
-
                    Mode::Operation => vec![("enter", "show"), ("e", "edit")],
-
                };
+
                let mut shortcuts = vec![("enter", "show"), ("e", "edit")];
                if state.section == Some(Section::Browser) {
                    shortcuts = [shortcuts, [("/", "search")].to_vec()].concat()
                }
@@ -437,16 +447,17 @@ fn browser_page(channel: &Channel<Message>) -> Widget<State, Message> {
            let props = props
                .and_then(|props| props.inner_ref::<PageProps>())
                .unwrap_or(&default);
-

            if props.handle_keys {
                if let Event::Key(key) = event {
                    match key {
                        Key::Char('q') | Key::Ctrl('c') => Some(Message::Quit),
                        Key::Char('p') => Some(Message::TogglePreview),
                        Key::Char('?') => Some(Message::OpenHelp),
-
                        Key::Enter => Some(Message::ExitFromMode),
+
                        Key::Enter => Some(Message::Exit {
+
                            operation: Some(RequestedIssueOperation::Show),
+
                        }),
                        Key::Char('e') => Some(Message::Exit {
-
                            operation: Some(IssueOperation::Edit),
+
                            operation: Some(RequestedIssueOperation::Edit),
                        }),
                        _ => None,
                    }
@@ -673,7 +684,6 @@ fn help_text() -> String {

# Specific keybindings

-
`Enter`:    Select issue (if --mode id)
`Enter`:    Show issue
`e`:        Edit issue
`p`:        Toggle issue preview
modified bin/commands/patch.rs
@@ -19,7 +19,7 @@ use radicle_cli::terminal;
use radicle_cli::terminal::args::{string, Args, Error, Help};

use crate::cob::patch;
-
use crate::cob::patch::Filter;
+
use crate::commands::tui_patch::common::PatchOperation;

pub const HELP: Help = Help {
    name: "patch",
@@ -32,9 +32,6 @@ Usage

List options

-
    --mode <MODE>           Set selection mode; see MODE below (default: operation)
-
    --json                  Return JSON on stderr instead of calling `rad`
-

    --all                   Show all patches, including merged and archived patches
    --archived              Show only archived patches
    --merged                Show only merged patches
@@ -44,9 +41,7 @@ List options
    --author <did>          Show only patched where the given user is an author
                            (may be specified multiple times)

-
    The MODE argument can be 'operation' or 'id'. 'operation' selects a patch id and
-
    an operation, whereas 'id' selects a patch id only.
-

+
    --json                  Return JSON on stderr instead of calling `rad`

Other options

@@ -79,7 +74,6 @@ pub enum OperationName {

#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct ListOptions {
-
    mode: common::Mode,
    filter: patch::Filter,
    json: bool,
}
@@ -148,17 +142,6 @@ impl Args for Options {
                    };
                }

-
                // select options.
-
                Long("mode") | Short('m') if op == OperationName::List => {
-
                    let val = parser.value()?;
-
                    let val = val.to_str().unwrap_or_default();
-

-
                    list_opts.mode = match val {
-
                        "operation" => common::Mode::Operation,
-
                        "id" => common::Mode::Id,
-
                        unknown => anyhow::bail!("unknown mode '{}'", unknown),
-
                    };
-
                }
                Long("all") if op == OperationName::List => {
                    list_opts.filter = list_opts.filter.with_status(None);
                }
@@ -231,9 +214,6 @@ impl Args for Options {
        }

        // Configure list options
-
        if list_opts.mode == common::Mode::Id {
-
            list_opts.filter = Filter::default().with_status(None)
-
        }
        list_opts.json = json;

        // Map local commands. Forward help and ignore `no-forward`.
@@ -284,20 +264,28 @@ pub async fn run(options: Options, ctx: impl terminal::Context) -> anyhow::Resul
                eprint!("{selection}");
            } else if let Some(selection) = selection {
                if let Some(operation) = selection.operation.clone() {
-
                    let mut args = vec![operation.to_string()];
-

-
                    if let Some(id) = selection.ids.first() {
-
                        args.push(id.to_string());
-

-
                        match operation.as_str() {
-
                            "review" => {
-
                                let opts = ReviewOptions::default();
-
                                interface::review(opts, profile, rid, *id).await?;
-
                            }
-
                            _ => {
-
                                let args = args.into_iter().map(OsString::from).collect::<Vec<_>>();
-
                                let _ = crate::terminal::run_rad(Some("patch"), &args);
-
                            }
+
                    match operation {
+
                        PatchOperation::Show { id } => {
+
                            let _ = crate::terminal::run_rad(
+
                                Some("patch"),
+
                                &[OsString::from("show"), OsString::from(id.to_string())],
+
                            );
+
                        }
+
                        PatchOperation::Diff { id } => {
+
                            let _ = crate::terminal::run_rad(
+
                                Some("patch"),
+
                                &[OsString::from("diff"), OsString::from(id.to_string())],
+
                            );
+
                        }
+
                        PatchOperation::Checkout { id } => {
+
                            let _ = crate::terminal::run_rad(
+
                                Some("patch"),
+
                                &[OsString::from("checkout"), OsString::from(id.to_string())],
+
                            );
+
                        }
+
                        PatchOperation::_Review { id } => {
+
                            let opts = ReviewOptions::default();
+
                            interface::review(opts, profile, rid, id).await?;
                        }
                    }
                }
@@ -334,7 +322,6 @@ mod interface {
    use anyhow::anyhow;

    use radicle::cob;
-
    use radicle::cob::ObjectId;
    use radicle::identity::RepoId;
    use radicle::patch::PatchId;
    use radicle::patch::Verdict;
@@ -347,6 +334,7 @@ mod interface {
    use radicle_tui::Selection;

    use crate::cob::patch;
+
    use crate::commands::tui_patch::common::PatchOperation;
    use crate::tui_patch::list;
    use crate::tui_patch::review::builder::CommentBuilder;
    use crate::tui_patch::review::ReviewAction;
@@ -360,7 +348,7 @@ mod interface {
        opts: ListOptions,
        profile: Profile,
        rid: RepoId,
-
    ) -> anyhow::Result<Option<Selection<ObjectId>>> {
+
    ) -> anyhow::Result<Option<Selection<PatchOperation>>> {
        let repository = profile.storage.repository(rid).unwrap();

        log::info!("Starting patch selection interface in project {rid}..");
@@ -368,7 +356,6 @@ mod interface {
        let context = list::Context {
            profile,
            repository,
-
            mode: opts.mode,
            filter: opts.filter.clone(),
        };

modified bin/commands/patch/common.rs
@@ -1,39 +1,12 @@
-
use std::fmt::Display;
-

+
use radicle::patch::PatchId;
use serde::Serialize;

-
/// The application's mode. It tells the application
-
/// which widgets to render and which output to produce.
-
///
-
/// Depends on CLI arguments given by the user.
-
#[derive(Clone, Default, Debug, Eq, PartialEq)]
-
pub enum Mode {
-
    #[default]
-
    Operation,
-
    Id,
-
}
-

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

-
impl Display for PatchOperation {
-
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-
        match self {
-
            PatchOperation::Checkout => {
-
                write!(f, "checkout")
-
            }
-
            PatchOperation::Diff => {
-
                write!(f, "diff")
-
            }
-
            PatchOperation::Show => {
-
                write!(f, "show")
-
            }
-
        }
-
    }
+
    Checkout { id: PatchId },
+
    Diff { id: PatchId },
+
    Show { id: PatchId },
+
    _Review { id: PatchId },
}
modified bin/commands/patch/list.rs
@@ -3,7 +3,6 @@ use std::sync::{Arc, Mutex};

use anyhow::Result;

-
use radicle::patch::PatchId;
use radicle::storage::git::Repository;
use radicle::Profile;

@@ -24,9 +23,7 @@ use tui::ui::im::Show;
use tui::ui::{BufferedValue, Column, Spacing};
use tui::{Channel, Exit};

-
type Selection = tui::Selection<PatchId>;
-

-
use super::common::{Mode, PatchOperation};
+
use super::common::PatchOperation;

use crate::cob::patch;
use crate::ui::items::filter::Filter;
@@ -45,7 +42,6 @@ const HELP: &str = r#"# Generic keybindings

# Specific keybindings

-
`enter`:    Select patch (if --mode id)
`enter`:    Show patch
`c`:        Checkout patch
`d`:        Show patch diff
@@ -57,10 +53,11 @@ const HELP: &str = r#"# Generic keybindings
Pattern:    is:<state> | is:authored | authors:[<did>, <did>] | <search>
Example:    is:open is:authored improve"#;

+
type Selection = tui::Selection<PatchOperation>;
+

pub struct Context {
    pub profile: Profile,
    pub repository: Repository,
-
    pub mode: Mode,
    pub filter: patch::Filter,
}

@@ -107,7 +104,6 @@ pub enum Message {
    ShowSearch,
    HideSearch { apply: bool },
    Exit { operation: Option<PatchOperation> },
-
    ExitFromMode,
    Quit,
}

@@ -119,7 +115,6 @@ pub enum Page {

#[derive(Clone, Debug)]
pub struct AppState {
-
    mode: Mode,
    page: Page,
    main_group: ContainerState,
    patches: TableState,
@@ -158,7 +153,6 @@ impl TryFrom<&Context> for App {
        Ok(App {
            patches: Arc::new(Mutex::new(items.clone())),
            state: AppState {
-
                mode: context.mode.clone(),
                page: Page::Main,
                main_group: ContainerState::new(3, Some(0)),
                patches: TableState::new(Some(0)),
@@ -180,27 +174,12 @@ impl store::Update<Message> for App {
    fn update(&mut self, message: Message) -> Option<tui::Exit<Selection>> {
        match message {
            Message::Quit => Some(Exit { value: None }),
-
            Message::Exit { operation } => self.selected_patch().map(|issue| Exit {
+
            Message::Exit { operation } => Some(Exit {
                value: Some(Selection {
-
                    operation: operation.map(|op| op.to_string()),
-
                    ids: vec![issue.id],
+
                    operation,
                    args: vec![],
                }),
            }),
-
            Message::ExitFromMode => {
-
                let operation = match self.state.mode {
-
                    Mode::Operation => Some(PatchOperation::Show.to_string()),
-
                    Mode::Id => None,
-
                };
-

-
                self.selected_patch().map(|issue| Exit {
-
                    value: Some(Selection {
-
                        operation,
-
                        ids: vec![issue.id],
-
                        args: vec![],
-
                    }),
-
                })
-
            }
            Message::ShowSearch => {
                self.state.main_group = ContainerState::new(3, None);
                self.state.show_search = true;
@@ -375,18 +354,23 @@ impl App {
        if ui.has_input(|key| key == Key::Char('/')) {
            ui.send_message(Message::ShowSearch);
        }
-
        if ui.has_input(|key| key == Key::Enter) {
-
            ui.send_message(Message::ExitFromMode);
-
        }
-
        if ui.has_input(|key| key == Key::Char('d')) {
-
            ui.send_message(Message::Exit {
-
                operation: Some(PatchOperation::Diff),
-
            });
-
        }
-
        if ui.has_input(|key| key == Key::Char('c')) {
-
            ui.send_message(Message::Exit {
-
                operation: Some(PatchOperation::Checkout),
-
            });
+

+
        if let Some(patch) = selected.and_then(|s| patches.get(s)) {
+
            if ui.has_input(|key| key == Key::Enter) {
+
                ui.send_message(Message::Exit {
+
                    operation: Some(PatchOperation::Show { id: patch.id }),
+
                });
+
            }
+
            if ui.has_input(|key| key == Key::Char('d')) {
+
                ui.send_message(Message::Exit {
+
                    operation: Some(PatchOperation::Diff { id: patch.id }),
+
                });
+
            }
+
            if ui.has_input(|key| key == Key::Char('c')) {
+
                ui.send_message(Message::Exit {
+
                    operation: Some(PatchOperation::Checkout { id: patch.id }),
+
                });
+
            }
        }
    }

@@ -571,17 +555,13 @@ impl App {
    pub fn show_browser_shortcuts(&self, frame: &mut Frame, ui: &mut im::Ui<Message>) {
        ui.shortcuts(
            frame,
-
            &match self.state.mode {
-
                Mode::Id => [("enter", "select"), ("/", "search")].to_vec(),
-
                Mode::Operation => [
-
                    ("enter", "show"),
-
                    ("c", "checkout"),
-
                    ("d", "diff"),
-
                    ("/", "search"),
-
                    ("?", "help"),
-
                ]
-
                .to_vec(),
-
            },
+
            &[
+
                ("enter", "show"),
+
                ("c", "checkout"),
+
                ("d", "diff"),
+
                ("/", "search"),
+
                ("?", "help"),
+
            ],
            '∙',
        );
    }
@@ -632,18 +612,4 @@ impl App {
            Some(Borders::None),
        );
    }
-

-
    pub fn selected_patch(&self) -> Option<PatchItem> {
-
        let patches = self.patches.lock().unwrap();
-
        match self.state.patches.selected() {
-
            Some(selected) => patches
-
                .iter()
-
                .filter(|patch| self.state.filter.matches(patch))
-
                .collect::<Vec<_>>()
-
                .get(selected)
-
                .cloned()
-
                .cloned(),
-
            _ => None,
-
        }
-
    }
}
modified src/lib.rs
@@ -34,38 +34,32 @@ pub struct Exit<T> {

/// The output that is returned by all selection interfaces.
#[derive(Clone, Default, Debug, Eq, PartialEq)]
-
pub struct Selection<I>
+
pub struct Selection<O>
where
-
    I: ToString,
+
    O: Serialize,
{
-
    pub operation: Option<String>,
-
    pub ids: Vec<I>,
+
    pub operation: Option<O>,
    pub args: Vec<String>,
}

-
impl<I> Selection<I>
+
impl<O> Selection<O>
where
-
    I: ToString,
+
    O: Serialize,
{
-
    pub fn with_operation(mut self, operation: String) -> Self {
+
    pub fn with_operation(mut self, operation: O) -> Self {
        self.operation = Some(operation);
        self
    }

-
    pub fn with_id(mut self, id: I) -> Self {
-
        self.ids.push(id);
-
        self
-
    }
-

    pub fn with_args(mut self, arg: String) -> Self {
        self.args.push(arg);
        self
    }
}

-
impl<I> Serialize for Selection<I>
+
impl<O> Serialize for Selection<O>
where
-
    I: ToString,
+
    O: Serialize,
{
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
@@ -73,10 +67,6 @@ where
    {
        let mut state = serializer.serialize_struct("", 3)?;
        state.serialize_field("operation", &self.operation)?;
-
        state.serialize_field(
-
            "ids",
-
            &self.ids.iter().map(|id| id.to_string()).collect::<Vec<_>>(),
-
        )?;
        state.serialize_field("args", &self.args)?;
        state.end()
    }