Radish alpha
r
rad:z39mP9rQAaGmERfUMPULfPUi473tY
Radicle terminal user interface
Radicle
Git
patch: Implement text-based search
Merged did:key:z6MkswQE...2C1V opened 2 years ago
  • adds a text field widget
  • implements a patch item filter w/ nom parser and fuzzy search
  • adds a search widget to the selection page
10 files changed +790 -170 ac76014a 79adc448
modified Cargo.lock
@@ -696,6 +696,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d"

[[package]]
+
name = "fuzzy-matcher"
+
version = "0.3.7"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94"
+
dependencies = [
+
 "thread_local",
+
]
+

+
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1088,6 +1097,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d"

[[package]]
+
name = "minimal-lexical"
+
version = "0.2.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
+

+
[[package]]
name = "miniz_oxide"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1128,6 +1143,16 @@ dependencies = [
]

[[package]]
+
name = "nom"
+
version = "7.1.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
+
dependencies = [
+
 "memchr",
+
 "minimal-lexical",
+
]
+

+
[[package]]
name = "nonempty"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1615,10 +1640,12 @@ name = "radicle-tui"
version = "0.3.0"
dependencies = [
 "anyhow",
+
 "fuzzy-matcher",
 "inquire",
 "lexopt",
 "libc",
 "log",
+
 "nom",
 "radicle",
 "radicle-surf",
 "radicle-term",
@@ -2283,6 +2310,16 @@ dependencies = [
]

[[package]]
+
name = "thread_local"
+
version = "1.1.8"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c"
+
dependencies = [
+
 "cfg-if",
+
 "once_cell",
+
]
+

+
[[package]]
name = "time"
version = "0.1.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
modified Cargo.toml
@@ -19,8 +19,10 @@ flux = ["dep:tokio", "dep:tokio-stream"]
anyhow = { version = "1" }
inquire = { version = "0.6.2", default-features = false, features = ["termion", "editor"] }
lexopt = { version = "0.3.0" }
+
fuzzy-matcher = "*"
libc = { version = "^0.2" }
log = { version = "0.4.19" }
+
nom = { version = "^7.1.0" }
radicle = { git = "https://seed.radicle.xyz/z3gqcJUoA1n9HaHKufZs5FCSGazv5" }
radicle-term = { git = "https://seed.radicle.xyz/z3gqcJUoA1n9HaHKufZs5FCSGazv5", package = "radicle-term" }
radicle-surf = { version = "0.18.0" }
modified bin/commands/patch.rs
@@ -12,9 +12,10 @@ use std::ffi::OsString;
use anyhow::anyhow;

use radicle::identity::RepoId;
+
use radicle::patch::Status;
use radicle_tui as tui;

-
use tui::common::cob::patch::{self, Filter, State};
+
use tui::common::cob::patch::{self, Filter};
use tui::common::log;

use crate::terminal;
@@ -98,19 +99,19 @@ impl Args for Options {
                    };
                }
                Long("all") if op == Some(OperationName::Select) => {
-
                    select_opts.filter = select_opts.filter.with_state(None);
+
                    select_opts.filter = select_opts.filter.with_status(None);
                }
                Long("draft") if op == Some(OperationName::Select) => {
-
                    select_opts.filter = select_opts.filter.with_state(Some(State::Draft));
+
                    select_opts.filter = select_opts.filter.with_status(Some(Status::Draft));
                }
                Long("archived") if op == Some(OperationName::Select) => {
-
                    select_opts.filter = select_opts.filter.with_state(Some(State::Archived));
+
                    select_opts.filter = select_opts.filter.with_status(Some(Status::Archived));
                }
                Long("merged") if op == Some(OperationName::Select) => {
-
                    select_opts.filter = select_opts.filter.with_state(Some(State::Merged));
+
                    select_opts.filter = select_opts.filter.with_status(Some(Status::Merged));
                }
                Long("open") if op == Some(OperationName::Select) => {
-
                    select_opts.filter = select_opts.filter.with_state(Some(State::Open));
+
                    select_opts.filter = select_opts.filter.with_status(Some(Status::Open));
                }
                Long("authored") if op == Some(OperationName::Select) => {
                    select_opts.filter = select_opts.filter.with_authored(true);
@@ -137,7 +138,7 @@ impl Args for Options {
        }

        if select_opts.mode == common::Mode::Id {
-
            select_opts.filter = Filter::default().with_state(None)
+
            select_opts.filter = Filter::default().with_status(None)
        }

        let op = match op.ok_or_else(|| anyhow!("an operation must be provided"))? {
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,
+
            },
+
        );
