diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f81e2e..ad1b8e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,8 +25,14 @@ Released on FIXME: ?? - Added the possibility to enabled the synchronized brower navigation - when you enter a directory, the same directory will be entered on the other tab - Enable sync browser with `` + - Read more on manual: [Synchronized browsing](docs/man.md#Synchronized-browsing-) - **Remote and Local hosts file formatter**: - Added the possibility to set different formatters for local and remote hosts +- **Work on multiple files**: + - Added the possibility to work on **multiple files simultaneously** + - Select a file with ``, the file when selected will have a `*` prepended to its name + - Select all files in the current directory with `` + - Read more on manual: [Work on multiple files](docs/man.md#Work-on-multiple-files-) - Enhancements - Added a status bar in the file explorer showing whether the sync browser is enabled and which file sorting mode is selected - Removed the goold old figlet title diff --git a/README.md b/README.md index 301ab7f..7d65229 100644 --- a/README.md +++ b/README.md @@ -184,8 +184,6 @@ The developer documentation can be found on Rust Docs at ` to move the remote explorer panel and `` to move back to the local explorer panel. Whenever you are in the find results panel, you need to press `` to exit panel and go back to the previous panel. + +### Keybindings ⌨ | Key | Command | Reminder | |---------------|-------------------------------------------------------|-------------| @@ -100,7 +114,8 @@ Password can be basically provided through 3 ways when address argument is provi | `` | Go to supplied path | Go to | | `` | Show help | Help | | `` | Show info about selected file or directory | Info | -| `` | Reload current directory's content | List | +| `` | Reload current directory's content / Clear selection | List | +| `` | Select a file | Mark | | `` | Create new file with provided name | New | | `` | Edit file; see [Text editor](#text-editor-) | Open | | `` | Quit termscp | Quit | @@ -110,8 +125,28 @@ Password can be basically provided through 3 ways when address argument is provi | `` | Execute a command | eXecute | | `` | Toggle synchronized browsing | sYnc | | `` | Delete file | | +| `` | Select all files | | | `` | Abort file transfer process | | +### Work on multiple files 🥷 + +You can opt to work on multiple files, selecting them pressing ``, in order to select the current file, or pressing ``, which will select all the files in the working directory. +Once a file is marked for selection, it will be displayed with a `*` on the left. +When working on selection, only selected file will be processed for actions, while the current highlighted item will be ignored. +It is possible to work on multiple files also when in the find result panel. +All the actions are available when working with multiple files, but be aware that some actions work in a slightly different way. Let's dive in: + +- *Copy*: whenever you copy a file, you'll be prompted to insert the destination name. When working with multiple file, this name refers to the destination directory where all these files will be copied. +- *Rename*: same as copy, but will move files there. +- *Save as*: same as copy, but will write them there. + +### Synchronized browsing ⏲️ + +When enabled, synchronized browsing, will allow you to synchronize the navigation between the two panels. +This means that whenever you'll change the working directory on one panel, the same action will be reproduced on the other panel. If you want to enable synchronized browsing just press ``; press twice to disable. While enabled, the synchronized browising state will be reported on the status bar on `ON`. + +*Warning*: at the moment, whenever you try to access an unexisting directory, you won't be prompted to create it. This might change in a future update. + --- ## Bookmarks ⭐ diff --git a/src/ui/activities/filetransfer/actions/copy.rs b/src/ui/activities/filetransfer/actions/copy.rs index 3b3d7c4..550c643 100644 --- a/src/ui/activities/filetransfer/actions/copy.rs +++ b/src/ui/activities/filetransfer/actions/copy.rs @@ -26,41 +26,34 @@ * SOFTWARE. */ // locals -use super::{FileTransferActivity, FsEntry, LogLevel}; -use std::path::PathBuf; +use super::{FileTransferActivity, FsEntry, LogLevel, SelectedEntry}; +use std::path::{Path, PathBuf}; impl FileTransferActivity { /// ### action_local_copy /// /// Copy file on local pub(crate) fn action_local_copy(&mut self, input: String) { - if let Some(idx) = self.get_local_file_idx() { - let dest_path: PathBuf = PathBuf::from(input); - let entry: FsEntry = self.local().get(idx).unwrap().clone(); - match self.host.copy(&entry, dest_path.as_path()) { - Ok(_) => { - self.log( - LogLevel::Info, - format!( - "Copied \"{}\" to \"{}\"", - entry.get_abs_path().display(), - dest_path.display() - ), - ); - // Reload entries - let wrkdir: PathBuf = self.local().wrkdir.clone(); - self.local_scan(wrkdir.as_path()); - } - Err(err) => self.log_and_alert( - LogLevel::Error, - format!( - "Could not copy \"{}\" to \"{}\": {}", - entry.get_abs_path().display(), - dest_path.display(), - err - ), - ), + match self.get_local_selected_entries() { + SelectedEntry::One(entry) => { + let dest_path: PathBuf = PathBuf::from(input); + self.local_copy_file(&entry, dest_path.as_path()); + // Reload entries + self.reload_local_dir(); } + SelectedEntry::Many(entries) => { + // Try to copy each file to Input/{FILE_NAME} + let base_path: PathBuf = PathBuf::from(input); + // Iter files + for entry in entries.iter() { + let mut dest_path: PathBuf = base_path.clone(); + dest_path.push(entry.get_name()); + self.local_copy_file(entry, dest_path.as_path()); + } + // Reload entries + self.reload_local_dir(); + } + SelectedEntry::None => {} } } @@ -68,31 +61,74 @@ impl FileTransferActivity { /// /// Copy file on remote pub(crate) fn action_remote_copy(&mut self, input: String) { - if let Some(idx) = self.get_remote_file_idx() { - let dest_path: PathBuf = PathBuf::from(input); - let entry: FsEntry = self.remote().get(idx).unwrap().clone(); - match self.client.as_mut().copy(&entry, dest_path.as_path()) { - Ok(_) => { - self.log( - LogLevel::Info, - format!( - "Copied \"{}\" to \"{}\"", - entry.get_abs_path().display(), - dest_path.display() - ), - ); - self.reload_remote_dir(); - } - Err(err) => self.log_and_alert( - LogLevel::Error, - format!( - "Could not copy \"{}\" to \"{}\": {}", - entry.get_abs_path().display(), - dest_path.display(), - err - ), - ), + match self.get_remote_selected_entries() { + SelectedEntry::One(entry) => { + let dest_path: PathBuf = PathBuf::from(input); + self.remote_copy_file(&entry, dest_path.as_path()); + // Reload entries + self.reload_remote_dir(); } + SelectedEntry::Many(entries) => { + // Try to copy each file to Input/{FILE_NAME} + let base_path: PathBuf = PathBuf::from(input); + // Iter files + for entry in entries.iter() { + let mut dest_path: PathBuf = base_path.clone(); + dest_path.push(entry.get_name()); + self.remote_copy_file(entry, dest_path.as_path()); + } + // Reload entries + self.reload_remote_dir(); + } + SelectedEntry::None => {} + } + } + + fn local_copy_file(&mut self, entry: &FsEntry, dest: &Path) { + match self.host.copy(entry, dest) { + Ok(_) => { + self.log( + LogLevel::Info, + format!( + "Copied \"{}\" to \"{}\"", + entry.get_abs_path().display(), + dest.display() + ), + ); + } + Err(err) => self.log_and_alert( + LogLevel::Error, + format!( + "Could not copy \"{}\" to \"{}\": {}", + entry.get_abs_path().display(), + dest.display(), + err + ), + ), + } + } + + fn remote_copy_file(&mut self, entry: &FsEntry, dest: &Path) { + match self.client.as_mut().copy(entry, dest) { + Ok(_) => { + self.log( + LogLevel::Info, + format!( + "Copied \"{}\" to \"{}\"", + entry.get_abs_path().display(), + dest.display() + ), + ); + } + Err(err) => self.log_and_alert( + LogLevel::Error, + format!( + "Could not copy \"{}\" to \"{}\": {}", + entry.get_abs_path().display(), + dest.display(), + err + ), + ), } } } diff --git a/src/ui/activities/filetransfer/actions/delete.rs b/src/ui/activities/filetransfer/actions/delete.rs index 4628c19..3d95a19 100644 --- a/src/ui/activities/filetransfer/actions/delete.rs +++ b/src/ui/activities/filetransfer/actions/delete.rs @@ -26,58 +26,90 @@ * SOFTWARE. */ // locals -use super::{FileTransferActivity, FsEntry, LogLevel}; -use std::path::PathBuf; +use super::{FileTransferActivity, FsEntry, LogLevel, SelectedEntry}; impl FileTransferActivity { pub(crate) fn action_local_delete(&mut self) { - let entry: Option = self.get_local_file_entry().cloned(); - if let Some(entry) = entry { - let full_path: PathBuf = entry.get_abs_path(); - // Delete file or directory and report status as popup - match self.host.remove(&entry) { - Ok(_) => { - // Reload files - let p: PathBuf = self.local().wrkdir.clone(); - self.local_scan(p.as_path()); - // Log - self.log( - LogLevel::Info, - format!("Removed file \"{}\"", full_path.display()), - ); - } - Err(err) => { - self.log_and_alert( - LogLevel::Error, - format!("Could not delete file \"{}\": {}", full_path.display(), err), - ); - } + match self.get_local_selected_entries() { + SelectedEntry::One(entry) => { + // Delete file + self.local_remove_file(&entry); + // Reload + self.reload_local_dir(); } + SelectedEntry::Many(entries) => { + // Iter files + for entry in entries.iter() { + // Delete file + self.local_remove_file(entry); + } + // Reload entries + self.reload_local_dir(); + } + SelectedEntry::None => {} } } pub(crate) fn action_remote_delete(&mut self) { - if let Some(idx) = self.get_remote_file_idx() { - // Check if file entry exists - let entry = self.remote().get(idx).cloned(); - if let Some(entry) = entry { - let full_path: PathBuf = entry.get_abs_path(); + match self.get_remote_selected_entries() { + SelectedEntry::One(entry) => { // Delete file - match self.client.remove(&entry) { - Ok(_) => { - self.reload_remote_dir(); - self.log( - LogLevel::Info, - format!("Removed file \"{}\"", full_path.display()), - ); - } - Err(err) => { - self.log_and_alert( - LogLevel::Error, - format!("Could not delete file \"{}\": {}", full_path.display(), err), - ); - } + self.remote_remove_file(&entry); + // Reload + self.reload_remote_dir(); + } + SelectedEntry::Many(entries) => { + // Iter files + for entry in entries.iter() { + // Delete file + self.remote_remove_file(entry); } + // Reload entries + self.reload_remote_dir(); + } + SelectedEntry::None => {} + } + } + + pub(crate) fn local_remove_file(&mut self, entry: &FsEntry) { + match self.host.remove(&entry) { + Ok(_) => { + // Log + self.log( + LogLevel::Info, + format!("Removed file \"{}\"", entry.get_abs_path().display()), + ); + } + Err(err) => { + self.log_and_alert( + LogLevel::Error, + format!( + "Could not delete file \"{}\": {}", + entry.get_abs_path().display(), + err + ), + ); + } + } + } + + pub(crate) fn remote_remove_file(&mut self, entry: &FsEntry) { + match self.client.remove(&entry) { + Ok(_) => { + self.log( + LogLevel::Info, + format!("Removed file \"{}\"", entry.get_abs_path().display()), + ); + } + Err(err) => { + self.log_and_alert( + LogLevel::Error, + format!( + "Could not delete file \"{}\": {}", + entry.get_abs_path().display(), + err + ), + ); } } } diff --git a/src/ui/activities/filetransfer/actions/edit.rs b/src/ui/activities/filetransfer/actions/edit.rs index 3cb1543..ec88186 100644 --- a/src/ui/activities/filetransfer/actions/edit.rs +++ b/src/ui/activities/filetransfer/actions/edit.rs @@ -26,51 +26,54 @@ * SOFTWARE. */ // locals -use super::{FileTransferActivity, FsEntry, LogLevel}; -use std::path::PathBuf; +use super::{FileTransferActivity, FsEntry, LogLevel, SelectedEntry}; impl FileTransferActivity { pub(crate) fn action_edit_local_file(&mut self) { - if self.get_local_file_entry().is_some() { - let fsentry: FsEntry = self.get_local_file_entry().unwrap().clone(); + let entries: Vec = match self.get_local_selected_entries() { + SelectedEntry::One(entry) => vec![entry], + SelectedEntry::Many(entries) => entries, + SelectedEntry::None => vec![], + }; + // Edit all entries + for entry in entries.iter() { // Check if file - if fsentry.is_file() { + if entry.is_file() { self.log( LogLevel::Info, - format!("Opening file \"{}\"...", fsentry.get_abs_path().display()), + format!("Opening file \"{}\"...", entry.get_abs_path().display()), ); // Edit file - match self.edit_local_file(fsentry.get_abs_path().as_path()) { - Ok(_) => { - // Reload directory - let pwd: PathBuf = self.local().wrkdir.clone(); - self.local_scan(pwd.as_path()); - } - Err(err) => self.log_and_alert(LogLevel::Error, err), + if let Err(err) = self.edit_local_file(entry.get_abs_path().as_path()) { + self.log_and_alert(LogLevel::Error, err); } } } + // Reload entries + self.reload_local_dir(); } pub(crate) fn action_edit_remote_file(&mut self) { - if self.get_remote_file_entry().is_some() { - let fsentry: FsEntry = self.get_remote_file_entry().unwrap().clone(); + let entries: Vec = match self.get_remote_selected_entries() { + SelectedEntry::One(entry) => vec![entry], + SelectedEntry::Many(entries) => entries, + SelectedEntry::None => vec![], + }; + // Edit all entries + for entry in entries.iter() { // Check if file - if let FsEntry::File(file) = fsentry.clone() { + if let FsEntry::File(file) = entry { self.log( LogLevel::Info, - format!("Opening file \"{}\"...", fsentry.get_abs_path().display()), + format!("Opening file \"{}\"...", entry.get_abs_path().display()), ); // Edit file - match self.edit_remote_file(&file) { - Ok(_) => { - // Reload directory - let pwd: PathBuf = self.remote().wrkdir.clone(); - self.remote_scan(pwd.as_path()); - } - Err(err) => self.log_and_alert(LogLevel::Error, err), + if let Err(err) = self.edit_remote_file(&file) { + self.log_and_alert(LogLevel::Error, err); } } } + // Reload entries + self.reload_remote_dir(); } } diff --git a/src/ui/activities/filetransfer/actions/exec.rs b/src/ui/activities/filetransfer/actions/exec.rs index 73ab3cb..ba897c2 100644 --- a/src/ui/activities/filetransfer/actions/exec.rs +++ b/src/ui/activities/filetransfer/actions/exec.rs @@ -27,7 +27,6 @@ */ // locals use super::{FileTransferActivity, LogLevel}; -use std::path::PathBuf; impl FileTransferActivity { pub(crate) fn action_local_exec(&mut self, input: String) { @@ -35,8 +34,8 @@ impl FileTransferActivity { Ok(output) => { // Reload files self.log(LogLevel::Info, format!("\"{}\": {}", input, output)); - let wrkdir: PathBuf = self.local().wrkdir.clone(); - self.local_scan(wrkdir.as_path()); + // Reload entries + self.reload_local_dir(); } Err(err) => { // Report err diff --git a/src/ui/activities/filetransfer/actions/find.rs b/src/ui/activities/filetransfer/actions/find.rs index 6268bfb..7e0a97d 100644 --- a/src/ui/activities/filetransfer/actions/find.rs +++ b/src/ui/activities/filetransfer/actions/find.rs @@ -27,7 +27,7 @@ */ // locals use super::super::browser::FileExplorerTab; -use super::{FileTransferActivity, FsEntry, LogLevel}; +use super::{FileTransferActivity, FsEntry, SelectedEntry}; use std::path::PathBuf; @@ -46,12 +46,12 @@ impl FileTransferActivity { } } - pub(crate) fn action_find_changedir(&mut self, idx: usize) { + pub(crate) fn action_find_changedir(&mut self) { // Match entry - if let Some(entry) = self.found().as_ref().unwrap().get(idx) { + if let SelectedEntry::One(entry) = self.get_found_selected_entries() { // Get path: if a directory, use directory path; if it is a File, get parent path let path: PathBuf = match entry { - FsEntry::Directory(dir) => dir.abs_path.clone(), + FsEntry::Directory(dir) => dir.abs_path, FsEntry::File(file) => match file.abs_path.parent() { None => PathBuf::from("."), Some(p) => p.to_path_buf(), @@ -69,78 +69,75 @@ impl FileTransferActivity { } } - pub(crate) fn action_find_transfer(&mut self, idx: usize, name: Option) { - let entry: Option = self.found().as_ref().unwrap().get(idx).cloned(); - if let Some(entry) = entry { - // Download file - match self.browser.tab() { + pub(crate) fn action_find_transfer(&mut self, save_as: Option) { + let wrkdir: PathBuf = match self.browser.tab() { + FileExplorerTab::FindLocal | FileExplorerTab::Local => self.remote().wrkdir.clone(), + FileExplorerTab::FindRemote | FileExplorerTab::Remote => self.local().wrkdir.clone(), + }; + match self.get_found_selected_entries() { + SelectedEntry::One(entry) => match self.browser.tab() { FileExplorerTab::FindLocal | FileExplorerTab::Local => { - let wrkdir: PathBuf = self.remote().wrkdir.clone(); - self.filetransfer_send(&entry.get_realfile(), wrkdir.as_path(), name); + self.filetransfer_send(&entry.get_realfile(), wrkdir.as_path(), save_as); } FileExplorerTab::FindRemote | FileExplorerTab::Remote => { - let wrkdir: PathBuf = self.local().wrkdir.clone(); - self.filetransfer_recv(&entry.get_realfile(), wrkdir.as_path(), name); + self.filetransfer_recv(&entry.get_realfile(), wrkdir.as_path(), save_as); + } + }, + SelectedEntry::Many(entries) => { + // In case of selection: save multiple files in wrkdir/input + let mut dest_path: PathBuf = wrkdir; + if let Some(save_as) = save_as { + dest_path.push(save_as); + } + // Iter files + for entry in entries.iter() { + match self.browser.tab() { + FileExplorerTab::FindLocal | FileExplorerTab::Local => { + self.filetransfer_send( + &entry.get_realfile(), + dest_path.as_path(), + None, + ); + } + FileExplorerTab::FindRemote | FileExplorerTab::Remote => { + self.filetransfer_recv( + &entry.get_realfile(), + dest_path.as_path(), + None, + ); + } + } } } + SelectedEntry::None => {} } } - pub(crate) fn action_find_delete(&mut self, idx: usize) { - let entry: Option = self.found().as_ref().unwrap().get(idx).cloned(); - if let Some(entry) = entry { - // Download file - match self.browser.tab() { - FileExplorerTab::FindLocal | FileExplorerTab::Local => { - let full_path: PathBuf = entry.get_abs_path(); - // Delete file or directory and report status as popup - match self.host.remove(&entry) { - Ok(_) => { - // Reload files - let p: PathBuf = self.local().wrkdir.clone(); - self.local_scan(p.as_path()); - // Log - self.log( - LogLevel::Info, - format!("Removed file \"{}\"", full_path.display()), - ); - } - Err(err) => { - self.log_and_alert( - LogLevel::Error, - format!( - "Could not delete file \"{}\": {}", - full_path.display(), - err - ), - ); - } - } - } - FileExplorerTab::FindRemote | FileExplorerTab::Remote => { - let full_path: PathBuf = entry.get_abs_path(); + pub(crate) fn action_find_delete(&mut self) { + match self.get_found_selected_entries() { + SelectedEntry::One(entry) => { + // Delete file + self.remove_found_file(&entry); + } + SelectedEntry::Many(entries) => { + // Iter files + for entry in entries.iter() { // Delete file - match self.client.remove(&entry) { - Ok(_) => { - self.reload_remote_dir(); - self.log( - LogLevel::Info, - format!("Removed file \"{}\"", full_path.display()), - ); - } - Err(err) => { - self.log_and_alert( - LogLevel::Error, - format!( - "Could not delete file \"{}\": {}", - full_path.display(), - err - ), - ); - } - } + self.remove_found_file(entry); } } + SelectedEntry::None => {} + } + } + + fn remove_found_file(&mut self, entry: &FsEntry) { + match self.browser.tab() { + FileExplorerTab::FindLocal | FileExplorerTab::Local => { + self.local_remove_file(entry); + } + FileExplorerTab::FindRemote | FileExplorerTab::Remote => { + self.remote_remove_file(entry); + } } } } diff --git a/src/ui/activities/filetransfer/actions/mkdir.rs b/src/ui/activities/filetransfer/actions/mkdir.rs index 3f78823..3664920 100644 --- a/src/ui/activities/filetransfer/actions/mkdir.rs +++ b/src/ui/activities/filetransfer/actions/mkdir.rs @@ -35,8 +35,8 @@ impl FileTransferActivity { Ok(_) => { // Reload files self.log(LogLevel::Info, format!("Created directory \"{}\"", input)); - let wrkdir: PathBuf = self.local().wrkdir.clone(); - self.local_scan(wrkdir.as_path()); + // Reload entries + self.reload_local_dir(); } Err(err) => { // Report err diff --git a/src/ui/activities/filetransfer/actions/mod.rs b/src/ui/activities/filetransfer/actions/mod.rs index 1dda2ce..2e13003 100644 --- a/src/ui/activities/filetransfer/actions/mod.rs +++ b/src/ui/activities/filetransfer/actions/mod.rs @@ -40,46 +40,109 @@ pub(crate) mod newfile; pub(crate) mod rename; pub(crate) mod save; +pub(crate) enum SelectedEntry { + One(FsEntry), + Many(Vec), + None, +} + +enum SelectedEntryIndex { + One(usize), + Many(Vec), + None, +} + +impl From> for SelectedEntry { + fn from(opt: Option<&FsEntry>) -> Self { + match opt { + Some(e) => SelectedEntry::One(e.clone()), + None => SelectedEntry::None, + } + } +} + +impl From> for SelectedEntry { + fn from(files: Vec<&FsEntry>) -> Self { + SelectedEntry::Many(files.into_iter().cloned().collect()) + } +} + impl FileTransferActivity { - /// ### get_local_file_entry + /// ### get_local_selected_entries /// /// Get local file entry - pub(crate) fn get_local_file_entry(&self) -> Option<&FsEntry> { - match self.get_local_file_idx() { - None => None, - Some(idx) => self.local().get(idx), + pub(crate) fn get_local_selected_entries(&self) -> SelectedEntry { + match self.get_selected_index(super::COMPONENT_EXPLORER_LOCAL) { + SelectedEntryIndex::One(idx) => SelectedEntry::from(self.local().get(idx)), + SelectedEntryIndex::Many(files) => { + let files: Vec<&FsEntry> = files + .iter() + .map(|x| self.local().get(*x)) // Usize to Option + .filter(|x| x.is_some()) // Get only some values + .map(|x| x.unwrap()) // Option to FsEntry + .collect(); + SelectedEntry::from(files) + } + SelectedEntryIndex::None => SelectedEntry::None, } } - /// ### get_remote_file_entry + /// ### get_remote_selected_entries /// /// Get remote file entry - pub(crate) fn get_remote_file_entry(&self) -> Option<&FsEntry> { - match self.get_remote_file_idx() { - None => None, - Some(idx) => self.remote().get(idx), + pub(crate) fn get_remote_selected_entries(&self) -> SelectedEntry { + match self.get_selected_index(super::COMPONENT_EXPLORER_REMOTE) { + SelectedEntryIndex::One(idx) => SelectedEntry::from(self.remote().get(idx)), + SelectedEntryIndex::Many(files) => { + let files: Vec<&FsEntry> = files + .iter() + .map(|x| self.remote().get(*x)) // Usize to Option + .filter(|x| x.is_some()) // Get only some values + .map(|x| x.unwrap()) // Option to FsEntry + .collect(); + SelectedEntry::from(files) + } + SelectedEntryIndex::None => SelectedEntry::None, + } + } + + /// ### get_remote_selected_entries + /// + /// Get remote file entry + pub(crate) fn get_found_selected_entries(&self) -> SelectedEntry { + match self.get_selected_index(super::COMPONENT_EXPLORER_FIND) { + SelectedEntryIndex::One(idx) => { + SelectedEntry::from(self.found().as_ref().unwrap().get(idx)) + } + SelectedEntryIndex::Many(files) => { + let files: Vec<&FsEntry> = files + .iter() + .map(|x| self.found().as_ref().unwrap().get(*x)) // Usize to Option + .filter(|x| x.is_some()) // Get only some values + .map(|x| x.unwrap()) // Option to FsEntry + .collect(); + SelectedEntry::from(files) + } + SelectedEntryIndex::None => SelectedEntry::None, } } // -- private - /// ### get_local_file_idx - /// - /// Get index of selected file in the local tab - fn get_local_file_idx(&self) -> Option { - match self.view.get_state(super::COMPONENT_EXPLORER_LOCAL) { - Some(Payload::One(Value::Usize(idx))) => Some(idx), - _ => None, - } - } - - /// ### get_remote_file_idx - /// - /// Get index of selected file in the remote file - fn get_remote_file_idx(&self) -> Option { - match self.view.get_state(super::COMPONENT_EXPLORER_REMOTE) { - Some(Payload::One(Value::Usize(idx))) => Some(idx), - _ => None, + fn get_selected_index(&self, component: &str) -> SelectedEntryIndex { + match self.view.get_state(component) { + Some(Payload::One(Value::Usize(idx))) => SelectedEntryIndex::One(idx), + Some(Payload::Vec(files)) => { + let list: Vec = files + .iter() + .map(|x| match x { + Value::Usize(v) => *v, + _ => 0, + }) + .collect(); + SelectedEntryIndex::Many(list) + } + _ => SelectedEntryIndex::None, } } } diff --git a/src/ui/activities/filetransfer/actions/newfile.rs b/src/ui/activities/filetransfer/actions/newfile.rs index f09baa8..ac65f79 100644 --- a/src/ui/activities/filetransfer/actions/newfile.rs +++ b/src/ui/activities/filetransfer/actions/newfile.rs @@ -59,8 +59,7 @@ impl FileTransferActivity { ); } // Reload files - let path: PathBuf = self.local().wrkdir.clone(); - self.local_scan(path.as_path()); + self.reload_local_dir(); } pub(crate) fn action_remote_newfile(&mut self, input: String) { @@ -119,8 +118,7 @@ impl FileTransferActivity { ); } // Reload files - let path: PathBuf = self.remote().wrkdir.clone(); - self.remote_scan(path.as_path()); + self.reload_remote_dir(); } } } diff --git a/src/ui/activities/filetransfer/actions/rename.rs b/src/ui/activities/filetransfer/actions/rename.rs index 47a483a..80f3fd5 100644 --- a/src/ui/activities/filetransfer/actions/rename.rs +++ b/src/ui/activities/filetransfer/actions/rename.rs @@ -26,77 +26,103 @@ * SOFTWARE. */ // locals -use super::{FileTransferActivity, FsEntry, LogLevel}; -use std::path::PathBuf; +use super::{FileTransferActivity, FsEntry, LogLevel, SelectedEntry}; +use std::path::{Path, PathBuf}; impl FileTransferActivity { pub(crate) fn action_local_rename(&mut self, input: String) { - let entry: Option = self.get_local_file_entry().cloned(); - if let Some(entry) = entry { - let mut dst_path: PathBuf = PathBuf::from(input); - // Check if path is relative - if dst_path.as_path().is_relative() { - let mut wrkdir: PathBuf = self.local().wrkdir.clone(); - wrkdir.push(dst_path); - dst_path = wrkdir; + match self.get_local_selected_entries() { + SelectedEntry::One(entry) => { + let dest_path: PathBuf = PathBuf::from(input); + self.local_rename_file(&entry, dest_path.as_path()); + // Reload entries + self.reload_local_dir(); } - let full_path: PathBuf = entry.get_abs_path(); - // Rename file or directory and report status as popup - match self.host.rename(&entry, dst_path.as_path()) { - Ok(_) => { - // Reload files - let path: PathBuf = self.local().wrkdir.clone(); - self.local_scan(path.as_path()); - // Log - self.log( - LogLevel::Info, - format!( - "Renamed file \"{}\" to \"{}\"", - full_path.display(), - dst_path.display() - ), - ); - } - Err(err) => { - self.log_and_alert( - LogLevel::Error, - format!("Could not rename file \"{}\": {}", full_path.display(), err), - ); + SelectedEntry::Many(entries) => { + // Try to copy each file to Input/{FILE_NAME} + let base_path: PathBuf = PathBuf::from(input); + // Iter files + for entry in entries.iter() { + let mut dest_path: PathBuf = base_path.clone(); + dest_path.push(entry.get_name()); + self.local_rename_file(entry, dest_path.as_path()); } + // Reload entries + self.reload_local_dir(); } + SelectedEntry::None => {} } } pub(crate) fn action_remote_rename(&mut self, input: String) { - if let Some(idx) = self.get_remote_file_idx() { - let entry = self.remote().get(idx).cloned(); - if let Some(entry) = entry { - let dst_path: PathBuf = PathBuf::from(input); - let full_path: PathBuf = entry.get_abs_path(); - // Rename file or directory and report status as popup - match self.client.as_mut().rename(&entry, dst_path.as_path()) { - Ok(_) => { - // Reload files - let path: PathBuf = self.remote().wrkdir.clone(); - self.remote_scan(path.as_path()); - // Log - self.log( - LogLevel::Info, - format!( - "Renamed file \"{}\" to \"{}\"", - full_path.display(), - dst_path.display() - ), - ); - } - Err(err) => { - self.log_and_alert( - LogLevel::Error, - format!("Could not rename file \"{}\": {}", full_path.display(), err), - ); - } - } + match self.get_remote_selected_entries() { + SelectedEntry::One(entry) => { + let dest_path: PathBuf = PathBuf::from(input); + self.remote_rename_file(&entry, dest_path.as_path()); + // Reload entries + self.reload_remote_dir(); } + SelectedEntry::Many(entries) => { + // Try to copy each file to Input/{FILE_NAME} + let base_path: PathBuf = PathBuf::from(input); + // Iter files + for entry in entries.iter() { + let mut dest_path: PathBuf = base_path.clone(); + dest_path.push(entry.get_name()); + self.remote_rename_file(entry, dest_path.as_path()); + } + // Reload entries + self.reload_remote_dir(); + } + SelectedEntry::None => {} + } + } + + fn local_rename_file(&mut self, entry: &FsEntry, dest: &Path) { + match self.host.rename(entry, dest) { + Ok(_) => { + self.log( + LogLevel::Info, + format!( + "Moved \"{}\" to \"{}\"", + entry.get_abs_path().display(), + dest.display() + ), + ); + } + Err(err) => self.log_and_alert( + LogLevel::Error, + format!( + "Could not move \"{}\" to \"{}\": {}", + entry.get_abs_path().display(), + dest.display(), + err + ), + ), + } + } + + fn remote_rename_file(&mut self, entry: &FsEntry, dest: &Path) { + match self.client.as_mut().rename(entry, dest) { + Ok(_) => { + self.log( + LogLevel::Info, + format!( + "Moved \"{}\" to \"{}\"", + entry.get_abs_path().display(), + dest.display() + ), + ); + } + Err(err) => self.log_and_alert( + LogLevel::Error, + format!( + "Could not move \"{}\" to \"{}\": {}", + entry.get_abs_path().display(), + dest.display(), + err + ), + ), } } } diff --git a/src/ui/activities/filetransfer/actions/save.rs b/src/ui/activities/filetransfer/actions/save.rs index 22912a9..508d99c 100644 --- a/src/ui/activities/filetransfer/actions/save.rs +++ b/src/ui/activities/filetransfer/actions/save.rs @@ -26,31 +26,65 @@ * SOFTWARE. */ // locals -use super::{FileTransferActivity, FsEntry}; +use super::{FileTransferActivity, SelectedEntry}; use std::path::PathBuf; impl FileTransferActivity { pub(crate) fn action_local_saveas(&mut self, input: String) { - if let Some(idx) = self.get_local_file_idx() { - // Get pwd - let wrkdir: PathBuf = self.remote().wrkdir.clone(); - if self.local().get(idx).is_some() { - let file: FsEntry = self.local().get(idx).unwrap().clone(); - // Call upload; pass realfile, keep link name - self.filetransfer_send(&file.get_realfile(), wrkdir.as_path(), Some(input)); - } - } + self.action_local_send_file(Some(input)); } pub(crate) fn action_remote_saveas(&mut self, input: String) { - if let Some(idx) = self.get_remote_file_idx() { - // Get pwd - let wrkdir: PathBuf = self.local().wrkdir.clone(); - if self.remote().get(idx).is_some() { - let file: FsEntry = self.remote().get(idx).unwrap().clone(); - // Call upload; pass realfile, keep link name - self.filetransfer_recv(&file.get_realfile(), wrkdir.as_path(), Some(input)); + self.action_remote_recv_file(Some(input)); + } + + pub(crate) fn action_local_send(&mut self) { + self.action_local_send_file(None); + } + + pub(crate) fn action_remote_recv(&mut self) { + self.action_remote_recv_file(None); + } + + fn action_local_send_file(&mut self, save_as: Option) { + let wrkdir: PathBuf = self.remote().wrkdir.clone(); + match self.get_local_selected_entries() { + SelectedEntry::One(entry) => { + self.filetransfer_send(&entry.get_realfile(), wrkdir.as_path(), save_as); } + SelectedEntry::Many(entries) => { + // In case of selection: save multiple files in wrkdir/input + let mut dest_path: PathBuf = wrkdir; + if let Some(save_as) = save_as { + dest_path.push(save_as); + } + // Iter files + for entry in entries.iter() { + self.filetransfer_send(&entry.get_realfile(), dest_path.as_path(), None); + } + } + SelectedEntry::None => {} + } + } + + fn action_remote_recv_file(&mut self, save_as: Option) { + let wrkdir: PathBuf = self.local().wrkdir.clone(); + match self.get_remote_selected_entries() { + SelectedEntry::One(entry) => { + self.filetransfer_recv(&entry.get_realfile(), wrkdir.as_path(), save_as); + } + SelectedEntry::Many(entries) => { + // In case of selection: save multiple files in wrkdir/input + let mut dest_path: PathBuf = wrkdir; + if let Some(save_as) = save_as { + dest_path.push(save_as); + } + // Iter files + for entry in entries.iter() { + self.filetransfer_recv(&entry.get_realfile(), dest_path.as_path(), None); + } + } + SelectedEntry::None => {} } } } diff --git a/src/ui/activities/filetransfer/session.rs b/src/ui/activities/filetransfer/session.rs index d1c66d1..481a572 100644 --- a/src/ui/activities/filetransfer/session.rs +++ b/src/ui/activities/filetransfer/session.rs @@ -145,6 +145,11 @@ impl FileTransferActivity { } } + pub(super) fn reload_local_dir(&mut self) { + let wrkdir: PathBuf = self.local().wrkdir.clone(); + self.local_scan(wrkdir.as_path()); + } + /// ### filetransfer_send /// /// Send fs entry to remote. @@ -257,8 +262,7 @@ impl FileTransferActivity { } } // Scan dir on remote - let path: PathBuf = self.remote().wrkdir.clone(); - self.remote_scan(path.as_path()); + self.reload_remote_dir(); // If aborted; show popup if self.transfer.aborted { // Log abort diff --git a/src/ui/activities/filetransfer/update.rs b/src/ui/activities/filetransfer/update.rs index d7bd6d0..4c3f156 100644 --- a/src/ui/activities/filetransfer/update.rs +++ b/src/ui/activities/filetransfer/update.rs @@ -29,10 +29,10 @@ extern crate bytesize; // locals use super::{ - browser::FileExplorerTab, FileTransferActivity, LogLevel, COMPONENT_EXPLORER_FIND, - COMPONENT_EXPLORER_LOCAL, COMPONENT_EXPLORER_REMOTE, COMPONENT_INPUT_COPY, - COMPONENT_INPUT_EXEC, COMPONENT_INPUT_FIND, COMPONENT_INPUT_GOTO, COMPONENT_INPUT_MKDIR, - COMPONENT_INPUT_NEWFILE, COMPONENT_INPUT_RENAME, COMPONENT_INPUT_SAVEAS, + actions::SelectedEntry, browser::FileExplorerTab, FileTransferActivity, LogLevel, + COMPONENT_EXPLORER_FIND, COMPONENT_EXPLORER_LOCAL, COMPONENT_EXPLORER_REMOTE, + COMPONENT_INPUT_COPY, COMPONENT_INPUT_EXEC, COMPONENT_INPUT_FIND, COMPONENT_INPUT_GOTO, + COMPONENT_INPUT_MKDIR, COMPONENT_INPUT_NEWFILE, COMPONENT_INPUT_RENAME, COMPONENT_INPUT_SAVEAS, COMPONENT_LIST_FILEINFO, COMPONENT_LOG_BOX, COMPONENT_PROGRESS_BAR, COMPONENT_RADIO_DELETE, COMPONENT_RADIO_DISCONNECT, COMPONENT_RADIO_QUIT, COMPONENT_RADIO_SORTING, COMPONENT_TEXT_ERROR, COMPONENT_TEXT_FATAL, COMPONENT_TEXT_HELP, @@ -101,18 +101,8 @@ impl FileTransferActivity { } } (COMPONENT_EXPLORER_LOCAL, &MSG_KEY_SPACE) => { - // Get pwd - let wrkdir: PathBuf = self.remote().wrkdir.clone(); - // Get file and clone (due to mutable / immutable stuff...) - if self.get_local_file_entry().is_some() { - let file: FsEntry = self.get_local_file_entry().unwrap().clone(); - let name: String = file.get_name().to_string(); - // Call upload; pass realfile, keep link name - self.filetransfer_send(&file.get_realfile(), wrkdir.as_path(), Some(name)); - self.update_remote_filelist() - } else { - None - } + self.action_local_send(); + self.update_remote_filelist() } (COMPONENT_EXPLORER_LOCAL, &MSG_KEY_CHAR_A) => { // Toggle hidden files @@ -121,8 +111,7 @@ impl FileTransferActivity { self.update_local_filelist() } (COMPONENT_EXPLORER_LOCAL, &MSG_KEY_CHAR_I) => { - let file: Option = self.get_local_file_entry().cloned(); - if let Some(file) = file { + if let SelectedEntry::One(file) = self.get_local_selected_entries() { self.mount_file_info(&file); } None @@ -175,17 +164,8 @@ impl FileTransferActivity { } } (COMPONENT_EXPLORER_REMOTE, &MSG_KEY_SPACE) => { - // Get file and clone (due to mutable / immutable stuff...) - if self.get_remote_file_entry().is_some() { - let file: FsEntry = self.get_remote_file_entry().unwrap().clone(); - let name: String = file.get_name().to_string(); - // Call upload; pass realfile, keep link name - let wrkdir: PathBuf = self.local().wrkdir.clone(); - self.filetransfer_recv(&file.get_realfile(), wrkdir.as_path(), Some(name)); - self.update_local_filelist() - } else { - None - } + self.action_remote_recv(); + self.update_local_filelist() } (COMPONENT_EXPLORER_REMOTE, &MSG_KEY_BACKSPACE) => { // Go to previous directory @@ -204,8 +184,7 @@ impl FileTransferActivity { self.update_remote_filelist() } (COMPONENT_EXPLORER_REMOTE, &MSG_KEY_CHAR_I) => { - let file: Option = self.get_remote_file_entry().cloned(); - if let Some(file) = file { + if let SelectedEntry::One(file) = self.get_remote_selected_entries() { self.mount_file_info(&file); } None @@ -324,9 +303,9 @@ impl FileTransferActivity { self.finalize_find(); None } - (COMPONENT_EXPLORER_FIND, Msg::OnSubmit(Payload::One(Value::Usize(idx)))) => { + (COMPONENT_EXPLORER_FIND, Msg::OnSubmit(_)) => { // Find changedir - self.action_find_changedir(*idx); + self.action_find_changedir(); // Umount find self.umount_find(); // Finalize find @@ -340,17 +319,12 @@ impl FileTransferActivity { } (COMPONENT_EXPLORER_FIND, &MSG_KEY_SPACE) => { // Get entry - match self.view.get_state(COMPONENT_EXPLORER_FIND) { - Some(Payload::One(Value::Usize(idx))) => { - self.action_find_transfer(idx, None); - // Reload files - match self.browser.tab() { - // NOTE: swapped by purpose - FileExplorerTab::FindLocal => self.update_remote_filelist(), - FileExplorerTab::FindRemote => self.update_local_filelist(), - _ => None, - } - } + self.action_find_transfer(None); + // Reload files + match self.browser.tab() { + // NOTE: swapped by purpose + FileExplorerTab::FindLocal => self.update_remote_filelist(), + FileExplorerTab::FindRemote => self.update_local_filelist(), _ => None, } } @@ -540,11 +514,7 @@ impl FileTransferActivity { FileExplorerTab::Remote => self.action_remote_saveas(input.to_string()), FileExplorerTab::FindLocal | FileExplorerTab::FindRemote => { // Get entry - if let Some(Payload::One(Value::Usize(idx))) = - self.view.get_state(COMPONENT_EXPLORER_FIND) - { - self.action_find_transfer(idx, Some(input.to_string())); - } + self.action_find_transfer(Some(input.to_string())); } } self.umount_saveas(); @@ -576,14 +546,25 @@ impl FileTransferActivity { FileExplorerTab::Remote => self.action_remote_delete(), FileExplorerTab::FindLocal | FileExplorerTab::FindRemote => { // Get entry - if let Some(Payload::One(Value::Usize(idx))) = - self.view.get_state(COMPONENT_EXPLORER_FIND) - { - self.action_find_delete(idx); - // Reload entries - self.found_mut().unwrap().del_entry(idx); - self.update_find_list(); + self.action_find_delete(); + // Delete entries + match self.view.get_state(COMPONENT_EXPLORER_FIND) { + Some(Payload::One(Value::Usize(idx))) => { + // Reload entries + self.found_mut().unwrap().del_entry(idx); + } + Some(Payload::Vec(values)) => { + values + .iter() + .map(|x| match x { + Value::Usize(v) => *v, + _ => 0, + }) + .for_each(|x| self.found_mut().unwrap().del_entry(x)); + } + _ => {} } + self.update_find_list(); } } self.umount_radio_delete(); diff --git a/src/ui/activities/filetransfer/view.rs b/src/ui/activities/filetransfer/view.rs index bee6c03..48884ca 100644 --- a/src/ui/activities/filetransfer/view.rs +++ b/src/ui/activities/filetransfer/view.rs @@ -975,6 +975,14 @@ impl FileTransferActivity { ) .add_col(TextSpan::from(" Reload directory content")) .add_row() + .add_col( + TextSpanBuilder::new("") + .bold() + .with_foreground(Color::Cyan) + .build(), + ) + .add_col(TextSpan::from(" Select file")) + .add_row() .add_col( TextSpanBuilder::new("") .bold() @@ -1047,6 +1055,14 @@ impl FileTransferActivity { ) .add_col(TextSpan::from(" Delete selected file")) .add_row() + .add_col( + TextSpanBuilder::new("") + .bold() + .with_foreground(Color::Cyan) + .build(), + ) + .add_col(TextSpan::from(" Select all files")) + .add_row() .add_col( TextSpanBuilder::new("") .bold() diff --git a/src/ui/components/file_list.rs b/src/ui/components/file_list.rs index 105c084..ec21799 100644 --- a/src/ui/components/file_list.rs +++ b/src/ui/components/file_list.rs @@ -27,7 +27,7 @@ */ // ext use tuirealm::components::utils::get_block; -use tuirealm::event::{Event, KeyCode}; +use tuirealm::event::{Event, KeyCode, KeyModifiers}; use tuirealm::props::{BordersProps, Props, PropsBuilder, TextParts, TextSpan}; use tuirealm::tui::{ layout::{Corner, Rect}, @@ -133,33 +133,34 @@ impl FileListPropsBuilder { /// OwnStates contains states for this component #[derive(Clone)] struct OwnStates { - list_index: usize, // Index of selected element in list - list_len: usize, // Length of file list - focus: bool, // Has focus? + list_index: usize, // Index of selected element in list + selected: Vec, // Selected files + focus: bool, // Has focus? } impl Default for OwnStates { fn default() -> Self { OwnStates { list_index: 0, - list_len: 0, + selected: Vec::new(), focus: false, } } } impl OwnStates { - /// ### set_list_len + /// ### init_list_states /// - /// Set list length - pub fn set_list_len(&mut self, len: usize) { - self.list_len = len; + /// Initialize list states + pub fn init_list_states(&mut self, len: usize) { + self.selected = Vec::with_capacity(len); + self.fix_list_index(); } - /// ### get_list_index + /// ### list_index /// /// Return current value for list index - pub fn get_list_index(&self) -> usize { + pub fn list_index(&self) -> usize { self.list_index } @@ -168,7 +169,7 @@ impl OwnStates { /// Incremenet list index pub fn incr_list_index(&mut self) { // Check if index is at last element - if self.list_index + 1 < self.list_len { + if self.list_index + 1 < self.list_len() { self.list_index += 1; } } @@ -183,16 +184,83 @@ impl OwnStates { } } + /// ### list_len + /// + /// Returns the length of the file list, which is actually the capacity of the selection vector + pub fn list_len(&self) -> usize { + self.selected.capacity() + } + + /// ### is_selected + /// + /// Returns whether the file with index `entry` is selected + pub fn is_selected(&self, entry: usize) -> bool { + self.selected.contains(&entry) + } + + /// ### is_selection_empty + /// + /// Returns whether the selection is currently empty + pub fn is_selection_empty(&self) -> bool { + self.selected.is_empty() + } + + /// ### get_selection + /// + /// Returns current file selection + pub fn get_selection(&self) -> Vec { + self.selected.clone() + } + /// ### fix_list_index /// /// Keep index if possible, otherwise set to lenght - 1 - pub fn fix_list_index(&mut self) { - if self.list_index >= self.list_len && self.list_len > 0 { - self.list_index = self.list_len - 1; - } else if self.list_len == 0 { + fn fix_list_index(&mut self) { + if self.list_index >= self.list_len() && self.list_len() > 0 { + self.list_index = self.list_len() - 1; + } else if self.list_len() == 0 { self.list_index = 0; } } + + // -- select manipulation + + /// ### toggle_file + /// + /// Select or deselect file with provided entry index + pub fn toggle_file(&mut self, entry: usize) { + match self.is_selected(entry) { + true => self.deselect(entry), + false => self.select(entry), + } + } + + /// ### select_all + /// + /// Select all files + pub fn select_all(&mut self) { + for i in 0..self.list_len() { + self.select(i); + } + } + + /// ### select + /// + /// Select provided index if not selected yet + fn select(&mut self, entry: usize) { + if !self.is_selected(entry) { + self.selected.push(entry); + } + } + + /// ### deselect + /// + /// Remove element file with associated index + fn deselect(&mut self, entry: usize) { + if self.is_selected(entry) { + self.selected.retain(|&x| x != entry); + } + } } // -- Component @@ -213,11 +281,8 @@ impl FileList { pub fn new(props: Props) -> Self { // Initialize states let mut states: OwnStates = OwnStates::default(); - // Set list length - states.set_list_len(match &props.texts.spans { - Some(tokens) => tokens.len(), - None => 0, - }); + // Init list states + states.init_list_states(props.texts.spans.as_ref().map(|x| x.len()).unwrap_or(0)); FileList { props, states } } } @@ -231,7 +296,14 @@ impl Component for FileList { None => vec![], Some(lines) => lines .iter() - .map(|line| ListItem::new(Span::from(line.content.to_string()))) + .enumerate() + .map(|(num, line)| { + let to_display: String = match self.states.is_selected(num) { + true => format!("*{}", line.content), + false => line.content.to_string(), + }; + ListItem::new(Span::from(to_display)) + }) .collect(), }; let (fg, bg): (Color, Color) = match self.states.focus { @@ -263,13 +335,15 @@ impl Component for FileList { fn update(&mut self, props: Props) -> Msg { self.props = props; - // re-Set list length - self.states.set_list_len(match &self.props.texts.spans { - Some(tokens) => tokens.len(), - None => 0, - }); - // Fix list index - self.states.fix_list_index(); + // re-Set list states + self.states.init_list_states( + self.props + .texts + .spans + .as_ref() + .map(|x| x.len()) + .unwrap_or(0), + ); Msg::None } @@ -305,6 +379,20 @@ impl Component for FileList { } Msg::None } + KeyCode::Char('a') => match key.modifiers.intersects(KeyModifiers::CONTROL) { + // CTRL+A + true => { + // Select all + self.states.select_all(); + Msg::None + } + false => Msg::OnKey(key), + }, + KeyCode::Char('m') => { + // Toggle current file in selection + self.states.toggle_file(self.states.list_index()); + Msg::None + } KeyCode::Enter => { // Report event Msg::OnSubmit(self.get_state()) @@ -320,8 +408,22 @@ impl Component for FileList { } } + /// ### get_state + /// + /// Get state returns for this component two different payloads based on the states: + /// - if the file selection is empty, returns the highlighted item as `One` of `Usize` + /// - if at least one item is selected, return the selected as a `Vec` of `Usize` fn get_state(&self) -> Payload { - Payload::One(Value::Usize(self.states.get_list_index())) + match self.states.is_selection_empty() { + true => Payload::One(Value::Usize(self.states.list_index())), + false => Payload::Vec( + self.states + .get_selection() + .into_iter() + .map(Value::Usize) + .collect(), + ), + } } // -- events @@ -349,6 +451,72 @@ mod tests { use pretty_assertions::assert_eq; use tuirealm::event::KeyEvent; + #[test] + fn test_ui_components_file_list_states() { + let mut states: OwnStates = OwnStates::default(); + assert_eq!(states.list_len(), 0); + assert_eq!(states.selected.len(), 0); + assert_eq!(states.focus, false); + // Init states + states.init_list_states(4); + assert_eq!(states.list_len(), 4); + assert_eq!(states.selected.len(), 0); + assert!(states.is_selection_empty()); + // Select all files + states.select_all(); + assert_eq!(states.list_len(), 4); + assert_eq!(states.selected.len(), 4); + assert_eq!(states.is_selection_empty(), false); + assert_eq!(states.get_selection(), vec![0, 1, 2, 3]); + // Verify reset + states.init_list_states(5); + assert_eq!(states.list_len(), 5); + assert_eq!(states.selected.len(), 0); + // Toggle file + states.toggle_file(2); + assert_eq!(states.list_len(), 5); + assert_eq!(states.selected.len(), 1); + assert_eq!(states.selected[0], 2); + states.toggle_file(4); + assert_eq!(states.list_len(), 5); + assert_eq!(states.selected.len(), 2); + assert_eq!(states.selected[1], 4); + states.toggle_file(2); + assert_eq!(states.list_len(), 5); + assert_eq!(states.selected.len(), 1); + assert_eq!(states.selected[0], 4); + // Select twice (nothing should change) + states.select(4); + assert_eq!(states.list_len(), 5); + assert_eq!(states.selected.len(), 1); + assert_eq!(states.selected[0], 4); + // Deselect not-selectd item + states.deselect(2); + assert_eq!(states.list_len(), 5); + assert_eq!(states.selected.len(), 1); + assert_eq!(states.selected[0], 4); + // Index + states.init_list_states(2); + states.incr_list_index(); + assert_eq!(states.list_index(), 1); + states.incr_list_index(); + assert_eq!(states.list_index(), 1); + states.decr_list_index(); + assert_eq!(states.list_index(), 0); + states.decr_list_index(); + assert_eq!(states.list_index(), 0); + // Try fixing index + states.init_list_states(5); + states.list_index = 4; + states.init_list_states(3); + assert_eq!(states.list_index(), 2); + states.init_list_states(6); + assert_eq!(states.list_index(), 2); + // Focus + states.focus = true; + assert_eq!(states.focus, true); + } + #[test] fn test_ui_components_file_list() { // Make component @@ -375,7 +543,9 @@ mod tests { assert_eq!(component.props.texts.spans.as_ref().unwrap().len(), 2); // Verify states assert_eq!(component.states.list_index, 0); - assert_eq!(component.states.list_len, 2); + assert_eq!(component.states.selected.len(), 0); + assert_eq!(component.states.list_len(), 2); + assert_eq!(component.states.selected.capacity(), 2); assert_eq!(component.states.focus, false); // Focus component.active(); @@ -408,7 +578,7 @@ mod tests { ); // Verify states assert_eq!(component.states.list_index, 1); // Kept - assert_eq!(component.states.list_len, 3); + assert_eq!(component.states.list_len(), 3); // get value assert_eq!(component.get_state(), Payload::One(Value::Usize(1))); // Render @@ -451,5 +621,90 @@ mod tests { component.on(Event::Key(KeyEvent::from(KeyCode::Backspace))), Msg::OnKey(KeyEvent::from(KeyCode::Backspace)) ); + // Verify 'A' still works + assert_eq!( + component.on(Event::Key(KeyEvent::from(KeyCode::Char('a')))), + Msg::OnKey(KeyEvent::from(KeyCode::Char('a'))) + ); + } + + #[test] + fn test_ui_components_file_list_selection() { + // Make component + let mut component: FileList = FileList::new( + FileListPropsBuilder::default() + .with_files( + Some(String::from("files")), + vec![ + String::from("file1"), + String::from("file2"), + String::from("file3"), + ], + ) + .build(), + ); + // Get state + assert_eq!(component.get_state(), Payload::One(Value::Usize(0))); + // Select one + assert_eq!( + component.on(Event::Key(KeyEvent::from(KeyCode::Char('m')))), + Msg::None + ); + // Now should be a vec + assert_eq!(component.get_state(), Payload::Vec(vec![Value::Usize(0)])); + // De-select + assert_eq!( + component.on(Event::Key(KeyEvent::from(KeyCode::Char('m')))), + Msg::None + ); + assert_eq!(component.get_state(), Payload::One(Value::Usize(0))); + // Go down + assert_eq!( + component.on(Event::Key(KeyEvent::from(KeyCode::Down))), + Msg::None + ); + // Select + assert_eq!( + component.on(Event::Key(KeyEvent::from(KeyCode::Char('m')))), + Msg::None + ); + assert_eq!(component.get_state(), Payload::Vec(vec![Value::Usize(1)])); + // Go down and select + assert_eq!( + component.on(Event::Key(KeyEvent::from(KeyCode::Down))), + Msg::None + ); + assert_eq!( + component.on(Event::Key(KeyEvent::from(KeyCode::Char('m')))), + Msg::None + ); + assert_eq!( + component.get_state(), + Payload::Vec(vec![Value::Usize(1), Value::Usize(2)]) + ); + // Select all + assert_eq!( + component.on(Event::Key(KeyEvent { + code: KeyCode::Char('a'), + modifiers: KeyModifiers::CONTROL, + })), + Msg::None + ); + // All selected + assert_eq!( + component.get_state(), + Payload::Vec(vec![Value::Usize(1), Value::Usize(2), Value::Usize(0)]) + ); + // Update files + component.update( + FileListPropsBuilder::from(component.get_props()) + .with_files( + Some(String::from("filelist")), + vec![String::from("file1"), String::from("file2")], + ) + .build(), + ); + // Selection should now be empty + assert_eq!(component.get_state(), Payload::One(Value::Usize(1))); } }