Radish alpha
r
Radicle terminal user interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
patch: Add search widget
Erik Kundt committed 2 years ago
commit 79adc44814b69f34de106fa883b39ce6bd135765
parent 724a724b2903256e2213de73c0def6173a1dce4e
2 files changed +264 -95
modified bin/commands/patch/flux/select.rs
@@ -10,7 +10,7 @@ use radicle::Profile;
use radicle_tui as tui;

use tui::common::cob::patch::{self, Filter};
-
use tui::flux::store::{State, Store};
+
use tui::flux::store::{State, StateValue, Store};
use tui::flux::task::{self, Interrupted};
use tui::flux::ui::cob::PatchItem;
use tui::flux::ui::Frontend;
@@ -36,11 +36,15 @@ pub struct App {
#[derive(Clone, Debug)]
pub struct UIState {
    page_size: usize,
+
    show_search: bool,
}

impl Default for UIState {
    fn default() -> Self {
-
        Self { page_size: 1 }
+
        Self {
+
            page_size: 1,
+
            show_search: false,
+
        }
    }
}

@@ -49,7 +53,7 @@ pub struct PatchesState {
    patches: Vec<PatchItem>,
    selected: Option<PatchItem>,
    mode: Mode,
-
    filter: Filter,
+
    search: StateValue<String>,
    ui: UIState,
}

@@ -58,10 +62,6 @@ impl TryFrom<&Context> for PatchesState {

    fn try_from(context: &Context) -> Result<Self, Self::Error> {
        let patches = patch::all(&context.profile, &context.repository)?;
-
        let patches = patches
-
            .into_iter()
-
            .filter(|(_, patch)| context.filter.matches(&context.profile, patch))
-
            .collect::<Vec<_>>();

        // Convert into UI items
        let mut items = vec![];
@@ -71,16 +71,13 @@ impl TryFrom<&Context> for PatchesState {
            }
        }

-
        // Apply sorting
-
        items.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
-

        let selected = items.first().cloned();

        Ok(Self {
            patches: items,
            selected,
            mode: context.mode.clone(),
-
            filter: context.filter.clone(),
+
            search: StateValue::new(context.filter.to_string()),
            ui: UIState::default(),
        })
    }
@@ -90,6 +87,10 @@ pub enum Action {
    Exit { selection: Option<Selection> },
    Select { item: PatchItem },
    PageSize(usize),
+
    OpenSearch,
+
    UpdateSearch { value: String },
+
    ApplySearch,
+
    CloseSearch,
}

impl State<Action, Selection> for PatchesState {
@@ -106,6 +107,24 @@ impl State<Action, Selection> for PatchesState {
                self.ui.page_size = size;
                None
            }
+
            Action::OpenSearch => {
+
                self.ui.show_search = true;
+
                None
+
            }
+
            Action::UpdateSearch { value } => {
+
                self.search.write(value);
+
                None
+
            }
+
            Action::ApplySearch => {
+
                self.search.apply();
+
                self.ui.show_search = false;
+
                None
+
            }
+
            Action::CloseSearch => {
+
                self.search.reset();
+
                self.ui.show_search = false;
+
                None
+
            }
        }
    }
}
modified bin/commands/patch/flux/select/ui.rs
@@ -1,23 +1,25 @@
use std::collections::HashMap;
+
use std::str::FromStr;
use std::vec;

-
use radicle::patch::{self};
+
use radicle::patch::{self, Status};

use tokio::sync::mpsc::UnboundedSender;

use termion::event::Key;

use ratatui::backend::Backend;
-
use ratatui::layout::{Constraint, Direction, Layout, Rect};
+
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::Stylize;
use ratatui::text::Line;

use radicle_tui as tui;

