Radish alpha
r
Radicle terminal user interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
patch: Implement select app w/ flux
Erik Kundt committed 2 years ago
commit 49a6dfe21f90c5624293d6edec5187adc2437ef7
parent e2c75514309dccc02d28308293ca18eee3a53678
8 files changed +530 -47
modified Cargo.lock
@@ -424,31 +424,6 @@ dependencies = [
]

[[package]]
-
name = "crossterm"
-
version = "0.27.0"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df"
-
dependencies = [
-
 "bitflags 2.4.1",
-
 "crossterm_winapi",
-
 "libc",
-
 "mio",
-
 "parking_lot",
-
 "signal-hook",
-
 "signal-hook-mio",
-
 "winapi",
-
]
-

-
[[package]]
-
name = "crossterm_winapi"
-
version = "0.9.1"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
-
dependencies = [
-
 "winapi",
-
]
-

-
[[package]]
name = "crypto-bigint"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1128,7 +1103,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f3d0b296e374a4e6f3c7b0a1f5a51d748a0d34c85e7dc48fc3fa9a87657fe09"
dependencies = [
 "libc",
-
 "log",
 "wasi 0.11.0+wasi-snapshot-preview1",
 "windows-sys 0.48.0",
]
@@ -1717,7 +1691,6 @@ dependencies = [
 "bitflags 2.4.1",
 "cassowary",
 "compact_str",
-
 "crossterm",
 "indoc",
 "itertools 0.12.1",
 "lru",
@@ -1944,17 +1917,6 @@ dependencies = [
]

[[package]]
-
name = "signal-hook-mio"
-
version = "0.2.3"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "29ad2e15f37ec9a6cc544097b78a1ec90001e9f71b81338ca39f430adaca99af"
-
dependencies = [
-
 "libc",
-
 "mio",
-
 "signal-hook",
-
]
-

-
[[package]]
name = "signal-hook-registry"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
modified Cargo.toml
@@ -24,7 +24,7 @@ log = { version = "0.4.19" }
radicle = { git = "https://github.com/radicle-dev/heartwood" }
radicle-term = { git = "https://github.com/radicle-dev/heartwood", package = "radicle-term" }
radicle-surf = { version = "0.18.0" }
-
ratatui = { git = "https://github.com/erak/ratatui", branch = "termion-cursor", features = ["all-widgets", "termion"] }
+
ratatui = { git = "https://github.com/erak/ratatui", branch = "termion-cursor", default-features = false, features = ["all-widgets", "termion"] }
simple-logging = { version = "2.0.2" }
serde = { version = "1.0", features = ["derive"] }
serde_json = { version = "1.0" }
modified bin/commands/issue.rs
@@ -164,7 +164,7 @@ pub async fn run(options: Options, _ctx: impl terminal::Context) -> anyhow::Resu
            let profile = terminal::profile()?;
            let repository = profile.storage.repository(rid).unwrap();

-
            log::enable(&profile, "inbox", "select")?;
+
            log::enable(&profile, "issue", "select")?;

            let context = flux::select::Context {
                profile,
modified bin/commands/patch.rs
@@ -1,10 +1,12 @@
+
#[path = "patch/common.rs"]
+
mod common;
+
#[cfg(feature = "flux")]
+
#[path = "patch/flux.rs"]
+
mod flux;
#[cfg(feature = "realm")]
#[path = "patch/realm.rs"]
mod realm;

-
#[path = "patch/common.rs"]
-
mod common;
-

use std::ffi::OsString;

use anyhow::anyhow;
@@ -12,6 +14,7 @@ use anyhow::anyhow;
use radicle_tui as tui;

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

use crate::terminal;
use crate::terminal::args::{Args, Error, Help};
@@ -133,7 +136,6 @@ impl Args for Options {
#[cfg(feature = "realm")]
pub fn run(options: Options, _ctx: impl terminal::Context) -> anyhow::Result<()> {
    use tui::common::context;
-
    use tui::common::log;
    use tui::realm::Window;

    pub const FPS: u64 = 60;
@@ -162,10 +164,35 @@ pub fn run(options: Options, _ctx: impl terminal::Context) -> anyhow::Result<()>
}

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

+
    let (_, rid) = radicle::rad::cwd()
+
        .map_err(|_| anyhow!("this command must be run in the context of a project"))?;
+

    match options.op {
-
        Operation::Select { opts: _ } => {
-
            anyhow::bail!("operation not yet implemented with flux")
+
        Operation::Select { opts } => {
+
            let profile = terminal::profile()?;
+
            let repository = profile.storage.repository(rid).unwrap();
+

+
            log::enable(&profile, "patch", "select")?;
+

+
            let context = flux::select::Context {
+
                profile,
+
                repository,
+
                mode: opts.mode,
+
                filter: opts.filter.clone(),
+
            };
+
            let output = flux::select::App::new(context).run().await?;
+

+
            let output = output
+
                .map(|o| serde_json::to_string(&o).unwrap_or_default())
+
                .unwrap_or_default();
+

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

+
    Ok(())
}
modified bin/commands/patch/common.rs
@@ -1,3 +1,7 @@
+
use std::fmt::Display;
+

+
use serde::Serialize;
+

/// The application's mode. It tells the application
/// which widgets to render and which output to produce.
///
@@ -8,3 +12,36 @@ pub enum Mode {
    Operation,
    Id,
}
+

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

+
impl Display for PatchOperation {
+
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+
        match self {
+
            PatchOperation::Show => {
+
                write!(f, "show")
+
            }
+
            PatchOperation::Checkout => {
+
                write!(f, "checkout")
+
            }
+
            PatchOperation::Delete => {
+
                write!(f, "delete")
+
            }
+
            PatchOperation::Edit => {
+
                write!(f, "edit")
+
            }
+
            PatchOperation::Comment => {
+
                write!(f, "comment")
+
            }
+
        }
+
    }
+
}
added bin/commands/patch/flux.rs
@@ -0,0 +1,2 @@
+
#[path = "flux/select.rs"]
+
pub mod select;
added bin/commands/patch/flux/select.rs
@@ -0,0 +1,122 @@
+
#[path = "select/ui.rs"]
+
mod ui;
+

+
use anyhow::Result;
+

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

+
use radicle_tui as tui;
+

+
use tui::common::cob::patch::{self, Filter};
+
use tui::flux::store::{State, Store};
+
use tui::flux::termination::{self, Interrupted};
+
use tui::flux::ui::cob::PatchItem;
+
use tui::flux::ui::Frontend;
+
use tui::Exit;
+

+
use ui::ListPage;
+

+
use super::super::common::Mode;
+

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

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

+
pub struct App {
+
    context: Context,
+
}
+

+
#[derive(Clone, Debug)]
+
pub struct PatchesState {
+
    patches: Vec<PatchItem>,
+
    selected: Option<PatchItem>,
+
    mode: Mode,
+
    filter: Filter,
+
}
+

+
impl TryFrom<&Context> for PatchesState {
+
    type Error = anyhow::Error;
+

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

+
        let mut items = vec![];
+

+
        // Convert into UI items
+
        for patch in patches {
+
            if let Ok(item) = PatchItem::new(&context.profile, &context.repository, patch.clone()) {
+
                items.push(item);
+
            }
+
        }
+

+
        // 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(),
+
        })
+
    }
+
}
+

+
pub enum Action {
+
    Exit { selection: Option<Selection> },
+
    Select { item: PatchItem },
+
}
+

+
impl State<Action, Selection> for PatchesState {
+
    fn tick(&self) {}
+

+
    fn handle_action(&mut self, action: Action) -> Option<Exit<Selection>> {
+
        match action {
+
            Action::Select { item } => {
+
                self.selected = Some(item);
+
                None
+
            }
+
            Action::Exit { selection } => Some(Exit { value: selection }),
+
        }
+
    }
+
}
+

+
impl App {
+
    pub fn new(context: Context) -> Self {
+
        Self { context }
+
    }
+

+
    pub async fn run(&self) -> Result<Option<Selection>> {
+
        let (terminator, mut interrupt_rx) = termination::create_termination();
+
        let (store, state_rx) = Store::<Action, PatchesState, Selection>::new();
+
        let (frontend, action_rx) = Frontend::<Action>::new();
+
        let state = PatchesState::try_from(&self.context)?;
+

+
        tokio::try_join!(
+
            store.main_loop(state, terminator, action_rx, interrupt_rx.resubscribe()),
+
            frontend.main_loop::<PatchesState, ListPage, Selection>(
+
                state_rx,
+
                interrupt_rx.resubscribe()
+
            ),
+
        )?;
+

+
        if let Ok(reason) = interrupt_rx.recv().await {
+
            match reason {
+
                Interrupted::User { payload } => Ok(payload),
+
                Interrupted::OsSignal => anyhow::bail!("exited because of an os sig int"),
+
            }
+
        } else {
+
            anyhow::bail!("exited because of an unexpected error");
+
        }
+
    }
+
}
added bin/commands/patch/flux/select/ui.rs
@@ -0,0 +1,333 @@
+
use std::vec;
+

+
use ratatui::style::Stylize;
+
use tokio::sync::mpsc::UnboundedSender;
+

+
use termion::event::Key;
+

+
use ratatui::backend::Backend;
+
use ratatui::layout::{Constraint, Rect};
+

+
use radicle_tui as tui;
+

+
use tui::common::cob::patch::Filter;
+
use tui::flux::ui::cob::PatchItem;
+
use tui::flux::ui::span;
+
use tui::flux::ui::widget::{
+
    FooterProps, Render, Shortcut, Shortcuts, ShortcutsProps, Table, TableProps, Widget,
+
};
+
use tui::Selection;
+

+
use crate::tui_patch::common::Mode;
+
use crate::tui_patch::common::PatchOperation;
+

+
use super::{Action, PatchesState};
+

+
pub struct ListPageProps {
+
    selected: Option<PatchItem>,
+
    mode: Mode,
+
}
+

+
impl From<&PatchesState> for ListPageProps {
+
    fn from(state: &PatchesState) -> Self {
+
        Self {
+
            selected: state.selected.clone(),
+
            mode: state.mode.clone(),
+
        }
+
    }
+
}
+

+
pub struct ListPage {
+
    /// Action sender
+
    pub action_tx: UnboundedSender<Action>,
+
    /// State mapped props
+
    props: ListPageProps,
+
    /// Notification widget
+
    patches: Patches,
+
    /// Shortcut widget
+
    shortcuts: Shortcuts<Action>,
+
}
+

+
impl Widget<PatchesState, Action> for ListPage {
+
    fn new(state: &PatchesState, action_tx: UnboundedSender<Action>) -> Self
+
    where
+
        Self: Sized,
+
    {
+
        Self {
+
            action_tx: action_tx.clone(),
+
            props: ListPageProps::from(state),
+
            patches: Patches::new(state, action_tx.clone()),
+
            shortcuts: Shortcuts::new(state, action_tx.clone()),
+
        }
+
        .move_with_state(state)
+
    }
+

+
    fn move_with_state(self, state: &PatchesState) -> Self
+
    where
+
        Self: Sized,
+
    {
+
        ListPage {
+
            patches: self.patches.move_with_state(state),
+
            shortcuts: self.shortcuts.move_with_state(state),
+
            props: ListPageProps::from(state),
+
            ..self
+
        }
+
    }
+

+
    fn name(&self) -> &str {
+
        "list-page"
+
    }
+

+
    fn handle_key_event(&mut self, key: termion::event::Key) {
+
        match key {
+
            Key::Char('q') => {
+
                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 _ = self.action_tx.send(Action::Exit {
+
                        selection: Some(Selection {
+
                            operation: Some(PatchOperation::Checkout.to_string()),
+
                            ids: vec![selected.id],
+
                            args: vec![],
+
                        }),
+
                    });
+
                }
+
            }
+
            Key::Char('m') => {
+
                if let Some(selected) = &self.props.selected {
+
                    let _ = self.action_tx.send(Action::Exit {
+
                        selection: Some(Selection {
+
                            operation: Some(PatchOperation::Comment.to_string()),
+
                            ids: vec![selected.id],
+
                            args: vec![],
+
                        }),
+
                    });
+
                }
+
            }
+
            Key::Char('e') => {
+
                if let Some(selected) = &self.props.selected {
+
                    let _ = self.action_tx.send(Action::Exit {
+
                        selection: Some(Selection {
+
                            operation: Some(PatchOperation::Edit.to_string()),
+
                            ids: vec![selected.id],
+
                            args: vec![],
+
                        }),
+
                    });
+
                }
+
            }
+
            Key::Char('d') => {
+
                if let Some(selected) = &self.props.selected {
+
                    let _ = self.action_tx.send(Action::Exit {
+
                        selection: Some(Selection {
+
                            operation: Some(PatchOperation::Delete.to_string()),
+
                            ids: vec![selected.id],
+
                            args: vec![],
+
                        }),
+
                    });
+
                }
+
            }
+
            _ => {
+
                <Patches as Widget<PatchesState, Action>>::handle_key_event(&mut self.patches, key);
+
            }
+
        }
+
    }
+
}
+