+
    }
+
}
modified src/common/cob/patch.rs
@@ -1,39 +1,15 @@
-
use std::fmt::Display;
-

use anyhow::Result;

use radicle::cob::patch::{Patch, PatchId};
use radicle::identity::Did;
use radicle::patch::cache::Patches;
+
use radicle::patch::Status;
use radicle::storage::git::Repository;
-
use radicle::{patch, Profile};
-

-
use super::format;
-

-
#[derive(Clone, Default, Debug, Eq, PartialEq)]
-
pub enum State {
-
    Draft,
-
    #[default]
-
    Open,
-
    Merged,
-
    Archived,
-
}
-

-
impl Display for State {
-
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-
        let state = match self {
-
            State::Draft => "draft",
-
            State::Open => "open",
-
            State::Merged => "merged",
-
            State::Archived => "archived",
-
        };
-
        f.write_str(state)
-
    }
-
}
+
use radicle::Profile;

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Filter {
-
    state: Option<State>,
+
    status: Option<Status>,
    authored: bool,
    authors: Vec<Did>,
}
@@ -41,7 +17,7 @@ pub struct Filter {
impl Default for Filter {
    fn default() -> Self {
        Self {
-
            state: Some(State::default()),
+
            status: Some(Status::default()),
            authored: false,
            authors: vec![],
        }
@@ -49,8 +25,8 @@ impl Default for Filter {
}

impl Filter {
-
    pub fn with_state(mut self, state: Option<State>) -> Self {
-
        self.state = state;
+
    pub fn with_status(mut self, status: Option<Status>) -> Self {
+
        self.status = status;
        self
    }

@@ -63,42 +39,13 @@ impl Filter {
        self.authors.push(author);
        self
    }
-

-
    pub fn state(&self) -> Option<State> {
-
        self.state.clone()
-
    }
-

-
    pub fn matches(&self, profile: &Profile, patch: &Patch) -> bool {
-
        let matches_state = match self.state {
-
            Some(State::Draft) => matches!(patch.state(), patch::State::Draft),
-
            Some(State::Open) => matches!(patch.state(), patch::State::Open { .. }),
-
            Some(State::Merged) => matches!(patch.state(), patch::State::Merged { .. }),
-
            Some(State::Archived) => matches!(patch.state(), patch::State::Archived),
-
            None => true,
-
        };
-

-
        let matches_authored = self
-
            .authored
-
            .then(|| *patch.author().id() == profile.did())
-
            .unwrap_or(true);
-

-
        let matches_authors = (!self.authors.is_empty())
-
            .then(|| {
-
                self.authors
-
                    .iter()
-
                    .any(|other| *patch.author().id() == *other)
-
            })
-
            .unwrap_or(true);
-

-
        matches_state && matches_authored && matches_authors
-
    }
}

impl ToString for Filter {
    fn to_string(&self) -> String {
        let mut filter = String::new();

-
        if let Some(state) = &self.state {
+
        if let Some(state) = &self.status {
            filter.push_str(&format!("is:{}", state));
            filter.push(' ');
        }
@@ -112,7 +59,7 @@ impl ToString for Filter {

            let mut authors = self.authors.iter().peekable();
            while let Some(author) = authors.next() {
-
                filter.push_str(&format::did(author));
+
                filter.push_str(&author.to_string());

                if authors.peek().is_some() {
                    filter.push(',');
@@ -136,3 +83,49 @@ pub fn find(profile: &Profile, repository: &Repository, id: &PatchId) -> Result<
    let cache = profile.patches(repository)?;
    Ok(cache.get(id)?)
}
+

+
#[cfg(test)]
+
mod tests {
+
    use std::str::FromStr;
+

+
    use anyhow::Result;
+
    use radicle::patch;
+

+
    use super::*;
+

+
    #[test]
+
    fn patch_filter_display_with_status_should_succeed() -> Result<()> {
+
        let actual = Filter::default().with_status(Some(patch::Status::Open));
+

+
        assert_eq!(String::from("is:open "), actual.to_string());
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn patch_filter_display_with_status_and_authored_should_succeed() -> Result<()> {
+
        let actual = Filter::default()
+
            .with_status(Some(patch::Status::Open))
+
            .with_authored(true);
+

+
        assert_eq!(String::from("is:open is:authored "), actual.to_string());
+

+
        Ok(())
+
    }
+

+
    #[test]
+
    fn patch_filter_display_with_status_and_author_should_succeed() -> Result<()> {
+
        let actual = Filter::default()
+
            .with_status(Some(patch::Status::Open))
+
            .with_author(Did::from_str(
+
                "did:key:z6MkswQE8gwZw924amKatxnNCXA55BMupMmRg7LvJuim2C1V",
+
            )?);
+

+
        assert_eq!(
+
            String::from("is:open authors:[z6MkswQE8gwZw924amKatxnNCXA55BMupMmRg7LvJuim2C1V]"),
+
            actual.to_string()
+
        );
+

+
        Ok(())
+
    }
+
}
modified src/flux/store.rs
@@ -92,3 +92,105 @@ where
        Ok(result)
    }
}
+

+
/// A `StateValue` that writes updates to an internal
+
/// buffer. This buffer can be applied or reset.
+
///
+
/// Reading from a `StateValue` will return the buffer if it's
+
/// not empty. It will return the actual value otherwise.
+
#[derive(Clone, Debug)]
+
pub struct StateValue<T>
+
where
+
    T: Clone,
+
{
+
    value: T,
+
    buffer: Option<T>,
+
}
+

+
impl<T> StateValue<T>
+
where
+
    T: Clone,
+
{
+
    pub fn new(value: T) -> Self {
+
        Self {
+
            value,
+
            buffer: None,
+
        }
+
    }
+

+
    pub fn apply(&mut self) {
+
        if let Some(buffer) = self.buffer.clone() {
+
            self.value = buffer;
+
        }
+
        self.buffer = None;
+
    }
+

+
    pub fn reset(&mut self) {
+
        self.buffer = None;
+
    }
+

+
    pub fn write(&mut self, value: T) {
+
        self.buffer = Some(value);
+
    }
+

+
    pub fn read(&self) -> T {
+
        if let Some(buffer) = self.buffer.clone() {
+
            buffer
+
        } else {
+
            self.value.clone()
+
        }
+
    }
+
}
+

+
#[cfg(test)]
+
mod test {
+
    use super::*;
+

+
    #[test]
+
    fn state_value_read_should_succeed() {
+
        let value = StateValue::new(0);
+
        assert_eq!(value.read(), 0);
+
    }
+

+
    #[test]
+
    fn state_value_read_buffer_should_succeed() {
+
        let mut value = StateValue::new(0);
+
        value.write(1);
+

+
        assert_eq!(value.read(), 1);
+
    }
+

+
    #[test]
+
    fn state_value_apply_should_succeed() {
+
        let mut value = StateValue::new(0);
+

+
        value.write(1);
+
        assert_eq!(value.read(), 1);
+

+
        value.apply();
+
        assert_eq!(value.read(), 1);
+
    }
+

+
    #[test]
+
    fn state_value_reset_should_succeed() {
+
        let mut value = StateValue::new(0);
+

+
        value.write(1);
+
        assert_eq!(value.read(), 1);
+

+
        value.reset();
+
        assert_eq!(value.read(), 0);
+
    }
+

+
    #[test]
+
    fn state_value_reset_after_apply_should_succeed() {
+
        let mut value = StateValue::new(0);
+

+
        value.write(1);
+
        assert_eq!(value.read(), 1);
+

+
        value.apply();
+
        value.reset();
+
        assert_eq!(value.read(), 1);
+
    }
+
}
modified src/flux/ui/cob.rs
@@ -1,18 +1,26 @@
+
use std::str::FromStr;
+

+
use nom::bytes::complete::{tag, take};
+
use nom::multi::separated_list0;
+
use nom::sequence::{delimited, preceded};
+
use nom::{IResult, Parser};
+

+
use radicle::cob::{Label, ObjectId, Timestamp, TypedId};
use radicle::crypto::PublicKey;
use radicle::git::Oid;
use radicle::identity::{Did, Identity};
-
use radicle::node::{Alias, NodeId};
-
use radicle::{issue, patch, Profile};
-
use ratatui::style::{Color, Style, Stylize};
-
use ratatui::widgets::Cell;
-

-
use radicle::cob::{Label, ObjectId, Timestamp, TypedId};
+
use radicle::issue;
use radicle::issue::{Issue, IssueId, Issues};
use radicle::node::notifications::{Notification, NotificationId, NotificationKind};
-
use radicle::node::AliasStore;
+
use radicle::node::{Alias, AliasStore, NodeId};
+
use radicle::patch;
use radicle::patch::{Patch, PatchId, Patches};
use radicle::storage::git::Repository;
use radicle::storage::{ReadRepository, ReadStorage, RefUpdate, WriteRepository};
+
use radicle::Profile;
+

+
use ratatui::style::{Color, Style, Stylize};
+
use ratatui::widgets::Cell;

use super::super::git;
use super::theme::style;
@@ -490,6 +498,107 @@ impl ToRow<9> for PatchItem {
    }
}

+
#[derive(Clone, Default, Debug, Eq, PartialEq)]
+
pub struct PatchItemFilter {
+
    status: Option<patch::Status>,
+
    authored: bool,
+
    authors: Vec<Did>,
+
    search: Option<String>,
+
}
+

+
impl PatchItemFilter {
+
    pub fn status(&self) -> Option<patch::Status> {
+
        self.status
+
    }
+

+
    pub fn matches(&self, patch: &PatchItem) -> bool {
+
        use fuzzy_matcher::skim::SkimMatcherV2;
+
        use fuzzy_matcher::FuzzyMatcher;
+

+
        let matcher = SkimMatcherV2::default();
+

+
        let matches_state = match self.status {
+
            Some(patch::Status::Draft) => matches!(patch.state, patch::State::Draft),
+
            Some(patch::Status::Open) => matches!(patch.state, patch::State::Open { .. }),
+
            Some(patch::Status::Merged) => matches!(patch.state, patch::State::Merged { .. }),
+
            Some(patch::Status::Archived) => matches!(patch.state, patch::State::Archived),
+
            None => true,
+
        };
+

+
        let matches_authored = if self.authored {
+
            patch.author.you
+
        } else {
+
            true
+
        };
+

+
        let matches_authors = (!self.authors.is_empty())
+
            .then(|| {
+
                self.authors
+
                    .iter()
+
                    .any(|other| patch.author.nid.unwrap() == **other)
+
            })
+
            .unwrap_or(true);
+

+
        let matches_search = match &self.search {
+
            Some(search) => match matcher.fuzzy_match(&patch.title, search) {
+
                Some(score) => score == 0 || score > 60,
+
                _ => false,
+
            },
+
            None => true,
+
        };
+

+
        matches_state && matches_authored && matches_authors && matches_search
+
    }
+
}
+

+
impl FromStr for PatchItemFilter {
+
    type Err = anyhow::Error;
+

+
    fn from_str(value: &str) -> Result<Self, Self::Err> {
+
        let mut status = None;
+
        let mut search = String::new();
+
        let mut authored = false;
+
        let mut authors = vec![];
+

+
        let mut authors_parser = |input| -> IResult<&str, Vec<&str>> {
+
            preceded(
+
                tag("authors:"),
+
                delimited(
+
                    tag("["),
+
                    separated_list0(tag(","), take(56_usize)),
+
                    tag("]"),
+
                ),
+
            )(input)
+
        };
+

+
        let parts = value.split(' ');
+
        for part in parts {
+
            match part {
+
                "is:open" => status = Some(patch::Status::Open),
+
                "is:merged" => status = Some(patch::Status::Merged),
+
                "is:archived" => status = Some(patch::Status::Archived),
+
                "is:draft" => status = Some(patch::Status::Draft),
+
                "is:authored" => authored = true,
+
                other => match authors_parser.parse(other) {
+
                    Ok((_, dids)) => {
+
                        for did in dids {
+
                            authors.push(Did::from_str(did)?);
+
                        }
+
                    }
+
                    _ => search.push_str(other),
+
                },
+
            }
+
        }
+

+
        Ok(Self {
+
            status,
+
            authored,
+
            authors,
+
            search: Some(search),
+
        })
+
    }
+
}
+

pub fn format_issue_state(state: &issue::State) -> (String, Color) {
    match state {
        issue::State::Open => (" ● ".into(), Color::Green),
@@ -551,3 +660,30 @@ pub fn format_assignees(assignees: &[(Option<PublicKey>, Option<Alias>, bool)])
    }
    output
}
+

+
#[cfg(test)]
+
mod tests {
+
    use anyhow::Result;
+

+
    use super::*;
+

+
    #[test]
+
    fn patch_item_filter_from_str_should_succeed() -> Result<()> {
+
        let search = r#"is:open is:authored authors:[did:key:z6MkkpTPzcq1ybmjQyQpyre15JUeMvZY6toxoZVpLZ8YarsB,did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx] cli"#;
+
        let actual = PatchItemFilter::from_str(search)?;
+

+
        let expected = PatchItemFilter {
+
            status: Some(patch::Status::Open),
+
            authored: true,
+
            authors: vec![
+
                Did::from_str("did:key:z6MkkpTPzcq1ybmjQyQpyre15JUeMvZY6toxoZVpLZ8YarsB")?,
+
                Did::from_str("did:key:z6Mku8hpprWTmCv3BqkssCYDfr2feUdyLSUnycVajFo9XVAx")?,
+
            ],
+
            search: Some("cli".to_string()),
+
        };
+

+
        assert_eq!(expected, actual);
+

+
        Ok(())
+
    }
+
}
modified src/flux/ui/widget.rs
@@ -1,4 +1,5 @@
pub mod container;
+
pub mod input;

use std::cmp;
use std::fmt::Debug;
added src/flux/ui/widget/input.rs
@@ -0,0 +1,179 @@
+
use termion::event::Key;
+

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

+
use ratatui::layout::{Constraint, Layout};
+
use ratatui::prelude::{Backend, Rect};
+
use ratatui::style::Stylize;
+
use ratatui::text::{Line, Span};
+

+
use super::{Render, Widget};
+

+
pub struct TextField {
+
    /// Current value of the input box
+
    text: String,
+
    /// Position of cursor in the editor area.
+
    cursor_position: usize,
+
}
+

+
impl TextField {
+
    pub fn text(&self) -> &str {
+
        &self.text
+
    }
+

+
    pub fn set_text(&mut self, new_text: &str) {
+
        if self.text != new_text {
+
            self.text = String::from(new_text);
+
            self.cursor_position = self.text.len();
+
        }
+
    }
+

+
    pub fn reset(&mut self) {
+
        self.cursor_position = 0;
+
        self.text.clear();
+
    }
+

+
    pub fn is_empty(&self) -> bool {
+
        self.text.is_empty()
+
    }
+

+
    fn move_cursor_left(&mut self) {
+
        let cursor_moved_left = self.cursor_position.saturating_sub(1);
+
        self.cursor_position = self.clamp_cursor(cursor_moved_left);
+
    }
+

+
    fn move_cursor_right(&mut self) {
+
        let cursor_moved_right = self.cursor_position.saturating_add(1);
+
        self.cursor_position = self.clamp_cursor(cursor_moved_right);
+
    }
+

+
    fn enter_char(&mut self, new_char: char) {
+
        self.text.insert(self.cursor_position, new_char);
+

+
        self.move_cursor_right();
+
    }
+

+
    fn delete_char(&mut self) {
+
        let is_not_cursor_leftmost = self.cursor_position != 0;
+
        if is_not_cursor_leftmost {
+
            // Method "remove" is not used on the saved text for deleting the selected char.
+
            // Reason: Using remove on String works on bytes instead of the chars.
+
            // Using remove would require special care because of char boundaries.
+

+
            let current_index = self.cursor_position;
+
            let from_left_to_current_index = current_index - 1;
+

+
            // Getting all characters before the selected character.
+
            let before_char_to_delete = self.text.chars().take(from_left_to_current_index);
+
            // Getting all characters after selected character.
+
            let after_char_to_delete = self.text.chars().skip(current_index);
+

+
            // Put all characters together except the selected one.
+
            // By leaving the selected one out, it is forgotten and therefore deleted.
+
            self.text = before_char_to_delete.chain(after_char_to_delete).collect();
+
            self.move_cursor_left();
+
        }
+
    }
+

+
    fn clamp_cursor(&self, new_cursor_pos: usize) -> usize {
+
        new_cursor_pos.clamp(0, self.text.len())
+
    }
+
}
+

+
impl<S, A> Widget<S, A> for TextField {
+
    fn new(_state: &S, _action_tx: UnboundedSender<A>) -> Self {
+
        Self {
+
            //
+
            text: String::new(),
+
            cursor_position: 0,
+
        }
+
    }
+

+
    fn move_with_state(self, _state: &S) -> Self
+
    where
+
        Self: Sized,
+
    {
+
        Self { ..self }
+
    }
+

+
    fn name(&self) -> &str {
+
        "Input Box"
+
    }
+

+
    fn handle_key_event(&mut self, key: Key) {
+
        match key {
+
            Key::Char(to_insert)
+
                if (key != Key::Alt('\n'))
+
                    && (key != Key::Char('\n'))
+
                    && (key != Key::Ctrl('\n')) =>
+
            {
+
                self.enter_char(to_insert);
+
            }
+
            Key::Backspace => {
+
                self.delete_char();
+
            }
+
            Key::Left => {
+
                self.move_cursor_left();
+
            }
+
            Key::Right => {
+
                self.move_cursor_right();
+
            }
+
            _ => {}
+
        }
+
    }
+
}
+

+
pub struct TextFieldProps {
+
    pub titles: (String, String),
+
    pub inline_label: bool,
+
    pub show_cursor: bool,
+
}
+

+
impl Render<TextFieldProps> for TextField {
+
    fn render<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect, props: TextFieldProps) {
+
        let layout = Layout::vertical(Constraint::from_lengths([1, 1])).split(area);
+

+
        let input = self.text.as_str();
+
        let label = format!(" {} ", props.titles.0);
+
        let overline = String::from("▔").repeat(area.width as usize);
+
        let cursor_pos = self.cursor_position as u16;
+

+
        if props.inline_label {
+
            let top_layout = Layout::horizontal([
+
                Constraint::Length(label.chars().count() as u16),
+
                Constraint::Length(1),
+
                Constraint::Min(1),
+
            ])
+
            .split(layout[0]);
+

+
            let label = Span::from(label.clone()).magenta().dim().reversed();
+
            let input = Span::from(input).reset();
+

+
            let overline = Line::from([Span::raw(overline).magenta().dim()].to_vec());
+

+
            frame.render_widget(label, top_layout[0]);
+
            frame.render_widget(input, top_layout[2]);
+
            frame.render_widget(overline, layout[1]);
+

+
            if props.show_cursor {
+
                frame.set_cursor(top_layout[2].x + cursor_pos, top_layout[2].y)
+
            }
+
        } else {
+
            let top = Line::from([Span::from(input).reset()].to_vec());
+
            let bottom = Line::from(
+
                [
+
                    Span::from(label).magenta().dim().reversed(),
+
                    Span::raw(overline).magenta().dim(),
+
                ]
+
                .to_vec(),
+
            );
+

+
            frame.render_widget(top, layout[0]);
+
            frame.render_widget(bottom, layout[1]);
+

+
            if props.show_cursor {
+
                frame.set_cursor(area.x + cursor_pos, area.y)
+
            }
+
        }
+
    }
+
}