From 85bb586e8ff0e6cbf9c472579ab35b1e01f2205a Mon Sep 17 00:00:00 2001 From: veeso Date: Sat, 22 Mar 2025 16:17:59 +0100 Subject: [PATCH] feat(file-select): Queueing transfers the logic of selecting files has been extended! From now on selecting file will put the files into a transfer queue, which is shown on the bottom panel. When a file is selected the file is added to the queue with a destination path, which is the **current other explorer path at the moment of selection. It is possible to navigate to the transfer queue by using `P` and pressing `ENTER` on a file will remove it from the transfer queue.Other commands will work as well on the transfer queue, like `COPY`, `MOVE`, `DELETE`, `RENAME`. closes #132 --- CHANGELOG.md | 6 + src/explorer/mod.rs | 8 ++ .../activities/filetransfer/actions/mark.rs | 38 +---- .../activities/filetransfer/components/log.rs | 6 + .../activities/filetransfer/components/mod.rs | 2 + .../filetransfer/components/popups.rs | 2 +- .../filetransfer/components/selected_files.rs | 133 ++++++++++++++++++ .../filetransfer/components/transfer/mod.rs | 4 +- src/ui/activities/filetransfer/mod.rs | 52 ++++++- src/ui/activities/filetransfer/update.rs | 45 +++++- src/ui/activities/filetransfer/view.rs | 71 +++++++++- 11 files changed, 324 insertions(+), 43 deletions(-) create mode 100644 src/ui/activities/filetransfer/components/selected_files.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 01b0a4d..c4b5297 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,12 @@ Released on ?? +- **Queuing transfers**: + - the logic of selecting files has been extended! + - From now on selecting file will put the files into a **transfer queue**, which is shown on the bottom panel. + - When a file is selected the file is added to the queue with a destination path, which is the **current other explorer path at the moment of selection.** + - It is possible to navigate to the transfer queue by using `P` and pressing `ENTER` on a file will remove it from the transfer queue. + - Other commands will work as well on the transfer queue, like `COPY`, `MOVE`, `DELETE`, `RENAME`. - [issue 308](https://github.com/veeso/termscp/issues/308): added `--wno-keyring` flag to disable keyring - [issue 316](https://github.com/veeso/termscp/issues/316): Local directory path is not switching to what's specified in the bookmark. Now the local directory path is correctly set following this hierarchy: 1. Local directory path specified for the host bridge diff --git a/src/explorer/mod.rs b/src/explorer/mod.rs index 3e6ec04..0d1007e 100644 --- a/src/explorer/mod.rs +++ b/src/explorer/mod.rs @@ -156,6 +156,14 @@ impl FileExplorer { .insert(PathBuf::from(src), PathBuf::from(dst)); } + /// Enqueue all files for transfer + pub fn enqueue_all(&mut self, dst: &Path) { + let files: Vec<_> = self.iter_files().map(|f| f.path.clone()).collect(); + for file in files { + self.enqueue(&file, dst); + } + } + /// Get enqueued files pub fn enqueued(&self) -> &HashMap { &self.transfer_queue diff --git a/src/ui/activities/filetransfer/actions/mark.rs b/src/ui/activities/filetransfer/actions/mark.rs index 04c954a..d126718 100644 --- a/src/ui/activities/filetransfer/actions/mark.rs +++ b/src/ui/activities/filetransfer/actions/mark.rs @@ -6,46 +6,14 @@ use super::FileTransferActivity; impl FileTransferActivity { pub(crate) fn action_mark_file(&mut self, index: usize) { - // get dest - let dest_path = self.browser.other_explorer_no_found().wrkdir.clone(); - // get file - let browser = self.browser.explorer_mut(); - let Some(file) = browser.get(index).map(|item| item.path().to_path_buf()) else { - return; - }; - - if browser.enqueued().contains_key(&file) { - debug!("File already marked, unmarking {}", file.display()); - browser.dequeue(&file); - } else { - debug!("Marking file {}", file.display()); - browser.enqueue(&file, &dest_path); - } - - self.reload_browser_file_list(); + self.enqueue_file(index); } pub(crate) fn action_mark_all(&mut self) { - let dest_path = self.browser.other_explorer_no_found().wrkdir.clone(); - let browser = self.browser.explorer_mut(); - - let mut files = vec![]; - for file in browser.iter_files().map(|x| x.path()) { - files.push(file.to_path_buf()); - } - for file in files { - debug!("Marking file {}", file.display()); - browser.enqueue(&file, &dest_path); - } - - self.reload_browser_file_list(); + self.enqueue_all(); } pub(crate) fn action_mark_clear(&mut self) { - let browser = self.browser.explorer_mut(); - debug!("Clearing all marked files"); - browser.clear_queue(); - - self.reload_browser_file_list(); + self.clear_queue(); } } diff --git a/src/ui/activities/filetransfer/components/log.rs b/src/ui/activities/filetransfer/components/log.rs index 320d70f..2b2ee27 100644 --- a/src/ui/activities/filetransfer/components/log.rs +++ b/src/ui/activities/filetransfer/components/log.rs @@ -166,6 +166,12 @@ impl Component for Log { self.perform(Cmd::Move(Direction::Up)); Some(Msg::None) } + Event::Keyboard(KeyEvent { + code: Key::Right, .. + }) => Some(Msg::Ui(UiMsg::BottomPanelRight)), + Event::Keyboard(KeyEvent { + code: Key::Left, .. + }) => Some(Msg::Ui(UiMsg::BottomPanelLeft)), Event::Keyboard(KeyEvent { code: Key::PageUp, .. }) => { diff --git a/src/ui/activities/filetransfer/components/mod.rs b/src/ui/activities/filetransfer/components/mod.rs index 4076ab2..a74bb83 100644 --- a/src/ui/activities/filetransfer/components/mod.rs +++ b/src/ui/activities/filetransfer/components/mod.rs @@ -12,6 +12,7 @@ use super::{Msg, PendingActionMsg, TransferMsg, UiMsg}; mod log; mod misc; mod popups; +mod selected_files; mod transfer; pub use misc::FooterBar; @@ -26,6 +27,7 @@ pub use popups::{ pub use transfer::{ExplorerFind, ExplorerFuzzy, ExplorerLocal, ExplorerRemote}; pub use self::log::Log; +pub use self::selected_files::SelectedFilesList; #[derive(Default, MockComponent)] pub struct GlobalListener { diff --git a/src/ui/activities/filetransfer/components/popups.rs b/src/ui/activities/filetransfer/components/popups.rs index 32e5ee5..eb4f665 100644 --- a/src/ui/activities/filetransfer/components/popups.rs +++ b/src/ui/activities/filetransfer/components/popups.rs @@ -670,7 +670,7 @@ impl KeybindingsPopup { )) .add_row() .add_col(TextSpan::new("