+
impl Render<()> for ListPage {
+
    fn render<B: Backend>(&self, frame: &mut ratatui::Frame, _area: Rect, _props: ()) {
+
        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"), Shortcut::new("q", "quit")],
+
            Mode::Operation => vec![
+
                Shortcut::new("enter", "show"),
+
                Shortcut::new("c", "checkout"),
+
                Shortcut::new("m", "comment"),
+
                Shortcut::new("e", "edit"),
+
                Shortcut::new("d", "delete"),
+
                Shortcut::new("q", "quit"),
+
            ],
+
        };
+

+
        self.patches.render::<B>(frame, layout.component, ());
+
        self.shortcuts.render::<B>(
+
            frame,
+
            layout.shortcuts,
+
            ShortcutsProps {
+
                shortcuts,
+
                divider: '∙',
+
            },
+
        );
+
    }
+
}
+

+
struct PatchesProps {
+
    patches: Vec<PatchItem>,
+
    filter: Filter,
+
}
+

+
impl From<&PatchesState> for PatchesProps {
+
    fn from(state: &PatchesState) -> Self {
+
        Self {
+
            patches: state.patches.clone(),
+
            filter: state.filter.clone(),
+
        }
+
    }
+
}
+

+
struct Patches {
+
    /// Action sender
+
    action_tx: UnboundedSender<Action>,
+
    /// State mapped props
+
    props: PatchesProps,
+
    /// Notification table
+
    table: Table<Action>,
+
}
+

