s3tui 0.5.0

Simple TUI application for multiple s3 account operations
Documentation
use crate::components::component::{Component, ComponentRender};
use crate::components::widgets::QuitConfirmation;
use crate::model::action::Action;
use crate::model::state::{ActivePage, State};
use crate::settings::file_credentials::FileCredential;
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind};
use ratatui::{prelude::*, widgets::*};
use tokio::sync::mpsc::UnboundedSender;

#[derive(Clone)]
struct Props {
    creds_table_state: TableState,
    creds_data: Vec<FileCredential>,
}

impl From<&State> for Props {
    fn from(state: &State) -> Self {
        let st = state.clone();
        Props {
            creds_table_state: TableState::default(),
            creds_data: st.creds,
        }
    }
}

/// Page displaying different s3 configuration from your config directory
/// Selecting an item on this page will use specially configured s3 client up to the next change
/// or quitting the app
pub struct S3CredsPage {
    pub action_tx: UnboundedSender<Action>,
    props: Props,
    show_shortcuts_overlay: bool,
    show_quit_confirmation: bool,
}

impl Component for S3CredsPage {
    fn new(state: &State, action_tx: UnboundedSender<Action>) -> Self
    where
        Self: Sized,
    {
        S3CredsPage {
            action_tx: action_tx.clone(),
            // set the props
            props: Props::from(state),
            show_shortcuts_overlay: false,
            show_quit_confirmation: false,
        }
        .move_with_state(state)
    }

    fn move_with_state(self, state: &State) -> Self
    where
        Self: Sized,
    {
        let new_props = Props::from(state);
        S3CredsPage {
            props: Props {
                creds_table_state: self.props.creds_table_state.clone(),
                ..new_props
            },
            show_shortcuts_overlay: self.show_shortcuts_overlay,
            show_quit_confirmation: self.show_quit_confirmation,
            ..self
        }
    }

    fn handle_key_event(&mut self, key: KeyEvent) {
        if key.kind != KeyEventKind::Press {
            return;
        }

        // Handle quit confirmation
        if self.show_quit_confirmation {
            if let Some(confirmed) = QuitConfirmation::handle_key_event(key) {
                if confirmed {
                    let _ = self.action_tx.send(Action::Exit);
                } else {
                    self.show_quit_confirmation = false;
                }
            }
            return;
        }

        // Handle shortcuts overlay
        if self.show_shortcuts_overlay {
            match key.code {
                KeyCode::Esc | KeyCode::Char('?') => {
                    self.show_shortcuts_overlay = false;
                }
                _ => {}
            }
            return;
        }

        match key.code {
            KeyCode::Char('j') | KeyCode::Down => self.move_down_creds_table_selection(),
            KeyCode::Char('k') | KeyCode::Up => self.move_up_creds_table_selection(),
            KeyCode::Enter => self.set_current_s3_account(),
            KeyCode::Char('q') => {
                self.show_quit_confirmation = true;
            }
            KeyCode::Char('?') => {
                self.show_shortcuts_overlay = true;
            }
            KeyCode::Esc => {
                let _ = self.action_tx.send(Action::Navigate {
                    page: ActivePage::FileManager,
                });
            }
            _ => {}
        }
    }
}

impl S3CredsPage {
    fn make_shortcuts_overlay(&self) -> Table<'_> {
        let shortcuts = [
            ("↑↓ / j k", "Navigate up/down"),
            ("Enter", "Select account"),
            ("Esc", "Back to file manager"),
            ("q", "Quit application"),
            ("?", "Toggle this help"),
        ];

        let rows: Vec<Row> = shortcuts
            .iter()
            .map(|(key, desc)| {
                Row::new(vec![
                    Span::styled(*key, Style::default().fg(Color::Yellow).bold()),
                    Span::raw(*desc),
                ])
            })
            .collect();

        let header = Row::new(vec!["Key", "Action"])
            .style(Style::default().fg(Color::Cyan).bold())
            .bottom_margin(1);

