use std::str::FromStr; use std::sync::{Arc, Mutex}; use anyhow::{anyhow, Result}; use radicle::prelude::RepoId; use radicle::storage::ReadStorage; use ratatui::widgets::Clear; use serde::Serialize; use radicle::patch::cache::Patches; use radicle::patch::PatchId; use radicle::Profile; use ratatui::layout::{Alignment, Constraint, Layout, Position}; use ratatui::style::Stylize; use ratatui::text::Span; use ratatui::{Frame, Viewport}; use radicle_tui as tui; use tui::event::Key; use tui::store; use tui::task::{Process, Task}; use tui::ui; use tui::ui::layout::Spacing; use tui::ui::theme::Theme; use tui::ui::widget::{ Borders, Column, ContainerState, TableState, TextEditState, TextViewState, Window, }; use tui::ui::{BufferedValue, Show, Ui}; use tui::{Channel, Exit}; use crate::settings; use crate::ui::items::filter::Filter; use crate::ui::items::patch::filter::PatchFilter; use crate::ui::items::patch::Patch; const HELP: &str = r#"# Generic keybindings `↑,k`: move cursor one line up `↓,j: move cursor one line down `PageUp`: move cursor one page up `PageDown`: move cursor one page down `Home`: move cursor to the first line `End`: move cursor to the last line `1-9`: focus section `Tab`: focus next section `BackTab`: focus previous section `Esc`: Cancel `q`: Quit # Specific keybindings `enter`: Show patch `c`: Checkout patch `d`: Show patch diff `/`: Search `?`: Show help # Searching Examples: state=open bugfix state=merged author=(did:key:... or did:key:...)"#; #[derive(Clone, Debug, Eq, PartialEq, Serialize)] pub struct OperationArguments { id: PatchId, search: String, } impl OperationArguments { pub fn id(&self) -> PatchId { self.id } pub fn search(&self) -> String { self.search.clone() } } impl TryFrom<(&Vec, &AppState)> for OperationArguments { type Error = anyhow::Error; fn try_from(value: (&Vec, &AppState)) -> Result { let (patches, state) = value; let selected = state.patches.selected(); let id = selected .and_then(|s| patches.get(s)) .ok_or(anyhow!("No patch selected"))? .id; let search = state.search.read().text; Ok(Self { id, search }) } } /// The selected patch operation returned by the operation /// selection widget. #[derive(Clone, Debug, Eq, PartialEq, Serialize)] pub enum PatchOperation { Checkout { args: OperationArguments }, Diff { args: OperationArguments }, Show { args: OperationArguments }, _Review { args: OperationArguments }, } type Selection = tui::Selection; #[derive(Clone, Debug)] pub struct Context { pub profile: Profile, pub rid: RepoId, pub filter: PatchFilter, pub patch_id: Option, pub search: Option, } #[derive(Clone, Debug)] pub struct Loader { context: Context, } impl Loader { fn new(context: Context) -> Self { Self { context } } } #[derive(Debug)] pub struct PatchLoader { context: Context, } impl PatchLoader { fn new(context: Context) -> Self { PatchLoader { context } } } impl Task for PatchLoader { type Return = Message; fn run(&self) -> anyhow::Result> { let context = &self.context; let profile = context.profile.clone(); let repo = profile.storage.repository(context.rid)?; let cache = profile.patches(&repo)?; let patches = cache .list()? .filter_map(|patch| patch.ok()) .flat_map(|patch| Patch::new(&context.profile, &repo, patch.clone()).ok()) .collect::>(); Ok(vec![Message::Loaded(patches)]) } } impl Process for Loader { async fn process(&mut self, message: Message) -> anyhow::Result> { match message { Message::Initialize | Message::Reload => { let loader = PatchLoader::new(self.context.clone()); let messages = tokio::spawn(async move { loader.run() }).await.unwrap()?; Ok(messages) } _ => Ok(vec![]), } } } pub struct Tui { context: Context, } impl Tui { pub fn new(context: Context) -> Self { Self { context } } pub async fn run(&self) -> Result> { let viewport = Viewport::Inline(20); let channel = Channel::default(); let state = App::try_from(&self.context)?; tui::im( state, viewport, channel, vec![Loader::new(self.context.clone())], ) .await } } #[derive(Clone, Debug)] pub enum Change { Page { page: Page, }, MainGroup { state: ContainerState, }, Patches { state: TableState, }, Search { search: BufferedValue, }, Help { state: TextViewState, }, } #[derive(Clone, Debug)] pub enum Message { Initialize, Changed(Change), ShowSearch, HideSearch { apply: bool }, Reload, Loaded(Vec), Exit { operation: Option }, Quit, } #[derive(Clone, Debug)] pub enum Page { Main, Help, } #[derive(Clone, Debug)] pub struct AppState { page: Page, main_group: ContainerState, patches: TableState, search: BufferedValue, show_search: bool, help: TextViewState, filter: PatchFilter, loading: bool, initialized: bool, theme: Theme, } #[derive(Clone, Debug)] pub struct App { patches: Arc>>, state: AppState, } impl TryFrom<&Context> for App { type Error = anyhow::Error; fn try_from(context: &Context) -> Result { let settings = settings::Settings::default(); let theme = settings::configure_theme(&settings); let repo = &context.profile.storage.repository(context.rid)?; let cache = &context.profile.patches(repo)?; let mut patches = cache .list()? .filter_map(|patch| patch.ok()) .flat_map(|patch| Patch::without_stats(&context.profile, patch.clone()).ok()) .collect::>(); patches.sort_by(|a, b| b.timestamp.cmp(&a.timestamp)); let search = context.search.as_ref().map(|s| s.trim().to_string()); let (search, filter) = match search { Some(search) => ( search.clone(), PatchFilter::from_str(search.trim()).unwrap_or(PatchFilter::Invalid), ), None => { let filter = context.filter.clone(); (filter.to_string().trim().to_string(), filter) } }; Ok(App { patches: Arc::new(Mutex::new(patches.clone())), state: AppState { page: Page::Main, main_group: ContainerState::new(3, Some(0)), patches: TableState::new(Some( context .patch_id .and_then(|id| { patches .iter() .filter(|item| filter.matches(item)) .position(|item| item.id == id) }) .unwrap_or(0), )), search: BufferedValue::new(TextEditState { text: search.clone(), cursor: search.len(), }), show_search: false, help: TextViewState::new(Position::default()), filter, loading: false, initialized: false, theme, }, }) } } impl store::Update for App { type Return = Selection; fn update(&mut self, message: Message) -> Option> { match message { Message::Initialize => { self.state.loading = true; self.state.initialized = true; None } Message::Reload => { self.state.loading = true; None } Message::Loaded(patches) => { self.apply_patches(patches); self.apply_sorting(); self.state.loading = false; None } Message::Quit => Some(Exit { value: None }), Message::Exit { operation } => Some(Exit { value: Some(Selection { operation, args: vec![], }), }), Message::ShowSearch => { self.state.main_group = ContainerState::new(3, None); self.state.show_search = true; None } Message::HideSearch { apply } => { self.state.main_group = ContainerState::new(3, Some(0)); self.state.show_search = false; if apply { self.state.search.apply(); } else { self.state.search.reset(); } self.state.filter = PatchFilter::from_str(&self.state.search.read().text) .unwrap_or(PatchFilter::Invalid); None } Message::Changed(changed) => match changed { Change::Page { page } => { self.state.page = page; None } Change::MainGroup { state } => { self.state.main_group = state; None } Change::Patches { state } => { self.state.patches = state; None } Change::Search { search } => { self.state.search = search; self.state.filter = PatchFilter::from_str(&self.state.search.read().text) .unwrap_or(PatchFilter::Invalid); self.state.patches.select_first(); None } Change::Help { state } => { self.state.help = state; None } }, } } } impl Show for App { fn show(&self, ctx: &ui::Context, frame: &mut Frame) -> Result<()> { Window::default().show(ctx, self.state.theme.clone(), |ui| { // Initialize if !self.state.initialized { ui.send_message(Message::Initialize); } match self.state.page { Page::Main => { let show_search = self.state.show_search; let mut page_focus = if show_search { Some(1) } else { Some(0) }; ui.container( Layout::vertical([Constraint::Fill(1), Constraint::Length(2)]), &mut page_focus, |ui| { let mut group_focus = self.state.main_group.focus(); let group = ui.container( ui::Layout::Expandable3 { left_only: true }, &mut group_focus, |ui| { self.show_browser(frame, ui); }, ); if group.response.changed { ui.send_message(Message::Changed(Change::MainGroup { state: ContainerState::new(3, group_focus), })); } if show_search { self.show_browser_search(frame, ui); } else if let Some(0) = group_focus { self.show_browser_footer(frame, ui); } }, ); if !show_search && ui.has_input(|key| key == Key::Char('?')) { ui.send_message(Message::Changed(Change::Page { page: Page::Help })); } } Page::Help => { let layout = Layout::vertical([ Constraint::Length(3), Constraint::Fill(1), Constraint::Length(1), Constraint::Length(1), ]); ui.container(layout, &mut Some(1), |ui| { self.show_help_text(frame, ui); self.show_help_context(frame, ui); ui.shortcuts(frame, &[("?", "close")], '∙', Alignment::Left); }); if ui.has_input(|key| key == Key::Char('?')) { ui.send_message(Message::Changed(Change::Page { page: Page::Main })); } } } if ui.has_input(|key| key == Key::Char('q')) { ui.send_message(Message::Quit); } if ui.has_input(|key| key == Key::Ctrl('c')) { ui.send_message(Message::Quit); } }); Ok(()) } } impl App { pub fn show_browser(&self, frame: &mut Frame, ui: &mut Ui) { let patches = self.patches.lock().unwrap(); let patches = patches .iter() .filter(|patch| self.state.filter.matches(patch)) .cloned() .collect::>(); let mut selected = self.state.patches.selected(); let header = [ Column::new(Span::raw(" ● ").bold(), Constraint::Length(3)), Column::new(Span::raw("ID").bold(), Constraint::Length(8)), Column::new(Span::raw("Title").bold(), Constraint::Fill(1)), Column::new(Span::raw("Author").bold(), Constraint::Length(16)).hide_small(), Column::new("", Constraint::Length(16)).hide_medium(), Column::new(Span::raw("Head").bold(), Constraint::Length(8)).hide_small(), Column::new(Span::raw("+").bold(), Constraint::Length(6)).hide_small(), Column::new(Span::raw("-").bold(), Constraint::Length(6)).hide_small(), Column::new(Span::raw("Updated").bold(), Constraint::Length(16)).hide_small(), ]; ui.layout( Layout::vertical([Constraint::Length(3), Constraint::Min(1)]), Some(1), |ui| { ui.column_bar(frame, header.to_vec(), Spacing::from(1), Some(Borders::Top)); let table = ui.table( frame, &mut selected, &patches, header.to_vec(), Some("No patches found".into()), Spacing::from(1), Some(Borders::BottomSides), ); if table.changed { ui.send_message(Message::Changed(Change::Patches { state: TableState::new(selected), })); } if self.state.loading { self.show_loading_popup(frame, ui); } }, ); // TODO(erikli): Should only work if table has focus if ui.has_input(|key| key == Key::Char('/')) { ui.send_message(Message::ShowSearch); } if ui.has_input(|key| key == Key::Char('r')) { ui.send_message(Message::Reload); } if let Ok(args) = OperationArguments::try_from((&patches, &self.state)) { if ui.has_input(|key| key == Key::Enter) { ui.send_message(Message::Exit { operation: Some(PatchOperation::Show { args: args.clone() }), }); } if ui.has_input(|key| key == Key::Char('d')) { ui.send_message(Message::Exit { operation: Some(PatchOperation::Diff { args: args.clone() }), }); } if ui.has_input(|key| key == Key::Char('c')) { ui.send_message(Message::Exit { operation: Some(PatchOperation::Checkout { args }), }); } } } fn show_browser_footer(&self, frame: &mut Frame, ui: &mut Ui) { ui.layout(Layout::vertical([1, 1]), None, |ui| { self.show_browser_context(frame, ui); self.show_browser_shortcuts(frame, ui); }); } pub fn show_browser_search(&self, frame: &mut Frame, ui: &mut Ui) { let (mut search_text, mut search_cursor) = ( self.state.search.clone().read().text, self.state.search.clone().read().cursor, ); let mut search = self.state.search.clone(); let text_edit = ui.text_edit_singleline( frame, &mut search_text, &mut search_cursor, Some("Search".to_string()), Some(Borders::Spacer { top: 0, left: 0 }), ); if text_edit.changed { search.write(TextEditState { text: search_text, cursor: search_cursor, }); ui.send_message(Message::Changed(Change::Search { search })); } if ui.has_input(|key| key == Key::Esc) { ui.send_message(Message::HideSearch { apply: false }); } if ui.has_input(|key| key == Key::Enter) { ui.send_message(Message::HideSearch { apply: true }); } } fn show_browser_context(&self, frame: &mut Frame, ui: &mut Ui) { let context = { let patches = self.patches.lock().unwrap(); let search = self.state.search.read().text; let total_count = patches.len(); let filtered_count = patches .iter() .filter(|patch| self.state.filter.matches(patch)) .collect::>() .len(); let filtered_counts = format!(" {filtered_count}/{total_count} "); let state_counts = patches .iter() .fold((0, 0, 0, 0), |counts, patch| match patch.state { radicle::patch::State::Draft => { (counts.0 + 1, counts.1, counts.2, counts.3) } radicle::patch::State::Open { conflicts: _ } => { (counts.0, counts.1 + 1, counts.2, counts.3) } radicle::patch::State::Archived => { (counts.0, counts.1, counts.2 + 1, counts.3) } radicle::patch::State::Merged { revision: _, commit: _, } => (counts.0, counts.1, counts.2, counts.3 + 1), }); if !self.state.filter.has_state() { let draft = format!(" {} ", state_counts.0); let open = format!(" {} ", state_counts.1); let archived = format!(" {} ", state_counts.2); let merged = format!(" {} ", state_counts.3); [ Column::new( Span::raw(" Patches ").cyan().dim().reversed(), Constraint::Length(9), ), Column::new( Span::raw(format!(" {search} ")) .into_left_aligned_line() .style(ui.theme().bar_on_black_style) .cyan() .dim(), Constraint::Fill(1), ), Column::new( Span::raw("●") .style(ui.theme().bar_on_black_style) .dim() .bold(), Constraint::Length(1), ), Column::new( Span::raw(draft.clone()) .style(ui.theme().bar_on_black_style) .dim(), Constraint::Length(draft.chars().count() as u16), ), Column::new( Span::raw("●") .style(ui.theme().bar_on_black_style) .green() .dim() .bold(), Constraint::Length(1), ), Column::new( Span::raw(open.clone()) .style(ui.theme().bar_on_black_style) .dim(), Constraint::Length(open.chars().count() as u16), ), Column::new( Span::raw("●") .style(ui.theme().bar_on_black_style) .yellow() .dim() .bold(), Constraint::Length(1), ), Column::new( Span::raw(archived.clone()) .style(ui.theme().bar_on_black_style) .dim(), Constraint::Length(archived.chars().count() as u16), ), Column::new( Span::raw("✔") .style(ui.theme().bar_on_black_style) .magenta() .dim() .bold(), Constraint::Length(1), ), Column::new( Span::raw(merged.clone()) .style(ui.theme().bar_on_black_style) .dim(), Constraint::Length(merged.chars().count() as u16), ), Column::new( Span::raw(filtered_counts.clone()) .into_right_aligned_line() .cyan() .dim() .reversed(), Constraint::Length(filtered_counts.chars().count() as u16), ), ] .to_vec() } else { [ Column::new( Span::raw(" Patches ".to_string()).cyan().dim().reversed(), Constraint::Length(9), ), Column::new( Span::raw(format!(" {search} ")) .into_left_aligned_line() .style(ui.theme().bar_on_black_style) .cyan() .dim(), Constraint::Fill(1), ), Column::new( Span::raw(filtered_counts.clone()) .into_right_aligned_line() .cyan() .dim() .reversed(), Constraint::Length(filtered_counts.chars().count() as u16), ), ] .to_vec() } }; ui.column_bar(frame, context, Spacing::from(0), Some(Borders::None)); } pub fn show_browser_shortcuts(&self, frame: &mut Frame, ui: &mut Ui) { let shortcuts = vec![ ("/", "search"), ("enter", "show"), ("c", "checkout"), ("d", "diff"), ("r", "reload"), ]; let global_shortcuts = vec![("?", "help")]; ui.layout( Layout::horizontal([Constraint::Fill(1), Constraint::Length(30)]), None, |ui| { ui.shortcuts(frame, &shortcuts, '∙', Alignment::Left); ui.shortcuts(frame, &global_shortcuts, '∙', Alignment::Right); }, ); } fn show_loading_popup(&self, frame: &mut Frame, ui: &mut Ui) { ui.popup(Layout::vertical([Constraint::Min(1)]), |ui| { ui.layout( Layout::vertical([Constraint::Min(1), Constraint::Length(3)]).margin(1), None, |ui| { ui.label(frame, ""); ui.layout( Layout::horizontal([Constraint::Min(1), Constraint::Length(11)]), None, |ui| { ui.label(frame, ""); ui.layout(Layout::vertical([Constraint::Min(1)]), None, |ui| { frame.render_widget(Clear, ui.area()); ui.column_bar( frame, [Column::new( Span::raw(" Loading ").magenta().rapid_blink(), Constraint::Fill(1), )] .to_vec(), Spacing::from(0), Some(Borders::All), ); }); }, ); }, ); }); } fn show_help_text(&self, frame: &mut Frame, ui: &mut Ui) { ui.column_bar( frame, [Column::new(Span::raw(" Help ").bold(), Constraint::Fill(1))].to_vec(), Spacing::from(0), Some(Borders::Top), ); let mut cursor = self.state.help.cursor(); let text_view = ui.text_view( frame, HELP.to_string(), &mut cursor, Some(Borders::BottomSides), ); if text_view.changed { ui.send_message(Message::Changed(Change::Help { state: TextViewState::new(cursor), })) } } fn show_help_context(&self, frame: &mut Frame, ui: &mut Ui) { ui.column_bar( frame, [ Column::new( Span::raw(" ".to_string()) .into_left_aligned_line() .style(ui.theme().bar_on_black_style), Constraint::Fill(1), ), Column::new( Span::raw(" ") .into_right_aligned_line() .cyan() .dim() .reversed(), Constraint::Length(6), ), ] .to_vec(), Spacing::from(0), Some(Borders::None), ); } } impl App { fn apply_patches(&mut self, patches: Vec) { let mut items = self.patches.lock().unwrap(); *items = patches; } fn apply_sorting(&mut self) { let mut items = self.patches.lock().unwrap(); items.sort_by(|a, b| b.timestamp.cmp(&a.timestamp)); } }