+
impl Widget<PatchesState, Action> for Patches {
+
    fn new(state: &PatchesState, action_tx: UnboundedSender<Action>) -> Self {
+
        Self {
+
            action_tx: action_tx.clone(),
+
            props: PatchesProps::from(state),
+
            table: Table::new(state, action_tx.clone()),
+
        }
+
    }
+

+
    fn move_with_state(self, state: &PatchesState) -> Self
+
    where
+
        Self: Sized,
+
    {
+
        Self {
+
            props: PatchesProps::from(state),
+
            table: self.table.move_with_state(state),
+
            ..self
+
        }
+
    }
+

+
    fn name(&self) -> &str {
+
        "notification-list"
+
    }
+

+
    fn handle_key_event(&mut self, key: Key) {
+
        match key {
+
            Key::Up => {
+
                self.table.prev();
+

+
                let selected = self
+
                    .table
+
                    .selected()
+
                    .and_then(|selected| self.props.patches.get(selected));
+

+
                // TODO: propagate error
+
                if let Some(notif) = selected {
+
                    let _ = self.action_tx.send(Action::Select {
+
                        item: notif.clone(),
+
                    });
+
                }
+
            }
+
            Key::Down => {
+
                self.table.next(self.props.patches.len());
+

+
                let selected = self
+
                    .table
+
                    .selected()
+
                    .and_then(|selected| self.props.patches.get(selected));
+

+
                // TODO: propagate error
+
                if let Some(notif) = selected {
+
                    let _ = self.action_tx.send(Action::Select {
+
                        item: notif.clone(),
+
                    });
+
                }
+
            }
+
            _ => {}
+
        }
+
    }
+
}
+