-
use tui::common::cob::patch::{Filter, State};
-
use tui::flux::ui::cob::PatchItem;
+
use tui::flux::store::StateValue;
+
use tui::flux::ui::cob::{PatchItem, PatchItemFilter};
use tui::flux::ui::span;
use tui::flux::ui::widget::container::{Footer, FooterProps, Header, HeaderProps};
+
use tui::flux::ui::widget::input::{TextField, TextFieldProps};
use tui::flux::ui::widget::{
    Render, Shortcut, Shortcuts, ShortcutsProps, Table, TableProps, Widget,
};
@@ -31,6 +33,7 @@ use super::{Action, PatchesState};
pub struct ListPageProps {
    selected: Option<PatchItem>,
    mode: Mode,
+
    show_search: bool,
}

impl From<&PatchesState> for ListPageProps {
@@ -38,6 +41,7 @@ impl From<&PatchesState> for ListPageProps {
        Self {
            selected: state.selected.clone(),
            mode: state.mode.clone(),
+
            show_search: state.ui.show_search,
        }
    }
}
@@ -49,6 +53,8 @@ pub struct ListPage {
    props: ListPageProps,
    /// Notification widget
    patches: Patches,
+
    /// Search widget
+
    search: Search,
    /// Shortcut widget
    shortcuts: Shortcuts<Action>,
}
@@ -62,7 +68,8 @@ impl Widget<PatchesState, Action> for ListPage {
            action_tx: action_tx.clone(),
            props: ListPageProps::from(state),
            patches: Patches::new(state, action_tx.clone()),
-
            shortcuts: Shortcuts::new(state, action_tx.clone()),
+
            search: Search::new(state, action_tx.clone()),
+
            shortcuts: Shortcuts::new(state, action_tx),
        }
        .move_with_state(state)
    }
