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

View File

@@ -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

View File

@@ -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<PathBuf, PathBuf> {
&self.transfer_queue

View File

@@ -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();
}
}

View File

@@ -166,6 +166,12 @@ impl Component<Msg, NoUserEvent> 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, ..
}) => {

View File

@@ -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 {

View File

@@ -670,7 +670,7 @@ impl KeybindingsPopup {
))
.add_row()
.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_col(TextSpan::new("<Q|F10>").bold().fg(key_color))
.add_col(TextSpan::from(" Quit termscp"))

View File

@@ -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,
}
}
}

View File

@@ -523,7 +523,7 @@ impl Component<Msg, NoUserEvent> 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<Msg, NoUserEvent> 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,

View File

@@ -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<String> {
self.cache.as_ref().map(|_| {

View File

@@ -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<Msg> for FileTransferActivity {
fn update(&mut self, msg: Option<Msg>) -> Option<Msg> {
@@ -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
}

View File

@@ -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::<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) {
let sorting_color = self.theme().transfer_status_sorting;
let hidden_color = self.theme().transfer_status_hidden;