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
This commit is contained in:
veeso
2025-03-22 16:17:59 +01:00
parent 76c5528734
commit 85bb586e8f
11 changed files with 324 additions and 43 deletions
+6
View File
@@ -44,6 +44,12 @@
Released on ?? 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 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: - [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 1. Local directory path specified for the host bridge
+8
View File
@@ -156,6 +156,14 @@ impl FileExplorer {
.insert(PathBuf::from(src), PathBuf::from(dst)); .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 /// Get enqueued files
pub fn enqueued(&self) -> &HashMap<PathBuf, PathBuf> { pub fn enqueued(&self) -> &HashMap<PathBuf, PathBuf> {
&self.transfer_queue &self.transfer_queue
+3 -35
View File
@@ -6,46 +6,14 @@ use super::FileTransferActivity;
impl FileTransferActivity { impl FileTransferActivity {
pub(crate) fn action_mark_file(&mut self, index: usize) { pub(crate) fn action_mark_file(&mut self, index: usize) {
// get dest self.enqueue_file(index);
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();
} }
pub(crate) fn action_mark_all(&mut self) { pub(crate) fn action_mark_all(&mut self) {
let dest_path = self.browser.other_explorer_no_found().wrkdir.clone(); self.enqueue_all();
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();
} }
pub(crate) fn action_mark_clear(&mut self) { pub(crate) fn action_mark_clear(&mut self) {
let browser = self.browser.explorer_mut(); self.clear_queue();
debug!("Clearing all marked files");
browser.clear_queue();
self.reload_browser_file_list();
} }
} }
@@ -166,6 +166,12 @@ impl Component<Msg, NoUserEvent> for Log {
self.perform(Cmd::Move(Direction::Up)); self.perform(Cmd::Move(Direction::Up));
Some(Msg::None) 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 { Event::Keyboard(KeyEvent {
code: Key::PageUp, .. code: Key::PageUp, ..
}) => { }) => {
@@ -12,6 +12,7 @@ use super::{Msg, PendingActionMsg, TransferMsg, UiMsg};
mod log; mod log;
mod misc; mod misc;
mod popups; mod popups;
mod selected_files;
mod transfer; mod transfer;
pub use misc::FooterBar; pub use misc::FooterBar;
@@ -26,6 +27,7 @@ pub use popups::{
pub use transfer::{ExplorerFind, ExplorerFuzzy, ExplorerLocal, ExplorerRemote}; pub use transfer::{ExplorerFind, ExplorerFuzzy, ExplorerLocal, ExplorerRemote};
pub use self::log::Log; pub use self::log::Log;
pub use self::selected_files::SelectedFilesList;
#[derive(Default, MockComponent)] #[derive(Default, MockComponent)]
pub struct GlobalListener { pub struct GlobalListener {
@@ -670,7 +670,7 @@ impl KeybindingsPopup {
)) ))
.add_row() .add_row()
.add_col(TextSpan::new("<P>").bold().fg(key_color)) .add_col(TextSpan::new("<P>").bold().fg(key_color))
.add_col(TextSpan::from(" Toggle log panel")) .add_col(TextSpan::from(" Toggle bottom panel"))
.add_row() .add_row()
.add_col(TextSpan::new("<Q|F10>").bold().fg(key_color)) .add_col(TextSpan::new("<Q|F10>").bold().fg(key_color))
.add_col(TextSpan::from(" Quit termscp")) .add_col(TextSpan::from(" Quit termscp"))
@@ -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<PathBuf>,
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::<Vec<PathBuf>>();
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<Msg, NoUserEvent> for SelectedFilesList {
fn on(&mut self, ev: Event<NoUserEvent>) -> Option<Msg> {
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,
}
}
}
@@ -523,7 +523,7 @@ impl Component<Msg, NoUserEvent> for ExplorerLocal {
Event::Keyboard(KeyEvent { Event::Keyboard(KeyEvent {
code: Key::Char('p'), code: Key::Char('p'),
modifiers: KeyModifiers::NONE, modifiers: KeyModifiers::NONE,
}) => Some(Msg::Ui(UiMsg::ShowLogPanel)), }) => Some(Msg::Ui(UiMsg::GoToTransferQueue)),
Event::Keyboard(KeyEvent { Event::Keyboard(KeyEvent {
code: Key::Char('r') | Key::Function(6), code: Key::Char('r') | Key::Function(6),
modifiers: KeyModifiers::NONE, modifiers: KeyModifiers::NONE,
@@ -737,7 +737,7 @@ impl Component<Msg, NoUserEvent> for ExplorerRemote {
Event::Keyboard(KeyEvent { Event::Keyboard(KeyEvent {
code: Key::Char('p'), code: Key::Char('p'),
modifiers: KeyModifiers::NONE, modifiers: KeyModifiers::NONE,
}) => Some(Msg::Ui(UiMsg::ShowLogPanel)), }) => Some(Msg::Ui(UiMsg::GoToTransferQueue)),
Event::Keyboard(KeyEvent { Event::Keyboard(KeyEvent {
code: Key::Char('r') | Key::Function(6), code: Key::Char('r') | Key::Function(6),
modifiers: KeyModifiers::NONE, modifiers: KeyModifiers::NONE,
+51 -1
View File
@@ -40,6 +40,12 @@ use crate::system::watcher::FsWatcher;
// -- components // -- components
#[derive(Debug, Eq, PartialEq, Clone, Copy)]
pub enum MarkQueue {
Local,
Remote,
}
#[derive(Debug, Eq, PartialEq, Clone, Hash)] #[derive(Debug, Eq, PartialEq, Clone, Hash)]
enum Id { enum Id {
ChmodPopup, ChmodPopup,
@@ -74,6 +80,8 @@ enum Id {
StatusBarRemote, StatusBarRemote,
SymlinkPopup, SymlinkPopup,
SyncBrowsingMkdirPopup, SyncBrowsingMkdirPopup,
TransferQueueHostBridge,
TransferQueueRemote,
WaitPopup, WaitPopup,
WatchedPathsList, WatchedPathsList,
WatcherPopup, WatcherPopup,
@@ -125,6 +133,8 @@ enum TransferMsg {
#[derive(Debug, PartialEq)] #[derive(Debug, PartialEq)]
enum UiMsg { enum UiMsg {
BottomPanelLeft,
BottomPanelRight,
ChangeFileSorting(FileSorting), ChangeFileSorting(FileSorting),
ChangeTransferWindow, ChangeTransferWindow,
CloseChmodPopup, CloseChmodPopup,
@@ -155,6 +165,7 @@ enum UiMsg {
LogBackTabbed, LogBackTabbed,
/// Mark file on the list; usize is the index of the file /// Mark file on the list; usize is the index of the file
MarkFile(usize), MarkFile(usize),
MarkRemove(MarkQueue, PathBuf),
/// Mark all file at tab /// Mark all file at tab
MarkAll, MarkAll,
/// Clear all marks /// Clear all marks
@@ -171,7 +182,7 @@ enum UiMsg {
ShowFilterPopup, ShowFilterPopup,
ShowGotoPopup, ShowGotoPopup,
ShowKeybindingsPopup, ShowKeybindingsPopup,
ShowLogPanel, GoToTransferQueue,
ShowMkdirPopup, ShowMkdirPopup,
ShowNewFilePopup, ShowNewFilePopup,
ShowOpenWithPopup, ShowOpenWithPopup,
@@ -313,6 +324,45 @@ impl FileTransferActivity {
self.browser.found_mut() 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 /// Get file name for a file in cache
fn get_cache_tmp_name(&self, name: &str, file_type: Option<&str>) -> Option<String> { fn get_cache_tmp_name(&self, name: &str, file_type: Option<&str>) -> Option<String> {
self.cache.as_ref().map(|_| { self.cache.as_ref().map(|_| {
+42 -3
View File
@@ -11,7 +11,9 @@ use tuirealm::{State, StateValue, Update};
use super::actions::SelectedFile; use super::actions::SelectedFile;
use super::actions::walkdir::WalkdirError; use super::actions::walkdir::WalkdirError;
use super::browser::{FileExplorerTab, FoundExplorerTab}; 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<Msg> for FileTransferActivity { impl Update<Msg> for FileTransferActivity {
fn update(&mut self, msg: Option<Msg>) -> Option<Msg> { fn update(&mut self, msg: Option<Msg>) -> Option<Msg> {
@@ -473,8 +475,8 @@ impl FileTransferActivity {
self.browser.fuzzy_search(&needle); self.browser.fuzzy_search(&needle);
self.update_find_list(); self.update_find_list();
} }
UiMsg::ShowLogPanel => { UiMsg::GoToTransferQueue => {
assert!(self.app.active(&Id::Log).is_ok()); assert!(self.app.active(&Id::TransferQueueHostBridge).is_ok());
} }
UiMsg::LogBackTabbed => { UiMsg::LogBackTabbed => {
assert!(self.app.active(&Id::ExplorerHostBridge).is_ok()); assert!(self.app.active(&Id::ExplorerHostBridge).is_ok());
@@ -488,6 +490,18 @@ impl FileTransferActivity {
UiMsg::MarkClear => { UiMsg::MarkClear => {
self.action_mark_clear(); 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 => { UiMsg::Quit => {
self.disconnect_and_quit(); self.disconnect_and_quit();
self.umount_quit(); self.umount_quit();
@@ -593,6 +607,31 @@ impl FileTransferActivity {
UiMsg::WindowResized => { UiMsg::WindowResized => {
self.redraw = true; 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 None
} }
+70 -1
View File
@@ -16,6 +16,7 @@ use super::browser::{FileExplorerTab, FoundExplorerTab};
use super::components::ATTR_FILES; use super::components::ATTR_FILES;
use super::{Context, FileTransferActivity, Id, components}; use super::{Context, FileTransferActivity, Id, components};
use crate::explorer::FileSorting; use crate::explorer::FileSorting;
use crate::ui::activities::filetransfer::MarkQueue;
use crate::utils::ui::{Popup, Size}; use crate::utils::ui::{Popup, Size};
impl FileTransferActivity { impl FileTransferActivity {
@@ -81,6 +82,8 @@ impl FileTransferActivity {
) )
.is_ok() .is_ok()
); );
self.refresh_host_bridge_transfer_queue();
self.refresh_remote_transfer_queue();
// Load status bar // Load status bar
self.refresh_local_status_bar(); self.refresh_local_status_bar();
self.refresh_remote_status_bar(); self.refresh_remote_status_bar();
@@ -138,6 +141,17 @@ impl FileTransferActivity {
.direction(Direction::Horizontal) .direction(Direction::Horizontal)
.horizontal_margin(1) .horizontal_margin(1)
.split(bottom_chunks[0]); .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 // Draw footer
self.app.view(&Id::FooterBar, f, body[1]); self.app.view(&Id::FooterBar, f, body[1]);
// Draw explorers // Draw explorers
@@ -153,8 +167,13 @@ impl FileTransferActivity {
} else { } else {
self.app.view(&Id::ExplorerRemote, f, tabs_chunks[1]); 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 // Draw log box
self.app.view(&Id::Log, f, bottom_chunks[1]); self.app.view(&Id::Log, f, bottom_components[2]);
// Draw status bar // Draw status bar
self.app self.app
.view(&Id::StatusBarHostBridge, f, status_bar_chunks[0]); .view(&Id::StatusBarHostBridge, f, status_bar_chunks[0]);
@@ -928,6 +947,56 @@ impl FileTransferActivity {
let _ = self.app.umount(&Id::FileInfoPopup); 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::<Vec<_>>();
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::<Vec<_>>();
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) { pub(super) fn refresh_local_status_bar(&mut self) {
let sorting_color = self.theme().transfer_status_sorting; let sorting_color = self.theme().transfer_status_sorting;
let hidden_color = self.theme().transfer_status_hidden; let hidden_color = self.theme().transfer_status_hidden;