use crate::components::component::{Component, ComponentRender};
use crate::components::widgets::QuitConfirmation;
use crate::model::action::Action;
use crate::model::error::{LocalError, S3Error};
use crate::model::filtering::filter_items;
use crate::model::has_children::flatten_items;
use crate::model::local_data_item::LocalDataItem;
use crate::model::local_selected_item::LocalSelectedItem;
use crate::model::navigation_state::NavigationState;
use crate::model::s3_data_item::S3DataItem;
use crate::model::s3_selected_item::S3SelectedItem;
use crate::model::sorting::{SortColumn, SortState};
use crate::model::state::{ActivePage, State};
use crate::settings::file_credentials::FileCredential;
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind};
use ratatui::{prelude::*, widgets::*};
use throbber_widgets_tui::Throbber;
use tokio::sync::mpsc::UnboundedSender;
use tui_input::backend::crossterm::EventHandler;
use tui_input::Input;
static INPUT_SIZE: usize = 60;
#[derive(Clone)]
struct Props {
local_table_state: TableState,
local_data: Vec<LocalDataItem>,
s3_table_state: TableState,
s3_data: Vec<S3DataItem>,
s3_data_full_list: Vec<S3DataItem>,
s3_history: Vec<NavigationState>,
s3_loading: bool,
s3_list_recursive_loading: bool,
s3_selected_items: Vec<S3SelectedItem>,
local_selected_items: Vec<LocalSelectedItem>,
current_local_path: String,
current_s3_bucket: Option<String>,
current_s3_path: String,
current_s3_creds: FileCredential,
s3_delete_error: Option<S3Error>,
local_delete_error: Option<LocalError>,
create_bucket_error: Option<S3Error>,
s3_sort_state: SortState,
local_sort_state: SortState,
search_query: String,
search_mode: bool,
}
impl From<&State> for Props {
fn from(state: &State) -> Self {
let st = state.clone();
Props {
local_table_state: TableState::default(),
local_data: st.local_data,
s3_table_state: TableState::default(),
s3_data: st.s3_data,
s3_data_full_list: st.s3_data_full_list,
s3_history: Vec::new(),
s3_loading: st.s3_loading,
s3_list_recursive_loading: st.s3_list_recursive_loading,
s3_selected_items: st.s3_selected_items,
local_selected_items: st.local_selected_items,
current_local_path: st.current_local_path,
current_s3_bucket: st.current_s3_bucket,
current_s3_path: st.current_s3_path.unwrap_or("/".to_string()),
current_s3_creds: st.current_creds,
s3_delete_error: st.s3_delete_error,
local_delete_error: st.local_delete_error,
create_bucket_error: st.create_bucket_error,
s3_sort_state: st.s3_sort_state,
local_sort_state: st.local_sort_state,
search_query: st.search_query,
search_mode: st.search_mode,
}
}
}
pub struct FileManagerPage {
pub action_tx: UnboundedSender<Action>,
props: Props,
s3_panel_selected: bool,
show_problem_popup: bool,
show_bucket_input: bool,
show_delete_confirmation: bool,
show_delete_multiple_confirmation: bool,
show_download_confirmation: bool,
show_delete_error: bool,
show_quit_confirmation: bool,
show_shortcuts_overlay: bool,
default_navigation_state: NavigationState,
input: Input,
search_input: Input,
}
impl FileManagerPage {
fn make_transfer_error_popup(&self) -> Paragraph<'_> {
let text = " To move data into s3 you need to select at least a bucket to which you want to transfer your files";
Paragraph::new(text)
.block(Block::default()) .alignment(Alignment::Left)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default())
.title_bottom(Line::from(vec![
Span::raw("|"),
Span::styled("cancel", Style::default().fg(Color::Yellow)),
Span::raw("("),
Span::styled(
"Esc",
Style::default().add_modifier(Modifier::BOLD).fg(Color::Red),
),
Span::raw(")"),
Span::raw("|"),
]))
.title_top(Line::from(vec![Span::raw(
"| Problem detected! |",
)])),
)
.fg(Color::Red)
}
fn make_delete_alert(&self, text: String, text_color: Color) -> Paragraph<'_> {
let input = Paragraph::new(text)
.style(Style::default().fg(text_color))
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default())
.title_bottom(Line::from(vec![
Span::raw("|"),
Span::styled("ok", Style::default().fg(Color::Yellow)),
Span::raw("("),
Span::styled(
"Enter",
Style::default().add_modifier(Modifier::BOLD).fg(Color::Red),
),
Span::raw(")"),
Span::raw("|"),
]).alignment(Alignment::Right))
.title_bottom(Line::from(vec![
Span::raw("|"),
Span::styled("cancel", Style::default().fg(Color::Yellow)),
Span::raw("("),
Span::styled(
"Esc",
Style::default().add_modifier(Modifier::BOLD).fg(Color::Red),
),
Span::raw(")"),
Span::raw("|"),
]).alignment(Alignment::Left)),
);
input
}
fn make_confirm_download_alert(
&self,
text: String,
text_color: Color,
show_buttons: bool,
) -> Paragraph<'_> {
let mut block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default());
if show_buttons {
block = block
.title_bottom(Line::from(vec![
Span::raw("|"),
Span::styled("ok", Style::default().fg(Color::Yellow)),
Span::raw("("),
Span::styled(
"Enter",
Style::default().add_modifier(Modifier::BOLD).fg(Color::Red),
),
Span::raw(")"),
Span::raw("|"),
]).alignment(Alignment::Right))
.title_bottom(Line::from(vec![
Span::raw("|"),
Span::styled("cancel", Style::default().fg(Color::Yellow)),
Span::raw("("),
Span::styled(
"Esc",
Style::default().add_modifier(Modifier::BOLD).fg(Color::Red),
),
Span::raw(")"),
Span::raw("|"),
]).alignment(Alignment::Left));
}
Paragraph::new(text)
.style(Style::default().fg(text_color))
.block(block)
}
fn make_bucket_name_input(&self) -> Paragraph<'_> {
let scroll = self.input.visual_scroll(INPUT_SIZE);
let input = Paragraph::new(self.input.value())
.style(Style::default().fg(Color::Green))
.scroll((0, scroll as u16))
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default())
.title_bottom(Line::from(vec![
Span::raw("|"),
Span::styled("save", Style::default().fg(Color::Yellow)),
Span::raw("("),
Span::styled(
"Enter",
Style::default().add_modifier(Modifier::BOLD).fg(Color::Red),
),
Span::raw(")"),
Span::raw("|"),
]).alignment(Alignment::Right))
.title_bottom(Line::from(vec![
Span::raw("|"),
Span::styled("cancel", Style::default().fg(Color::Yellow)),
Span::raw("("),
Span::styled(
"Esc",
Style::default().add_modifier(Modifier::BOLD).fg(Color::Red),
),
Span::raw(")"),
Span::raw("|"),
]).alignment(Alignment::Left))
.title_top(Line::from(vec![Span::raw(
"| Enter new bucket name |",
)])),
);
input
}
fn make_shortcuts_overlay(&self) -> Table<'_> {
let shortcuts = vec![
("Tab / ← →", "Switch between panels"),
("↑↓ / j k", "Navigate up/down"),
("Enter", "Open directory/bucket"),
("Esc", "Go back / Clear filter"),
("t", "Select for transfer"),
("r", "Run transfers"),
("l", "Show transfers list"),
("s", "Select S3 account"),
("c", "Create bucket"),
("Del / ⌫", "Delete item"),
("/", "Search/filter files"),
("F1", "Sort by name"),
("F2", "Sort by size"),
("F3", "Sort by type"),
("F5", "Refresh view"),
("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(" Keyboard 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 make_search_input(&self) -> Paragraph<'_> {
let scroll = self.search_input.visual_scroll(INPUT_SIZE);
Paragraph::new(self.search_input.value())
.style(Style::default().fg(Color::Yellow))
.scroll((0, scroll as u16))
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow))
.title_top(Line::from(vec![
Span::raw("| "),
Span::styled("Search", Style::default().fg(Color::Yellow).bold()),
Span::raw(" |"),
]))
.title_bottom(Line::from(vec![
Span::raw("|"),
Span::styled("filter", Style::default().fg(Color::Yellow)),
Span::raw("("),
Span::styled(
"Enter",
Style::default().add_modifier(Modifier::BOLD).fg(Color::Green),
),
Span::raw(")"),
Span::raw("|"),
]).alignment(Alignment::Right))
.title_bottom(Line::from(vec![
Span::raw("|"),
Span::styled("cancel", Style::default().fg(Color::Yellow)),
Span::raw("("),
Span::styled(
"Esc",
Style::default().add_modifier(Modifier::BOLD).fg(Color::Red),
),
Span::raw(")"),
Span::raw("|"),
]).alignment(Alignment::Left)),
)
}
fn get_loading_info(&self) -> Throbber<'_> {
Throbber::default()
.label("Loading s3 data...")
.style(Style::default())
.throbber_style(Style::default().add_modifier(Modifier::BOLD))
}
fn get_local_table(&self, focus_color: Color) -> Table<'_> {
let sort_state = &self.props.local_sort_state;
let header = Row::new(vec![
format!("Name{}", sort_state.indicator(SortColumn::Name)),
format!("Size{}", sort_state.indicator(SortColumn::Size)),
format!("Type{}", sort_state.indicator(SortColumn::Type)),
])
.fg(focus_color)
.bold()
.underlined()
.height(1)
.bottom_margin(0);
let search_query = if !self.s3_panel_selected {
&self.props.search_query
} else {
&String::new()
};
let filtered_items = filter_items(&self.props.local_data, search_query);
let rows = filtered_items
.iter()
.map(|item| FileManagerPage::get_local_row(self, item, focus_color));
let widths = [
Constraint::Length(60),
Constraint::Length(20),
Constraint::Length(20),
];
let block = self.get_home_local_block();
let table = Table::new(rows, widths)
.header(header)
.block(block)
.row_highlight_style(
Style::default()
.fg(focus_color)
.bold()
.add_modifier(Modifier::REVERSED),
)
.widths([
Constraint::Percentage(60),
Constraint::Percentage(20),
Constraint::Percentage(20),
]);
table
}
fn get_status_line(&self) -> Paragraph<'_> {
let s3_items = flatten_items(self.props.s3_selected_items.clone());
let local_items = flatten_items(self.props.local_selected_items.clone());
let to_transfer = s3_items.len() + local_items.len();
let transferred = s3_items.iter().filter(|i| i.is_transferred()).count()
+ local_items.iter().filter(|i| i.is_transferred()).count();
let failed = s3_items
.iter()
.filter(|i| i.transfer_state.error().is_some())
.count()
+ local_items
.iter()
.filter(|i| i.transfer_state.error().is_some())
.count();
let transfer_info = if failed > 0 {
format!(
"Transfers: {} (✓{} done ✗{} failed)",
to_transfer, transferred, failed
)
} else if transferred > 0 {
format!("Transfers: {}/{} done", transferred, to_transfer)
} else {
format!("Transfers: {}", to_transfer)
};
let filter_info = if !self.props.search_query.is_empty() {
format!(" • Filter: \"{}\"", self.props.search_query)
} else {
String::new()
};
if let Some(bucket) = &self.props.current_s3_bucket {
let bottom_text = Paragraph::new(format!(
" Account: {} • Bucket: {} • {}{}",
self.props.current_s3_creds.name, bucket, transfer_info, filter_info
))
.style(Style::default().fg(Color::White))
.bg(Color::Blue);
bottom_text
} else {
let bottom_text = Paragraph::new(format!(
" Account: {} • {}{}",
self.props.current_s3_creds.name, transfer_info, filter_info
))
.style(Style::default().fg(Color::White))
.bg(Color::Blue);
bottom_text
}
}
fn get_help_line(&self) -> Paragraph<'_> {
if self.props.s3_selected_items.is_empty() && self.props.local_selected_items.is_empty() {
Paragraph::new(
"| '?' help, 'l' transfers list, 'Esc/Enter' browsing",
)
.style(Style::default().fg(Color::White))
.bg(Color::Blue)
.alignment(Alignment::Right)
} else {
Paragraph::new("| '?' help, 'l' transfers list, 's' select s3 account")
.style(Style::default().fg(Color::White))
.bg(Color::Blue)
.alignment(Alignment::Right)
}
}
fn get_s3_row(&self, item: &S3DataItem, focus_color: Color) -> Row<'_> {
if self.contains_s3_item(
item,
&self.props.s3_selected_items,
&self.props.current_s3_creds,
) {
Row::new(item.to_columns().clone())
.fg(focus_color)
.add_modifier(Modifier::REVERSED)
} else {
Row::new(item.to_columns().clone())
}
}
fn get_local_row(&self, item: &LocalDataItem, focus_color: Color) -> Row<'_> {
if self.contains_local_item(
item,
&self.props.local_selected_items,
&self.props.current_s3_creds,
) {
Row::new(item.to_columns().clone())
.fg(focus_color)
.add_modifier(Modifier::REVERSED)
} else {
Row::new(item.to_columns().clone())
}
}
fn contains_s3_item(
&self,
data_item: &S3DataItem,
selected_items: &[S3SelectedItem],
s3_creds: &FileCredential,
) -> bool {
let destination_dir = self.props.current_local_path.clone();
let search_item = S3SelectedItem::from_s3_data_item(
data_item.clone(),
s3_creds.clone(),
destination_dir.clone(),
); selected_items.contains(&search_item) }
fn contains_local_item(
&self,
data_item: &LocalDataItem,
selected_items: &[LocalSelectedItem],
s3_creds: &FileCredential,
) -> bool {
let search_item =
LocalSelectedItem::from_local_data_item(data_item.clone(), s3_creds.clone());
selected_items.contains(&search_item) }
fn get_s3_table(&self, focus_color: Color) -> Table<'_> {
let sort_state = &self.props.s3_sort_state;
let header = Row::new(vec![
format!("Name{}", sort_state.indicator(SortColumn::Name)),
format!("Size{}", sort_state.indicator(SortColumn::Size)),
format!("Type{}", sort_state.indicator(SortColumn::Type)),
])
.fg(focus_color)
.bold()
.underlined()
.height(1)
.bottom_margin(0);
let search_query = if self.s3_panel_selected {
&self.props.search_query
} else {
&String::new()
};
let filtered_items = filter_items(&self.props.s3_data, search_query);
let rows = filtered_items
.iter()
.map(|item| FileManagerPage::get_s3_row(self, item, focus_color));
let widths = [
Constraint::Length(60),
Constraint::Length(20),
Constraint::Length(20),
];
let block = self.get_home_s3_block();
let table = Table::new(rows, widths)
.header(header)
.block(block)
.row_highlight_style(
Style::default()
.fg(focus_color)
.bold()
.add_modifier(Modifier::REVERSED),
)
.widths([
Constraint::Percentage(60),
Constraint::Percentage(20),
Constraint::Percentage(20),
]);
table
}
fn get_home_s3_block(&self) -> Block<'_> {
if self.s3_panel_selected {
Block::default()
.borders(Borders::ALL)
.title(format!("S3 List ({} objects)", self.props.s3_data.len()))
.fg(Color::Blue)
} else {
Block::default()
.borders(Borders::ALL)
.title(format!("S3 List ({} objects)", self.props.s3_data.len()))
}
}
fn get_home_local_block(&self) -> Block<'_> {
if !self.s3_panel_selected {
Block::default()
.borders(Borders::ALL)
.title(format!(
"Local List ({} objects)",
self.props.local_data.len()
))
.fg(Color::Blue)
} else {
Block::default().borders(Borders::ALL).title(format!(
"Local List ({} objects)",
self.props.local_data.len()
))
}
}
pub fn move_up_s3_table_selection(&mut self) {
let filtered_len = filter_items(&self.props.s3_data, &self.props.search_query).len();
let i = match self.props.s3_table_state.selected() {
Some(i) => {
if i == 0_usize && filtered_len > 0 {
filtered_len - 1
} else if i > 0 {
i - 1
} else {
0
}
}
None => 0,
};
if filtered_len > 0 {
self.props.s3_table_state.select(Some(i));
}
}
pub fn move_down_s3_table_selection(&mut self) {
let filtered_len = filter_items(&self.props.s3_data, &self.props.search_query).len();
let i = match self.props.s3_table_state.selected() {
Some(i) => {
if filtered_len > 0 && i >= filtered_len - 1 {
0
} else {
i + 1
}
}
None => 0,
};
if filtered_len > 0 {
self.props.s3_table_state.select(Some(i));
}
}
pub fn move_up_local_table_selection(&mut self) {
let filtered_len = filter_items(&self.props.local_data, &self.props.search_query).len();
let i = match self.props.local_table_state.selected() {
Some(i) => {
if i == 0_usize && filtered_len > 0 {
filtered_len - 1
} else if i > 0 {
i - 1
} else {
0
}
}
None => 0,
};
if filtered_len > 0 {
self.props.local_table_state.select(Some(i));
}
}
pub fn move_down_local_table_selection(&mut self) {
let filtered_len = filter_items(&self.props.local_data, &self.props.search_query).len();
let i = match self.props.local_table_state.selected() {
Some(i) => {
if filtered_len > 0 && i >= filtered_len - 1 {
0
} else {
i + 1
}
}
None => 0,
};
if filtered_len > 0 {
self.props.local_table_state.select(Some(i));
}
}
pub fn handle_selected_local_row(&mut self) {
let filtered_items = filter_items(&self.props.local_data, &self.props.search_query);
if let Some(selected_row) = self
.props
.local_table_state
.selected()
.and_then(|index| filtered_items.get(index))
{
if selected_row.is_directory {
if !self.props.search_query.is_empty() {
self.search_input.reset();
let _ = self.action_tx.send(Action::ClearSearch);
}
let _ = self.action_tx.send(Action::FetchLocalData {
path: selected_row.path.clone(),
});
}
}
}
pub fn handle_selected_s3_row(&mut self) {
let filtered_items = filter_items(&self.props.s3_data, &self.props.search_query);
if let Some(selected_row) = self
.props
.s3_table_state
.selected()
.and_then(|index| filtered_items.get(index))
{
if selected_row.is_bucket {
if !self.props.search_query.is_empty() {
self.search_input.reset();
let _ = self.action_tx.send(Action::ClearSearch);
}
self.go_into(Some(selected_row.path.clone()), None);
let _ = self.action_tx.send(Action::FetchS3Data {
bucket: self.current_state().current_bucket.clone(),
prefix: self.current_state().current_prefix.clone(),
});
} else if selected_row.is_directory {
if !self.props.search_query.is_empty() {
self.search_input.reset();
let _ = self.action_tx.send(Action::ClearSearch);
}
self.go_into(None, Some(selected_row.path.clone())); let _ = self.action_tx.send(Action::FetchS3Data {
bucket: self.current_state().current_bucket.clone(),
prefix: self.current_state().current_prefix.clone(),
});
}
}
}
fn go_into(&mut self, bucket: Option<String>, prefix: Option<String>) {
if let Some(b) = bucket {
self.props.s3_history.clear();
self.props
.s3_history
.push(NavigationState::new(Some(b.clone()), None));
}
if let Some(p) = prefix {
let current_state = self.current_state().clone();
self.props.s3_history.push(NavigationState::new(
current_state.current_bucket.clone(),
Some(p),
));
}
}
fn go_up(&mut self) {
if !self.props.s3_history.is_empty() {
self.props.s3_history.pop();
}
}
fn current_state(&self) -> &NavigationState {
self.props
.s3_history
.last()
.unwrap_or(&self.default_navigation_state)
}
pub fn handle_go_back_local(&mut self) {
let _ = self.action_tx.send(Action::MoveBackLocal);
}
pub fn handle_go_back_s3(&mut self) {
self.go_up();
let _ = self.action_tx.send(Action::FetchS3Data {
bucket: self.current_state().current_bucket.clone(),
prefix: self.current_state().current_prefix.clone(),
});
}
fn handle_refresh(&mut self) {
if self.props.s3_loading {
return;
}
if self.s3_panel_selected {
let _ = self.action_tx.send(Action::FetchS3Data {
bucket: self.current_state().current_bucket.clone(),
prefix: self.current_state().current_prefix.clone(),
});
} else {
let _ = self.action_tx.send(Action::FetchLocalData {
path: self.props.current_local_path.clone(),
});
}
}
fn transfer_from_s3_to_local(&mut self) {
let filtered_items = filter_items(&self.props.s3_data, &self.props.search_query);
if let Some(selected_row) = self
.props
.s3_table_state
.selected()
.and_then(|index| filtered_items.get(index).copied())
{
let sr = selected_row.clone();
let cc = self.props.current_s3_creds.clone();
let creds = FileCredential {
default_region: sr.clone().region.unwrap_or(cc.default_region.clone()),
..cc
};
let selected_item = S3SelectedItem::from_s3_data_item(
sr.clone(),
creds,
self.props.current_local_path.clone(),
);
if !self.props.s3_selected_items.contains(&selected_item) {
if selected_item.is_bucket || selected_item.is_directory {
self.show_download_confirmation = true;
self.props.s3_list_recursive_loading = true;
let _ = self.action_tx.send(Action::ListS3DataRecursiveForItem {
item: selected_item,
});
} else {
let _ = self.action_tx.send(Action::SelectS3Item {
item: selected_item,
});
}
} else {
let _ = self.action_tx.send(Action::UnselectS3Item {
item: selected_item,
});
}
}
}
fn finish_recursive_transfer_from_s3_to_local(&mut self) {
let filtered_items = filter_items(&self.props.s3_data, &self.props.search_query);
if let Some(selected_row) = self
.props
.s3_table_state
.selected()
.and_then(|index| filtered_items.get(index).copied())
{
let sr = selected_row.clone();
let cc = self.props.current_s3_creds.clone();
let creds = FileCredential {
default_region: sr.clone().region.unwrap_or(cc.default_region.clone()),
..cc
};
let destination_dir = self.props.current_local_path.clone();
let children = self
.props
.s3_data_full_list
.iter()
.map(|i| {
S3SelectedItem::from_s3_data_item(
i.clone(),
creds.clone(),
destination_dir.clone(),
)
})
.collect();
let selected_item = S3SelectedItem::from_s3_data_item_with_children(
sr.clone(),
creds.clone(),
self.props.current_local_path.clone(),
children,
);
if !self.props.s3_selected_items.contains(&selected_item) {
let _ = self.action_tx.send(Action::SelectS3Item {
item: selected_item,
});
} else {
let _ = self.action_tx.send(Action::UnselectS3Item {
item: selected_item,
});
}
}
}
fn finish_recursive_delete_from_s3_to_local(&mut self) {
let filtered_items = filter_items(&self.props.s3_data, &self.props.search_query);
if let Some(selected_row) = self
.props
.s3_table_state
.selected()
.and_then(|index| filtered_items.get(index).copied())
{
let sr = selected_row.clone();
let cc = self.props.current_s3_creds.clone();
let creds = FileCredential {
default_region: sr.clone().region.unwrap_or(cc.default_region.clone()),
..cc
};
let destination_dir = self.props.current_local_path.clone();
let children = self
.props
.s3_data_full_list
.iter()
.map(|i| {
S3SelectedItem::from_s3_data_item(
i.clone(),
creds.clone(),
destination_dir.clone(),
)
})
.collect();
let selected_item = S3SelectedItem::from_s3_data_item_with_children(
sr.clone(),
creds.clone(),
self.props.current_local_path.clone(),
children,
);
let _ = self.action_tx.send(Action::DeleteS3Item {
item: selected_item,
});
}
}
fn transfer_from_local_to_s3(&mut self) {
let filtered_items = filter_items(&self.props.local_data, &self.props.search_query);
if let Some(selected_row) = self
.props
.local_table_state
.selected()
.and_then(|index| filtered_items.get(index).copied())
{
let sr = selected_row.clone();
if let Some(selected_bucket) = self.props.current_s3_bucket.clone() {
let destination_path = if sr.is_directory {
sr.name.clone()
} else {
"/".to_string()
};
let selected_item = LocalSelectedItem::new(
sr.name.clone(),
sr.path,
sr.is_directory,
selected_bucket,
destination_path,
self.props.current_s3_creds.clone(),
None,
);
if !self.props.local_selected_items.contains(&selected_item) {
let _ = self.action_tx.send(Action::SelectLocalItem {
item: selected_item,
});
} else {
let _ = self.action_tx.send(Action::UnselectLocalItem {
item: selected_item,
});
}
} else {
self.show_problem_popup = true;
}
}
}
fn delete_selected_s3_item(&mut self) {
let filtered_items = filter_items(&self.props.s3_data, &self.props.search_query);
if let Some(selected_row) = self
.props
.s3_table_state
.selected()
.and_then(|index| filtered_items.get(index).copied())
{
let sr = selected_row.clone();
let cc = self.props.current_s3_creds.clone();
let creds = FileCredential {
default_region: sr.clone().region.unwrap_or(cc.default_region.clone()),
..cc
};
let selected_item = S3SelectedItem::from_s3_data_item(
sr.clone(),
creds.clone(),
self.props.current_local_path.clone(),
);
if selected_item.is_directory {
self.show_delete_multiple_confirmation = true;
self.props.s3_list_recursive_loading = true;
let _ = self.action_tx.send(Action::ListS3DataRecursiveForItem {
item: selected_item,
});
} else {
let _ = self.action_tx.send(Action::DeleteS3Item {
item: selected_item,
});
}
}
}
fn delete_selected_local_item(&mut self) {
let filtered_items = filter_items(&self.props.local_data, &self.props.search_query);
if let Some(selected_row) = self
.props
.local_table_state
.selected()
.and_then(|index| filtered_items.get(index).copied())
{
let sr = selected_row.clone();
let selected_item = LocalSelectedItem::new(
sr.name,
sr.path,
sr.is_directory,
"".to_string(),
self.props.current_s3_path.clone(),
self.props.current_s3_creds.clone(),
None,
);
let _ = self.action_tx.send(Action::DeleteLocalItem {
item: selected_item,
});
}
}
fn send_clear_delete_errors_message(&mut self) {
let _ = self.action_tx.send(Action::ClearDeletionErrors);
self.show_delete_error = false;
}
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]
}
}
impl Component for FileManagerPage {
fn new(state: &State, action_tx: UnboundedSender<Action>) -> Self
where
Self: Sized,
{
FileManagerPage {
action_tx: action_tx.clone(),
props: Props::from(state),
show_problem_popup: false,
show_bucket_input: false,
show_delete_confirmation: false,
show_delete_multiple_confirmation: false,
show_download_confirmation: false,
show_delete_error: false,
show_quit_confirmation: false,
show_shortcuts_overlay: false,
s3_panel_selected: true,
default_navigation_state: NavigationState::new(None, None),
input: Input::default().with_value(String::from("")),
search_input: Input::default().with_value(String::from("")),
}
.move_with_state(state)
}
fn move_with_state(self, state: &State) -> Self
where
Self: Sized,
{
let new_props = Props::from(state);
FileManagerPage {
show_delete_error: state.s3_delete_error.is_some()
|| state.local_delete_error.is_some(),
show_bucket_input: state.create_bucket_error.is_some(),
props: Props {
s3_history: self.props.s3_history.clone(),
s3_table_state: self.props.s3_table_state.clone(),
local_table_state: self.props.local_table_state.clone(),
..new_props
},
..self
}
}
fn handle_key_event(&mut self, key: KeyEvent) {
if key.kind != KeyEventKind::Press {
return;
}
if self.show_bucket_input {
match key.code {
KeyCode::Enter => {
let _ = self.action_tx.send(Action::CreateBucket {
name: self.input.value().to_string(),
});
self.show_bucket_input = false;
}
KeyCode::Esc => {
self.show_bucket_input = false;
self.send_clear_delete_errors_message();
}
_ => {
let _ = self.input.handle_event(&crossterm::event::Event::Key(key));
}
}
} else if self.show_delete_confirmation {
match key.code {
KeyCode::Enter => {
match self.s3_panel_selected {
true => {
self.delete_selected_s3_item();
self.props.s3_loading = true;
}
false => {
self.delete_selected_local_item();
}
}
self.show_delete_confirmation = false;
}
KeyCode::Esc => {
self.show_delete_confirmation = false;
}
_ => {}
}
} else if self.show_delete_error {
match key.code {
KeyCode::Enter => {
self.send_clear_delete_errors_message();
}
KeyCode::Esc => {
self.send_clear_delete_errors_message();
}
_ => {}
}
} else if self.show_download_confirmation && !self.props.s3_list_recursive_loading {
match key.code {
KeyCode::Enter => {
self.finish_recursive_transfer_from_s3_to_local();
self.show_download_confirmation = false;
}
KeyCode::Esc => {
self.show_download_confirmation = false;
}
_ => {}
}
} else if self.show_delete_multiple_confirmation && !self.props.s3_list_recursive_loading {
match key.code {
KeyCode::Enter => {
self.finish_recursive_delete_from_s3_to_local();
self.show_delete_multiple_confirmation = false;
}
KeyCode::Esc => {
self.show_delete_multiple_confirmation = false;
}
_ => {}
}
} else if self.props.search_mode {
match key.code {
KeyCode::Enter => {
let _ = self.action_tx.send(Action::SetSearchQuery {
query: self.search_input.value().to_string(),
});
let _ = self.action_tx.send(Action::SetSearchMode { active: false });
}
KeyCode::Esc => {
self.search_input.reset();
let _ = self.action_tx.send(Action::ClearSearch);
}
_ => {
let _ = self.search_input.handle_event(&crossterm::event::Event::Key(key));
}
}
} else 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;
}
}
} else if self.show_shortcuts_overlay {
match key.code {
KeyCode::Esc | KeyCode::Char('?') => {
self.show_shortcuts_overlay = false;
}
_ => {}
}
} else {
match key.code {
KeyCode::Char('j') | KeyCode::Down => match self.s3_panel_selected {
true => self.move_down_s3_table_selection(),
false => self.move_down_local_table_selection(),
},
KeyCode::Char('k') | KeyCode::Up => match self.s3_panel_selected {
true => self.move_up_s3_table_selection(),
false => self.move_up_local_table_selection(),
},
KeyCode::Char('c') => {
if self.s3_panel_selected {
self.input.reset();
self.show_bucket_input = true;
}
}
KeyCode::Enter => match self.s3_panel_selected {
true => self.handle_selected_s3_row(),
false => self.handle_selected_local_row(),
},
KeyCode::Esc => {
if !self.props.search_query.is_empty() {
self.search_input.reset();
let _ = self.action_tx.send(Action::ClearSearch);
} else {
match self.s3_panel_selected {
true => {
if !self.props.s3_loading {
self.handle_go_back_s3()
}
}
false => {
if self.show_problem_popup {
self.show_problem_popup = false;
} else {
self.handle_go_back_local()
}
}
}
}
}
KeyCode::Delete | KeyCode::Backspace => {
self.show_delete_confirmation = true;
}
KeyCode::Char('t') => {
if self.s3_panel_selected {
self.transfer_from_s3_to_local()
} else {
self.transfer_from_local_to_s3()
}
}
KeyCode::Left => {
self.s3_panel_selected = true;
}
KeyCode::Right => {
self.s3_panel_selected = false;
}
KeyCode::Char('?') => {
self.show_shortcuts_overlay = true;
}
KeyCode::Char('l') => {
let _ = self.action_tx.send(Action::Navigate {
page: ActivePage::Transfers,
});
}
KeyCode::Char('s') => {
let _ = self.action_tx.send(Action::Navigate {
page: ActivePage::S3Creds,
});
}
KeyCode::Tab => {
self.s3_panel_selected = !&self.s3_panel_selected;
}
KeyCode::Char('q') => {
self.show_quit_confirmation = true;
}
KeyCode::F(5) => {
self.handle_refresh();
}
KeyCode::F(1) => {
if self.s3_panel_selected {
let _ = self.action_tx.send(Action::SortS3 {
column: SortColumn::Name,
});
} else {
let _ = self.action_tx.send(Action::SortLocal {
column: SortColumn::Name,
});
}
}
KeyCode::F(2) => {
if self.s3_panel_selected {
let _ = self.action_tx.send(Action::SortS3 {
column: SortColumn::Size,
});
} else {
let _ = self.action_tx.send(Action::SortLocal {
column: SortColumn::Size,
});
}
}
KeyCode::F(3) => {
if self.s3_panel_selected {
let _ = self.action_tx.send(Action::SortS3 {
column: SortColumn::Type,
});
} else {
let _ = self.action_tx.send(Action::SortLocal {
column: SortColumn::Type,
});
}
}
KeyCode::Char('/') => {
self.search_input.reset();
let _ = self.action_tx.send(Action::SetSearchMode { active: true });
}
_ => {}
}
}
}
}
impl ComponentRender<()> for FileManagerPage {
fn render(&self, frame: &mut Frame, _props: ()) {
let focus_color = Color::Rgb(98, 114, 164);
let vertical_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(0), Constraint::Length(1), ])
.split(frame.area());
let horizontal_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(vertical_chunks[0]);
if self.props.s3_loading {
let chunks_h = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(25), Constraint::Percentage(50),
Constraint::Percentage(25),
])
.split(horizontal_chunks[0]);
let chunks_v = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(25), Constraint::Percentage(50),
Constraint::Percentage(25),
])
.split(chunks_h[1]);
let loading_info = self.get_loading_info();
let loader_layout = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - 50) / 2),
Constraint::Percentage(50),
Constraint::Percentage((100 - 50) / 2),
])
.split(chunks_v[1]);
frame.render_widget(loading_info, loader_layout[1]);
} else {
let s3_table = self.get_s3_table(focus_color);
frame.render_stateful_widget(
&s3_table,
horizontal_chunks[0],
&mut self.props.clone().s3_table_state,
);
}
let local_table = self.get_local_table(focus_color);
frame.render_stateful_widget(
&local_table,
horizontal_chunks[1],
&mut self.props.clone().local_table_state,
);
let status_line = self.get_status_line();
let help_line = self.get_help_line();
let status_line_layout = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref())
.split(vertical_chunks[1]);
frame.render_widget(status_line, status_line_layout[0]);
frame.render_widget(help_line, status_line_layout[1]);
if self.show_problem_popup {
let area = Self::centered_rect(60, 20, frame.area());
frame.render_widget(Clear, area); let block = self.make_transfer_error_popup();
frame.render_widget(block, area);
} else if self.show_bucket_input {
let block = self.make_bucket_name_input();
let area = Self::centered_rect(40, 20, frame.area());
frame.render_widget(Clear, area); frame.render_widget(block, area);
if let Some(ref error) = self.props.create_bucket_error {
let error_paragraph =
Paragraph::new(format!("* {}", error)).style(Style::default().fg(Color::Red));
let error_rect = Rect::new(area.x + 1, area.y + 4, area.width, area.height);
frame.render_widget(Clear, error_rect);
frame.render_widget(error_paragraph, error_rect);
}
frame.set_cursor_position(ratatui::layout::Position::new(
area.x + self.input.visual_cursor() as u16 + 1,
area.y + 1,
));
} else if self.show_delete_confirmation {
let area = Self::centered_rect(60, 20, frame.area());
frame.render_widget(Clear, area); let block = self.make_delete_alert(
"Are you sure you want to delete this object?".to_string(),
Color::Green,
);
frame.render_widget(block, area);
} else if self.show_delete_multiple_confirmation {
let area = Self::centered_rect(60, 20, frame.area());
frame.render_widget(Clear, area);
let block = if self.props.s3_list_recursive_loading {
self.make_confirm_download_alert(
"Loading selected object information recursively...".to_string(),
Color::Green,
false,
)
} else {
self.make_confirm_download_alert(
format!(
"You have selected {} items to delete. Proceed?",
self.props.s3_data_full_list.len()
)
.to_string(),
Color::Green,
true,
)
};
frame.render_widget(block, area);
} else if self.show_download_confirmation {
let area = Self::centered_rect(60, 20, frame.area());
frame.render_widget(Clear, area);
let block = if self.props.s3_list_recursive_loading {
self.make_confirm_download_alert(
"Loading selected object information recursively...".to_string(),
Color::Green,
false,
)
} else {
self.make_confirm_download_alert(
format!(
"You have selected {} items to download. Proceed?",
self.props.s3_data_full_list.len()
)
.to_string(),
Color::Green,
true,
)
};
frame.render_widget(block, area);
} else if self.show_delete_error {
let possible_error: Option<String> = match (
&self.props.s3_delete_error,
&self.props.local_delete_error,
) {
(Some(err), None) => Some(err.to_string()),
(None, Some(err)) => Some(err.to_string()),
_ => None,
};
if let Some(err) = possible_error {
let area = Self::centered_rect(60, 20, frame.area());
frame.render_widget(Clear, area); let block = self.make_delete_alert(err, Color::Red);
frame.render_widget(block, area);
}
} else if self.props.search_mode {
let area = Self::centered_rect(50, 15, frame.area());
frame.render_widget(Clear, area);
let block = self.make_search_input();
frame.render_widget(block, area);
frame.set_cursor_position(ratatui::layout::Position::new(
area.x + self.search_input.visual_cursor() as u16 + 1,
area.y + 1,
));
} else if self.show_quit_confirmation {
QuitConfirmation::render(frame);
} else if self.show_shortcuts_overlay {
let area = Self::centered_rect(50, 60, 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::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
use tokio::sync::mpsc;
#[tokio::test]
async fn test_navigation_keys() {
let (tx, _rx) = mpsc::unbounded_channel();
let state = State::default(); let mut page = FileManagerPage::new(&state, tx);
assert!(
page.s3_panel_selected,
"S3 panel should be initially selected"
);
page.handle_key_event(KeyEvent {
code: KeyCode::Tab,
kind: KeyEventKind::Press,
modifiers: KeyModifiers::NONE,
state: KeyEventState::NONE,
});
assert!(
!page.s3_panel_selected,
"Local panel should be selected after tab"
);
}
}