        Table::new(rows, [Constraint::Length(12), Constraint::Length(30)])
            .header(header)
            .block(
                Block::default()
                    .borders(Borders::ALL)
                    .border_style(Style::default().fg(Color::Cyan))
                    .title(" Account Selection Shortcuts ")
                    .title_bottom(
                        Line::from(vec![
                            Span::raw(" Press "),
                            Span::styled("?", Style::default().fg(Color::Yellow).bold()),
                            Span::raw(" or "),
                            Span::styled("Esc", Style::default().fg(Color::Yellow).bold()),
                            Span::raw(" to close "),
                        ])
                        .alignment(Alignment::Center),
                    ),
            )
    }

    fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
        let popup_layout = Layout::vertical([
            Constraint::Percentage((100 - percent_y) / 2),
            Constraint::Percentage(percent_y),
            Constraint::Percentage((100 - percent_y) / 2),
        ])
        .split(r);

        Layout::horizontal([
            Constraint::Percentage((100 - percent_x) / 2),
            Constraint::Percentage(percent_x),
            Constraint::Percentage((100 - percent_x) / 2),
        ])
        .split(popup_layout[1])[1]
    }

    fn get_s3_row(&self, item: &FileCredential) -> Row<'_> {
        if item.selected {
            Row::new(vec![format!("{} (*)", item.name)])
        } else {
            Row::new(vec![format!("{}", item.name)])
        }
    }

    fn get_s3_table(&self) -> Table<'_> {
        let focus_color = Color::Rgb(98, 114, 164);
        let header = Row::new(vec!["Account Name"])
            .bold()
            .underlined()
            .height(1)
            .bottom_margin(0);
        let rows = self
            .props
            .creds_data
            .iter()
            .map(|item| S3CredsPage::get_s3_row(self, item));
        let widths = [
            Constraint::Length(10),
            Constraint::Length(35),
            Constraint::Length(35),
            Constraint::Length(10),
            Constraint::Length(10),
        ];
        let table = Table::new(rows, widths)
            .header(header)
            .block(Block::default().borders(Borders::ALL).title("Account list"))
            .row_highlight_style(
                Style::default()
                    .fg(focus_color)
                    .add_modifier(Modifier::REVERSED),
            )
            .widths([
                Constraint::Percentage(10),
                Constraint::Percentage(35),
                Constraint::Percentage(35),
                Constraint::Percentage(10),
                Constraint::Percentage(10),
            ]);
        table
    }

    pub fn move_up_creds_table_selection(&mut self) {
        let i = match self.props.creds_table_state.selected() {
            Some(i) => {
                if i == 0_usize && !self.props.creds_data.is_empty() {
                    self.props.creds_data.len() - 1
                } else if i > 0 {
                    i - 1
                } else {
                    0
                }
            }
            None => 0,
        };
        if !self.props.creds_data.is_empty() {
            self.props.creds_table_state.select(Some(i));
        }
    }

    pub fn move_down_creds_table_selection(&mut self) {
        let i = match self.props.creds_table_state.selected() {
            Some(i) => {
                if !self.props.creds_data.is_empty() && i >= self.props.creds_data.len() - 1 {
                    0
                } else {
                    i + 1
                }
            }
            None => 0,
        };
        if !self.props.creds_data.is_empty() {
            self.props.creds_table_state.select(Some(i));
        }
    }

    pub fn set_current_s3_account(&mut self) {
        if let Some(selected_row) = self
            .props
            .creds_table_state
            .selected()
            .and_then(|index| self.props.creds_data.get(index))
        {
            let sr = selected_row.clone();
            let _ = self
                .action_tx
                .send(Action::SelectCurrentS3Creds { item: sr.clone() });
        }
    }
}