@@ -73,6 +80,7 @@ impl Widget<PatchesState, Action> for ListPage {
    {
        ListPage {
            patches: self.patches.move_with_state(state),
+
            search: self.search.move_with_state(state),
            shortcuts: self.shortcuts.move_with_state(state),
            props: ListPageProps::from(state),
            ..self
@@ -84,51 +92,61 @@ impl Widget<PatchesState, Action> for ListPage {
    }

    fn handle_key_event(&mut self, key: termion::event::Key) {
-
        match key {
-
            Key::Esc | Key::Ctrl('c') => {
-
                let _ = self.action_tx.send(Action::Exit { selection: None });
-
            }
-
            Key::Char('\n') => {
-
                if let Some(selected) = &self.props.selected {
-
                    let operation = match self.props.mode {
-
                        Mode::Operation => Some(PatchOperation::Show.to_string()),
-
                        Mode::Id => None,
-
                    };
-
                    let _ = self.action_tx.send(Action::Exit {
-
                        selection: Some(Selection {
-
                            operation,
+
        if self.props.show_search {
+
            <Search as Widget<PatchesState, Action>>::handle_key_event(&mut self.search, key)
+
        } else {
+
            match key {
+
                Key::Esc | Key::Ctrl('c') => {
+
                    let _ = self.action_tx.send(Action::Exit { selection: None });
+
                }
+
                Key::Char('\n') => {
+
                    if let Some(selected) = &self.props.selected {
+
                        let operation = match self.props.mode {
+
                            Mode::Operation => Some(PatchOperation::Show.to_string()),
+
                            Mode::Id => None,
+
                        };
+
                        let _ = self.action_tx.send(Action::Exit {
+
                            selection: Some(Selection {
+
                                operation,
+
                                ids: vec![selected.id],
+
                                args: vec![],
+
                            }),
+
                        });
+
                    }
+
                }
+
                Key::Char('c') => {
+
                    if let Some(selected) = &self.props.selected {
+
                        let selection = Selection {
+
                            operation: Some(PatchOperation::Checkout.to_string()),
                            ids: vec![selected.id],
                            args: vec![],
-
                        }),
-
                    });
+
                        };
+
                        let _ = self.action_tx.send(Action::Exit {
+
                            selection: Some(selection),
+
                        });
+
                    }
                }
-
            }
-
            Key::Char('c') => {
-
                if let Some(selected) = &self.props.selected {
-
                    let selection = Selection {
-
                        operation: Some(PatchOperation::Checkout.to_string()),
-
                        ids: vec![selected.id],
-
                        args: vec![],
-
                    };
-
                    let _ = self.action_tx.send(Action::Exit {
-
                        selection: Some(selection),
-
                    });
+
                Key::Char('d') => {
+
                    if let Some(selected) = &self.props.selected {
+
                        let selection = Selection {
+
                            operation: Some(PatchOperation::Diff.to_string()),
+
                            ids: vec![selected.id],
+
                            args: vec![],
+
                        };
+
                        let _ = self.action_tx.send(Action::Exit {
+
                            selection: Some(selection),
+
                        });
+
                    }
                }
-
            }
-
            Key::Char('d') => {
-
                if let Some(selected) = &self.props.selected {
-
                    let selection = Selection {
-
                        operation: Some(PatchOperation::Diff.to_string()),
-
                        ids: vec![selected.id],
-
                        args: vec![],
-
                    };
-
                    let _ = self.action_tx.send(Action::Exit {
-
                        selection: Some(selection),
-
                    });
+
                Key::Char('/') => {
+
                    let _ = self.action_tx.send(Action::OpenSearch);
+
                }
+
                _ => {
+
                    <Patches as Widget<PatchesState, Action>>::handle_key_event(
+
                        &mut self.patches,
+
                        key,
+
                    );
                }
-
            }
-
            _ => {
-
                <Patches as Widget<PatchesState, Action>>::handle_key_event(&mut self.patches, key);
            }
        }
    }
@@ -139,16 +157,37 @@ impl Render<()> for ListPage {
        let area = frame.size();
        let layout = tui::flux::ui::layout::default_page(area, 0u16, 1u16);

-
        let shortcuts = match self.props.mode {
-
            Mode::Id => vec![Shortcut::new("enter", "select")],
-
            Mode::Operation => vec![
-
                Shortcut::new("enter", "show"),
-
                Shortcut::new("c", "checkout"),
-
                Shortcut::new("d", "diff"),
-
            ],
+
        let shortcuts = if self.props.show_search {
+
            vec![
+
                Shortcut::new("esc", "back"),
+
                Shortcut::new("enter", "search"),
+
            ]
+
        } else {
+
            match self.props.mode {
+
                Mode::Id => vec![
+
                    Shortcut::new("enter", "select"),
+
                    Shortcut::new("/", "search"),
+
                ],
+
                Mode::Operation => vec![
+
                    Shortcut::new("enter", "show"),
+
                    Shortcut::new("c", "checkout"),
+
                    Shortcut::new("d", "diff"),
+
                    Shortcut::new("/", "search"),
+
                ],
+
            }
        };

-
        self.patches.render::<B>(frame, layout.component, ());
+
        if self.props.show_search {
+
            let component_layout = Layout::vertical([Constraint::Min(1), Constraint::Length(2)])
+
                .split(layout.component);
+

+
            self.patches.render::<B>(frame, component_layout[0], ());
+
            self.search
+
                .render::<B>(frame, component_layout[1], SearchProps {});
+
        } else {
+
            self.patches.render::<B>(frame, layout.component, ());
+
        }
+

        self.shortcuts.render::<B>(
            frame,
            layout.shortcuts,
@@ -162,13 +201,14 @@ impl Render<()> for ListPage {

struct PatchesProps {
    patches: Vec<PatchItem>,
-
    filter: Filter,
+
    search: StateValue<String>,
    stats: HashMap<String, usize>,
    widths: [Constraint; 9],
    cutoff: usize,
    cutoff_after: usize,
    focus: bool,
    page_size: usize,
+
    show_search: bool,
}

impl From<&PatchesState> for PatchesProps {
@@ -178,7 +218,18 @@ impl From<&PatchesState> for PatchesProps {
        let mut archived = 0;
        let mut merged = 0;

-
        for patch in &state.patches {
+
        let filter = PatchItemFilter::from_str(&state.search.read()).unwrap_or_default();
+
        let mut patches = state
+
            .patches
+
            .clone()
+
            .into_iter()
+
            .filter(|patch| filter.matches(patch))
+
            .collect::<Vec<_>>();
+

+
        // Apply sorting
+
        patches.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
+

+
        for patch in &patches {
            match patch.state {
                patch::State::Draft => draft += 1,
                patch::State::Open { conflicts: _ } => open += 1,
@@ -198,8 +249,8 @@ impl From<&PatchesState> for PatchesProps {
        ]);

        Self {
-
            patches: state.patches.clone(),
-
            filter: state.filter.clone(),
+
            patches,
+
            search: state.search.clone(),
            widths: [
                Constraint::Length(3),
                Constraint::Length(8),
@@ -216,6 +267,7 @@ impl From<&PatchesState> for PatchesProps {
            focus: false,
            stats,
            page_size: state.ui.page_size,
+
            show_search: state.ui.show_search,
        }
    }
}
@@ -248,10 +300,19 @@ impl Widget<PatchesState, Action> for Patches {
    where
        Self: Sized,
    {
+
        let props = PatchesProps::from(state);
+
        let mut table = self.table.move_with_state(state);
+

+
        if let Some(selected) = table.selected() {
+
            if selected > props.patches.len() {
+
                table.begin();
+
            }
+
        }
+

        Self {
-
            props: PatchesProps::from(state),
+
            props,
            header: self.header.move_with_state(state),
-
            table: self.table.move_with_state(state),
+
            table,
            footer: self.footer.move_with_state(state),
            ..self
        }
@@ -329,7 +390,7 @@ impl Patches {
            TableProps {
                items: self.props.patches.to_vec(),
                has_header: true,
-
                has_footer: true,
+
                has_footer: !self.props.show_search,
                widths: self.props.widths,
                focus: self.props.focus,
                cutoff: self.props.cutoff,
@@ -339,13 +400,24 @@ impl Patches {
    }

    fn render_footer<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect) {
-
        let filter = Line::from(
-
            [
-
                span::default(" ".to_string()),
-
                span::default(self.props.filter.to_string()).magenta().dim(),
-
            ]
-
            .to_vec(),
-
        );
+
        let search = if self.props.search.read().is_empty() {
+
            Line::from(
+
                [span::default(self.props.search.read().to_string())
+
                    .magenta()
+
                    .dim()]
+
                .to_vec(),
+
            )
+
        } else {
+
            Line::from(
+
                [
+
                    span::default(" / ".to_string()).magenta().dim(),
+
                    span::default(self.props.search.read().to_string())
+
                        .magenta()
+
                        .dim(),
+
                ]
+
                .to_vec(),
+
            )
+
        };

        let draft = Line::from(
            [
@@ -396,20 +468,23 @@ impl Patches {
            .progress_percentage(self.props.patches.len(), self.props.page_size);
        let progress = span::default(format!("{}%", progress)).dim();

-
        match self.props.filter.state() {
+
        match PatchItemFilter::from_str(&self.props.search.read())
+
            .unwrap_or_default()
+
            .status()
+
        {
            Some(state) => {
                let block = match state {
-
                    State::Draft => draft,
-
                    State::Open => open,
-
                    State::Merged => merged,
-
                    State::Archived => archived,
+
                    Status::Draft => draft,
+
                    Status::Open => open,
+
                    Status::Merged => merged,
+
                    Status::Archived => archived,
                };

                self.footer.render::<B>(
                    frame,
                    area,
                    FooterProps {
-
                        cells: [filter.into(), block.clone().into(), progress.clone().into()],
+
                        cells: [search.into(), block.clone().into(), progress.clone().into()],
                        widths: [
                            Constraint::Fill(1),
                            Constraint::Min(block.width() as u16),
@@ -427,7 +502,7 @@ impl Patches {
                    area,
                    FooterProps {
                        cells: [
-
                            filter.into(),
+
                            search.into(),
                            draft.clone().into(),
                            open.clone().into(),
                            merged.clone().into(),
@@ -456,24 +531,99 @@ impl Patches {

impl Render<()> for Patches {
    fn render<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect, _props: ()) {
-
        let layout = Layout::default()
-
            .direction(Direction::Vertical)
-
            .constraints(vec![
+
        let page_size = if self.props.show_search {
+
            let layout = Layout::vertical([Constraint::Length(3), Constraint::Min(1)]).split(area);
+

+
            self.render_header::<B>(frame, layout[0]);
+
            self.render_list::<B>(frame, layout[1]);
+

+
            layout[1].height as usize
+
        } else {
+
            let layout = Layout::vertical([
                Constraint::Length(3),
                Constraint::Min(1),
                Constraint::Length(3),
            ])
            .split(area);

-
        self.render_header::<B>(frame, layout[0]);
-
        self.render_list::<B>(frame, layout[1]);
-
        self.render_footer::<B>(frame, layout[2]);
+
            self.render_header::<B>(frame, layout[0]);
+
            self.render_list::<B>(frame, layout[1]);
+
            self.render_footer::<B>(frame, layout[2]);
+

+
            layout[1].height as usize
+
        };

-
        let page_size = layout[1].height as usize;
        if page_size != self.props.page_size {
-
            let _ = self
-
                .action_tx
-
                .send(Action::PageSize(layout[1].height as usize));
+
            let _ = self.action_tx.send(Action::PageSize(page_size));
        }
    }
}
+

+
pub struct SearchProps {}
+

+
pub struct Search {
+
    pub action_tx: UnboundedSender<Action>,
+
    pub input: TextField,
+
}
+

+
impl Widget<PatchesState, Action> for Search {
+
    fn new(state: &PatchesState, action_tx: UnboundedSender<Action>) -> Self
+
    where
+
        Self: Sized,
+
    {
+
        let mut input = TextField::new(state, action_tx.clone());
+
        input.set_text(&state.search.read().to_string());
+

+
        Self { action_tx, input }.move_with_state(state)
+
    }
+

+
    fn move_with_state(self, state: &PatchesState) -> Self
+
    where
+
        Self: Sized,
+
    {
+
        let mut input =
+
            <TextField as Widget<PatchesState, Action>>::move_with_state(self.input, state);
+
        input.set_text(&state.search.read().to_string());
+

+
        Self { input, ..self }
+
    }
+

+
    fn name(&self) -> &str {
+
        "filter-popup"
+
    }
+

+
    fn handle_key_event(&mut self, key: termion::event::Key) {
+
        match key {
+
            Key::Esc => {
+
                let _ = self.action_tx.send(Action::CloseSearch);
+
            }
+
            Key::Char('\n') => {
+
                let _ = self.action_tx.send(Action::ApplySearch);
+
            }
+
            _ => {
+
                <TextField as Widget<PatchesState, Action>>::handle_key_event(&mut self.input, key);
+
                let _ = self.action_tx.send(Action::UpdateSearch {
+
                    value: self.input.text().to_string(),
+
                });
+
            }
+
        }
+
    }
+
}
+

+
impl Render<SearchProps> for Search {
+
    fn render<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect, _props: SearchProps) {
+
        let layout = Layout::horizontal(Constraint::from_mins([0]))
+
            .horizontal_margin(1)
+
            .split(area);
+

+
        self.input.render::<B>(
+
            frame,
+
            layout[0],
+
            TextFieldProps {
+
                titles: ("/".into(), "Search".into()),
+
                show_cursor: true,
+
                inline_label: true,
+
            },
+
        );
+
    }
+
}