+
impl Render<()> for Patches {
+
    fn render<B: Backend>(&self, frame: &mut ratatui::Frame, area: Rect, _props: ()) {
+
        let header = [
+
            String::from(" ● ").into(),
+
            String::from("ID").into(),
+
            String::from("Title").into(),
+
            String::from("Author").into(),
+
            String::from("").into(),
+
            String::from("Head").into(),
+
            String::from("+").into(),
+
            String::from("- ").into(),
+
            String::from("Updated").into(),
+
        ];
+

+
        let widths = [
+
            Constraint::Length(3),
+
            Constraint::Length(8),
+
            Constraint::Fill(1),
+
            Constraint::Length(16),
+
            Constraint::Length(16),
+
            Constraint::Length(8),
+
            Constraint::Length(6),
+
            Constraint::Length(6),
+
            Constraint::Length(16),
+
        ];
+

+
        let progress = {
+
            let step = self
+
                .table
+
                .selected()
+
                .map(|selected| selected.saturating_add(1).to_string())
+
                .unwrap_or("-".to_string());
+
            let length = self.props.patches.len().to_string();
+

+
            span::badge(format!("{}/{}", step, length))
+
        };
+

+
        let footer = FooterProps {
+
            cells: [
+
                span::badge("/".to_string()),
+
                span::default(self.props.filter.to_string()).magenta().dim(),
+
                String::from("").into(),
+
                progress.clone(),
+
            ]
+
            .to_vec(),
+
            widths: [
+
                Constraint::Length(3),
+
                Constraint::Fill(1),
+
                Constraint::Fill(1),
+
                Constraint::Length(progress.width() as u16),
+
            ]
+
            .to_vec(),
+
        };
+

+
        self.table.render::<B>(
+
            frame,
+
            area,
+
            TableProps {
+
                items: self.props.patches.to_vec(),
+
                focus: false,
+
                widths,
+
                header,
+
                footer: Some(footer),
+
                cutoff: 200,
+
                cutoff_after: 5,
+
            },
+
        );
+
    }
+
}