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,
}
}
}
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(),
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;
}
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;
}
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,
);
if self.show_quit_confirmation {
QuitConfirmation::render(frame);
}
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);
}
#[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);
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");
component.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()));
assert_eq!(rx.recv().await.unwrap(), Action::Exit);
component.show_quit_confirmation = true;
component.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()));
assert!(!component.show_quit_confirmation, "Quit confirmation should be hidden after pressing Esc");
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 ?");
component.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::empty()));
assert!(!component.show_shortcuts_overlay, "Overlay should be hidden after pressing ? again");
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)]))
}
}