impl ComponentRender<()> for S3CredsPage {
    fn render(&self, frame: &mut Frame, _props: ()) {
        let s3_table = self.get_s3_table();
        frame.render_stateful_widget(
            &s3_table,
            frame.area(),
            &mut self.props.clone().creds_table_state,
        );

        // Show quit confirmation overlay
        if self.show_quit_confirmation {
            QuitConfirmation::render(frame);
        }
        // Show keyboard shortcuts overlay
        else if self.show_shortcuts_overlay {
            let area = Self::centered_rect(50, 40, frame.area());
            frame.render_widget(Clear, area);
            let table = self.make_shortcuts_overlay();
            frame.render_widget(table, area);
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crossterm::event::KeyModifiers;
    use tokio::sync::mpsc::unbounded_channel;

    #[tokio::test]
    async fn test_component_initialization() {
        let (tx, _rx) = unbounded_channel::<Action>();
        let creds = FileCredential {
            name: "test".to_string(),
            access_key: "accessKey".to_string(),
            secret_key: "secretKey".to_string(),
            default_region: "eu-north-1".to_string(),
            endpoint_url: None,
            force_path_style: false,
            selected: true,
        };
        let state = State::new(vec![creds]);

        let _component = S3CredsPage::new(&state, tx);
        //test passes on no-panic
    }

    #[tokio::test]
    async fn test_key_event_handling() {
        let (tx, mut rx) = unbounded_channel::<Action>();
        let creds = FileCredential {
            name: "test".to_string(),
            access_key: "accessKey".to_string(),
            secret_key: "secretKey".to_string(),
            default_region: "eu-north-1".to_string(),
            endpoint_url: None,
            force_path_style: false,
            selected: true,
        };
        let state = State::new(vec![creds.clone()]);
        let mut component = S3CredsPage::new(&state, tx);

        // Simulate pressing 'q' shows quit confirmation
        assert!(!component.show_quit_confirmation, "Quit confirmation should be hidden initially");
        component.handle_key_event(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::empty()));
        assert!(component.show_quit_confirmation, "Quit confirmation should be shown after pressing q");

        // Simulate pressing Enter to confirm quit
        component.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()));
        assert_eq!(rx.recv().await.unwrap(), Action::Exit);

        // Reset for next test
        component.show_quit_confirmation = true;
        // Simulate pressing Esc to cancel quit
        component.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()));
        assert!(!component.show_quit_confirmation, "Quit confirmation should be hidden after pressing Esc");

        // Simulate pressing '?' to show overlay
        assert!(!component.show_shortcuts_overlay, "Overlay should be hidden initially");
        component.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::empty()));
        assert!(component.show_shortcuts_overlay, "Overlay should be shown after pressing ?");

        // Simulate pressing '?' again to close overlay
        component.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::empty()));
        assert!(!component.show_shortcuts_overlay, "Overlay should be hidden after pressing ? again");

        // Simulate pressing 'Esc'
        component.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()));
        assert_eq!(
            rx.recv().await.unwrap(),
            Action::Navigate {
                page: ActivePage::FileManager
            }
        );
    }

    #[test]
    fn get_s3_row_should_format_selected_row() {
        let (tx, _rx) = unbounded_channel::<Action>();
        let creds = FileCredential {
            name: "test".to_string(),
            access_key: "accessKey".to_string(),
            secret_key: "secretKey".to_string(),
            default_region: "eu-north-1".to_string(),
            endpoint_url: None,
            force_path_style: false,
            selected: true,
        };
        let state = State::new(vec![creds.clone()]);
        let component = S3CredsPage::new(&state, tx);
        let res = component.get_s3_row(&creds);

        assert_eq!(res, Row::new(vec![format!("{} (*)", creds.name)]))
    }

    #[test]
    fn get_s3_row_should_format_not_selected_row() {
        let (tx, _rx) = unbounded_channel::<Action>();
        let creds = FileCredential {
            name: "test".to_string(),
            access_key: "accessKey".to_string(),
            secret_key: "secretKey".to_string(),
            default_region: "eu-north-1".to_string(),
            endpoint_url: None,
            force_path_style: false,
            selected: false,
        };
        let state = State::new(vec![creds.clone()]);
        let component = S3CredsPage::new(&state, tx);
        let res = component.get_s3_row(&creds);

        assert_eq!(res, Row::new(vec![format!("{}", creds.name)]))
    }
}