").bold().fg(key_color)) - .add_col(TextSpan::from(" Toggle log panel")) + .add_col(TextSpan::from(" Toggle bottom panel")) .add_row() .add_col(TextSpan::new("").bold().fg(key_color)) .add_col(TextSpan::from(" Quit termscp")) diff --git a/src/ui/activities/filetransfer/components/selected_files.rs b/src/ui/activities/filetransfer/components/selected_files.rs new file mode 100644 index 0000000..25ceebb --- /dev/null +++ b/src/ui/activities/filetransfer/components/selected_files.rs @@ -0,0 +1,133 @@ +use std::path::PathBuf; + +use tui_realm_stdlib::List; +use tuirealm::command::{Cmd, Direction, Position}; +use tuirealm::event::{Key, KeyEvent}; +use tuirealm::props::{Alignment, BorderType, Borders, Color, TextSpan}; +use tuirealm::{Component, Event, MockComponent, NoUserEvent, State, StateValue}; + +use crate::ui::activities::filetransfer::{MarkQueue, Msg, UiMsg}; + +#[derive(MockComponent)] +pub struct SelectedFilesList { + component: List, + paths: Vec, + queue: MarkQueue, +} + +impl SelectedFilesList { + pub fn new( + paths: &[(PathBuf, PathBuf)], + queue: MarkQueue, + color: Color, + title: &'static str, + ) -> Self { + let enqueued_paths = paths + .iter() + .map(|(src, _)| src.clone()) + .collect::>(); + + Self { + queue, + paths: enqueued_paths, + component: List::default() + .borders( + Borders::default() + .color(color) + .modifiers(BorderType::Rounded), + ) + .rewind(true) + .scroll(true) + .step(4) + .highlighted_color(color) + .highlighted_str("➤ ") + .title(title, Alignment::Center) + .rows( + paths + .iter() + .map(|(src, dest)| { + let name = src + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + let dest = dest + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + + vec![ + TextSpan::from(name), + TextSpan::from(" -> "), + TextSpan::from(dest), + ] + }) + .collect(), + ), + } + } +} + +impl Component for SelectedFilesList { + fn on(&mut self, ev: Event) -> Option { + match ev { + Event::Keyboard(KeyEvent { + code: Key::Down, .. + }) => { + self.perform(Cmd::Move(Direction::Down)); + Some(Msg::None) + } + Event::Keyboard(KeyEvent { code: Key::Up, .. }) => { + self.perform(Cmd::Move(Direction::Up)); + Some(Msg::None) + } + Event::Keyboard(KeyEvent { + code: Key::PageDown, + .. + }) => { + self.perform(Cmd::Scroll(Direction::Down)); + Some(Msg::None) + } + Event::Keyboard(KeyEvent { + code: Key::PageUp, .. + }) => { + self.perform(Cmd::Scroll(Direction::Up)); + Some(Msg::None) + } + Event::Keyboard(KeyEvent { + code: Key::Home, .. + }) => { + self.perform(Cmd::GoTo(Position::Begin)); + Some(Msg::None) + } + Event::Keyboard(KeyEvent { code: Key::End, .. }) => { + self.perform(Cmd::GoTo(Position::End)); + Some(Msg::None) + } + Event::Keyboard(KeyEvent { + code: Key::Right, .. + }) => Some(Msg::Ui(UiMsg::BottomPanelRight)), + Event::Keyboard(KeyEvent { + code: Key::Left, .. + }) => Some(Msg::Ui(UiMsg::BottomPanelLeft)), + Event::Keyboard(KeyEvent { + code: Key::BackTab | Key::Tab | Key::Char('p'), + .. + }) => Some(Msg::Ui(UiMsg::LogBackTabbed)), + Event::Keyboard(KeyEvent { + code: Key::Enter, .. + }) => { + // unmark the selected file + let State::One(StateValue::Usize(idx)) = self.state() else { + return None; + }; + + let path = self.paths.get(idx)?; + + Some(Msg::Ui(UiMsg::MarkRemove(self.queue, path.clone()))) + } + _ => None, + } + } +} diff --git a/src/ui/activities/filetransfer/components/transfer/mod.rs b/src/ui/activities/filetransfer/components/transfer/mod.rs index 6428f23..6b62ad4 100644 --- a/src/ui/activities/filetransfer/components/transfer/mod.rs +++ b/src/ui/activities/filetransfer/components/transfer/mod.rs @@ -523,7 +523,7 @@ impl Component for ExplorerLocal { Event::Keyboard(KeyEvent { code: Key::Char('p'), modifiers: KeyModifiers::NONE, - }) => Some(Msg::Ui(UiMsg::ShowLogPanel)), + }) => Some(Msg::Ui(UiMsg::GoToTransferQueue)), Event::Keyboard(KeyEvent { code: Key::Char('r') | Key::Function(6), modifiers: KeyModifiers::NONE, @@ -737,7 +737,7 @@ impl Component for ExplorerRemote { Event::Keyboard(KeyEvent { code: Key::Char('p'), modifiers: KeyModifiers::NONE, - }) => Some(Msg::Ui(UiMsg::ShowLogPanel)), + }) => Some(Msg::Ui(UiMsg::GoToTransferQueue)), Event::Keyboard(KeyEvent { code: Key::Char('r') | Key::Function(6), modifiers: KeyModifiers::NONE, diff --git a/src/ui/activities/filetransfer/mod.rs b/src/ui/activities/filetransfer/mod.rs index b8f58aa..d097287 100644 --- a/src/ui/activities/filetransfer/mod.rs +++ b/src/ui/activities/filetransfer/mod.rs @@ -40,6 +40,12 @@ use crate::system::watcher::FsWatcher; // -- components +#[derive(Debug, Eq, PartialEq, Clone, Copy)] +pub enum MarkQueue { + Local, + Remote, +} + #[derive(Debug, Eq, PartialEq, Clone, Hash)] enum Id { ChmodPopup, @@ -74,6 +80,8 @@ enum Id { StatusBarRemote, SymlinkPopup, SyncBrowsingMkdirPopup, + TransferQueueHostBridge, + TransferQueueRemote, WaitPopup, WatchedPathsList, WatcherPopup, @@ -125,6 +133,8 @@ enum TransferMsg { #[derive(Debug, PartialEq)] enum UiMsg { + BottomPanelLeft, + BottomPanelRight, ChangeFileSorting(FileSorting), ChangeTransferWindow, CloseChmodPopup, @@ -155,6 +165,7 @@ enum UiMsg { LogBackTabbed, /// Mark file on the list; usize is the index of the file MarkFile(usize), + MarkRemove(MarkQueue, PathBuf), /// Mark all file at tab MarkAll, /// Clear all marks @@ -171,7 +182,7 @@ enum UiMsg { ShowFilterPopup, ShowGotoPopup, ShowKeybindingsPopup, - ShowLogPanel, + GoToTransferQueue, ShowMkdirPopup, ShowNewFilePopup, ShowOpenWithPopup, @@ -313,6 +324,45 @@ impl FileTransferActivity { self.browser.found_mut() } + /// Enqueue a file to be transferred + fn enqueue_file(&mut self, index: usize) { + let Some(src) = self + .browser + .explorer() + .get(index) + .map(|item| item.path().to_path_buf()) + else { + return; + }; + + if self.browser.explorer().enqueued().contains_key(&src) { + debug!("File already marked, unmarking {}", src.display()); + self.browser.explorer_mut().dequeue(&src); + } else { + debug!("Marking file {}", src.display()); + let dest = self.browser.other_explorer_no_found().wrkdir.clone(); + self.browser.explorer_mut().enqueue(&src, &dest); + } + self.reload_browser_file_list(); + self.refresh_host_bridge_transfer_queue(); + self.refresh_remote_transfer_queue(); + } + + fn enqueue_all(&mut self) { + let dest = self.browser.other_explorer_no_found().wrkdir.clone(); + self.browser.explorer_mut().enqueue_all(&dest); + self.reload_browser_file_list(); + self.refresh_host_bridge_transfer_queue(); + self.refresh_remote_transfer_queue(); + } + + fn clear_queue(&mut self) { + self.browser.explorer_mut().clear_queue(); + self.reload_browser_file_list(); + self.refresh_host_bridge_transfer_queue(); + self.refresh_remote_transfer_queue(); + } + /// Get file name for a file in cache fn get_cache_tmp_name(&self, name: &str, file_type: Option<&str>) -> Option { self.cache.as_ref().map(|_| { diff --git a/src/ui/activities/filetransfer/update.rs b/src/ui/activities/filetransfer/update.rs index 6e0213f..25d4db2 100644 --- a/src/ui/activities/filetransfer/update.rs +++ b/src/ui/activities/filetransfer/update.rs @@ -11,7 +11,9 @@ use tuirealm::{State, StateValue, Update}; use super::actions::SelectedFile; use super::actions::walkdir::WalkdirError; use super::browser::{FileExplorerTab, FoundExplorerTab}; -use super::{ExitReason, FileTransferActivity, Id, Msg, TransferMsg, TransferOpts, UiMsg}; +use super::{ + ExitReason, FileTransferActivity, Id, MarkQueue, Msg, TransferMsg, TransferOpts, UiMsg, +}; impl Update for FileTransferActivity { fn update(&mut self, msg: Option) -> Option { @@ -473,8 +475,8 @@ impl FileTransferActivity { self.browser.fuzzy_search(&needle); self.update_find_list(); } - UiMsg::ShowLogPanel => { - assert!(self.app.active(&Id::Log).is_ok()); + UiMsg::GoToTransferQueue => { + assert!(self.app.active(&Id::TransferQueueHostBridge).is_ok()); } UiMsg::LogBackTabbed => { assert!(self.app.active(&Id::ExplorerHostBridge).is_ok()); @@ -488,6 +490,18 @@ impl FileTransferActivity { UiMsg::MarkClear => { self.action_mark_clear(); } + UiMsg::MarkRemove(tab, path) => match tab { + MarkQueue::Local => { + self.host_bridge_mut().dequeue(&path); + self.reload_host_bridge_filelist(); + self.refresh_host_bridge_transfer_queue(); + } + MarkQueue::Remote => { + self.remote_mut().dequeue(&path); + self.reload_remote_filelist(); + self.refresh_remote_transfer_queue(); + } + }, UiMsg::Quit => { self.disconnect_and_quit(); self.umount_quit(); @@ -593,6 +607,31 @@ impl FileTransferActivity { UiMsg::WindowResized => { self.redraw = true; } + + UiMsg::BottomPanelLeft => match self.app.focus() { + Some(Id::TransferQueueHostBridge) => { + assert!(self.app.active(&Id::Log).is_ok()) + } + Some(Id::TransferQueueRemote) => { + assert!(self.app.active(&Id::TransferQueueHostBridge).is_ok()) + } + Some(Id::Log) => { + assert!(self.app.active(&Id::TransferQueueRemote).is_ok()) + } + _ => {} + }, + UiMsg::BottomPanelRight => match self.app.focus() { + Some(Id::TransferQueueHostBridge) => { + assert!(self.app.active(&Id::TransferQueueRemote).is_ok()) + } + Some(Id::TransferQueueRemote) => { + assert!(self.app.active(&Id::Log).is_ok()) + } + Some(Id::Log) => { + assert!(self.app.active(&Id::TransferQueueHostBridge).is_ok()) + } + _ => {} + }, } None } diff --git a/src/ui/activities/filetransfer/view.rs b/src/ui/activities/filetransfer/view.rs index ccbd286..2943ddf 100644 --- a/src/ui/activities/filetransfer/view.rs +++ b/src/ui/activities/filetransfer/view.rs @@ -16,6 +16,7 @@ use super::browser::{FileExplorerTab, FoundExplorerTab}; use super::components::ATTR_FILES; use super::{Context, FileTransferActivity, Id, components}; use crate::explorer::FileSorting; +use crate::ui::activities::filetransfer::MarkQueue; use crate::utils::ui::{Popup, Size}; impl FileTransferActivity { @@ -81,6 +82,8 @@ impl FileTransferActivity { ) .is_ok() ); + self.refresh_host_bridge_transfer_queue(); + self.refresh_remote_transfer_queue(); // Load status bar self.refresh_local_status_bar(); self.refresh_remote_status_bar(); @@ -138,6 +141,17 @@ impl FileTransferActivity { .direction(Direction::Horizontal) .horizontal_margin(1) .split(bottom_chunks[0]); + let bottom_components = Layout::default() + .constraints( + [ + Constraint::Percentage(25), + Constraint::Percentage(25), + Constraint::Percentage(50), + ] + .as_ref(), + ) + .direction(Direction::Horizontal) + .split(bottom_chunks[1]); // Draw footer self.app.view(&Id::FooterBar, f, body[1]); // Draw explorers @@ -153,8 +167,13 @@ impl FileTransferActivity { } else { self.app.view(&Id::ExplorerRemote, f, tabs_chunks[1]); } + // draw transfer queues + self.app + .view(&Id::TransferQueueHostBridge, f, bottom_components[0]); + self.app + .view(&Id::TransferQueueRemote, f, bottom_components[1]); // Draw log box - self.app.view(&Id::Log, f, bottom_chunks[1]); + self.app.view(&Id::Log, f, bottom_components[2]); // Draw status bar self.app .view(&Id::StatusBarHostBridge, f, status_bar_chunks[0]); @@ -928,6 +947,56 @@ impl FileTransferActivity { let _ = self.app.umount(&Id::FileInfoPopup); } + pub(super) fn refresh_host_bridge_transfer_queue(&mut self) { + let enqueued = self + .host_bridge() + .enqueued() + .iter() + .map(|(src, dest)| (src.clone(), dest.clone())) + .collect::>(); + let log_panel = self.theme().transfer_log_window; + + assert!( + self.app + .remount( + Id::TransferQueueHostBridge, + Box::new(components::SelectedFilesList::new( + &enqueued, + MarkQueue::Local, + log_panel, + "Host Bridge transfer queue", + )), + vec![] + ) + .is_ok() + ); + } + + pub(super) fn refresh_remote_transfer_queue(&mut self) { + let enqueued = self + .remote() + .enqueued() + .iter() + .map(|(src, dest)| (src.clone(), dest.clone())) + .collect::>(); + let log_panel = self.theme().transfer_log_window; + + assert!( + self.app + .remount( + Id::TransferQueueRemote, + Box::new(components::SelectedFilesList::new( + &enqueued, + MarkQueue::Remote, + log_panel, + "Remote transfer queue", + )), + vec![] + ) + .is_ok() + ); + } + pub(super) fn refresh_local_status_bar(&mut self) { let sorting_color = self.theme().transfer_status_sorting; let hidden_color = self.theme().transfer_status_hidden;