Add TUI download pause and queued delete
This commit is contained in:
@@ -115,6 +115,15 @@ impl ClientContext {
|
||||
) -> Option<&mut Download> {
|
||||
self.downloads.iter_mut().find(|d| d.token == token)
|
||||
}
|
||||
pub fn get_download_by_file_mut(
|
||||
&mut self,
|
||||
username: &str,
|
||||
filename: &str,
|
||||
) -> Option<&mut Download> {
|
||||
self.downloads
|
||||
.iter_mut()
|
||||
.find(|d| d.username == username && d.filename == filename)
|
||||
}
|
||||
pub fn get_download_tokens(&self) -> Vec<u32> {
|
||||
self.downloads.iter().map(|d| d.token).collect()
|
||||
}
|
||||
@@ -131,6 +140,23 @@ impl ClientContext {
|
||||
download.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove_queued_download_by_file(
|
||||
&mut self,
|
||||
username: &str,
|
||||
filename: &str,
|
||||
) -> bool {
|
||||
let Some(index) = self.downloads.iter().position(|download| {
|
||||
download.username == username
|
||||
&& download.filename == filename
|
||||
&& matches!(download.status, DownloadStatus::Queued)
|
||||
}) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
self.downloads.remove(index);
|
||||
true
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn test_client_context_downloads() {
|
||||
@@ -161,6 +187,104 @@ fn test_client_context_downloads() {
|
||||
assert!(context.get_download_by_token(1234).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_pause_and_resume_download() {
|
||||
let client = Client::new("test-user", "test-password");
|
||||
let (download_sender, download_receiver) = mpsc::channel();
|
||||
let download = Download {
|
||||
username: "peer".to_string(),
|
||||
filename: "song.mp3".to_string(),
|
||||
token: 123,
|
||||
size: 100,
|
||||
download_directory: "test".to_string(),
|
||||
status: DownloadStatus::InProgress {
|
||||
bytes_downloaded: 25,
|
||||
total_bytes: 100,
|
||||
speed_bytes_per_sec: 10.0,
|
||||
},
|
||||
sender: download_sender,
|
||||
};
|
||||
|
||||
client.context.write().unwrap().add_download(download);
|
||||
|
||||
assert!(client.pause_download("peer", "song.mp3"));
|
||||
assert!(matches!(
|
||||
client
|
||||
.context
|
||||
.read()
|
||||
.unwrap()
|
||||
.get_download_by_token(123)
|
||||
.unwrap()
|
||||
.status,
|
||||
DownloadStatus::Paused {
|
||||
bytes_downloaded: 25,
|
||||
total_bytes: 100
|
||||
}
|
||||
));
|
||||
assert!(matches!(
|
||||
download_receiver.try_recv().unwrap(),
|
||||
DownloadStatus::Paused {
|
||||
bytes_downloaded: 25,
|
||||
total_bytes: 100
|
||||
}
|
||||
));
|
||||
|
||||
assert!(client.resume_download("peer", "song.mp3"));
|
||||
assert!(matches!(
|
||||
client
|
||||
.context
|
||||
.read()
|
||||
.unwrap()
|
||||
.get_download_by_token(123)
|
||||
.unwrap()
|
||||
.status,
|
||||
DownloadStatus::InProgress {
|
||||
bytes_downloaded: 25,
|
||||
total_bytes: 100,
|
||||
speed_bytes_per_sec: 0.0
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_removes_only_queued_downloads() {
|
||||
let client = Client::new("test-user", "test-password");
|
||||
let queued_download = Download {
|
||||
username: "peer".to_string(),
|
||||
filename: "queued.mp3".to_string(),
|
||||
token: 123,
|
||||
size: 100,
|
||||
download_directory: "test".to_string(),
|
||||
status: DownloadStatus::Queued,
|
||||
sender: mpsc::channel().0,
|
||||
};
|
||||
let active_download = Download {
|
||||
username: "peer".to_string(),
|
||||
filename: "active.mp3".to_string(),
|
||||
token: 456,
|
||||
size: 100,
|
||||
download_directory: "test".to_string(),
|
||||
status: DownloadStatus::InProgress {
|
||||
bytes_downloaded: 25,
|
||||
total_bytes: 100,
|
||||
speed_bytes_per_sec: 10.0,
|
||||
},
|
||||
sender: mpsc::channel().0,
|
||||
};
|
||||
|
||||
{
|
||||
let mut context = client.context.write().unwrap();
|
||||
context.add_download(queued_download);
|
||||
context.add_download(active_download);
|
||||
}
|
||||
|
||||
assert!(client.remove_queued_download("peer", "queued.mp3"));
|
||||
assert!(!client.remove_queued_download("peer", "active.mp3"));
|
||||
let context = client.context.read().unwrap();
|
||||
assert!(context.get_download_by_token(123).is_none());
|
||||
assert!(context.get_download_by_token(456).is_some());
|
||||
}
|
||||
|
||||
impl ClientContext {
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
@@ -388,6 +512,69 @@ impl Client {
|
||||
self.context.read().unwrap().get_downloads().clone()
|
||||
}
|
||||
|
||||
pub fn pause_download(&self, username: &str, filename: &str) -> bool {
|
||||
let mut context = self.context.write().unwrap();
|
||||
let Some(download) =
|
||||
context.get_download_by_file_mut(username, filename)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let paused_status = match &download.status {
|
||||
DownloadStatus::InProgress {
|
||||
bytes_downloaded,
|
||||
total_bytes,
|
||||
..
|
||||
} => DownloadStatus::Paused {
|
||||
bytes_downloaded: *bytes_downloaded,
|
||||
total_bytes: *total_bytes,
|
||||
},
|
||||
DownloadStatus::Paused { .. } => return true,
|
||||
_ => return false,
|
||||
};
|
||||
|
||||
download.status = paused_status.clone();
|
||||
let _ = download.sender.send(paused_status);
|
||||
true
|
||||
}
|
||||
|
||||
pub fn resume_download(&self, username: &str, filename: &str) -> bool {
|
||||
let mut context = self.context.write().unwrap();
|
||||
let Some(download) =
|
||||
context.get_download_by_file_mut(username, filename)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let resumed_status = match &download.status {
|
||||
DownloadStatus::Paused {
|
||||
bytes_downloaded,
|
||||
total_bytes,
|
||||
} => DownloadStatus::InProgress {
|
||||
bytes_downloaded: *bytes_downloaded,
|
||||
total_bytes: *total_bytes,
|
||||
speed_bytes_per_sec: 0.0,
|
||||
},
|
||||
DownloadStatus::InProgress { .. } => return true,
|
||||
_ => return false,
|
||||
};
|
||||
|
||||
download.status = resumed_status.clone();
|
||||
let _ = download.sender.send(resumed_status);
|
||||
true
|
||||
}
|
||||
|
||||
pub fn remove_queued_download(
|
||||
&self,
|
||||
username: &str,
|
||||
filename: &str,
|
||||
) -> bool {
|
||||
self.context
|
||||
.write()
|
||||
.unwrap()
|
||||
.remove_queued_download_by_file(username, filename)
|
||||
}
|
||||
|
||||
pub fn download(
|
||||
&self,
|
||||
filename: String,
|
||||
|
||||
@@ -5,6 +5,7 @@ use std::net::TcpStream;
|
||||
use std::net::ToSocketAddrs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::client::ClientContext;
|
||||
@@ -268,9 +269,24 @@ impl DownloadPeer {
|
||||
stream
|
||||
.write_all(&START_DOWNLOAD)
|
||||
.map_err(DownloadError::StreamWriteError)?;
|
||||
if let Some(ref dl) = download {
|
||||
Self::send_download_status(
|
||||
client_context,
|
||||
dl,
|
||||
DownloadStatus::InProgress {
|
||||
bytes_downloaded: 0,
|
||||
total_bytes: dl.size,
|
||||
speed_bytes_per_sec: 0.0,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
loop {
|
||||
if let Some(ref dl) = download {
|
||||
self.wait_while_paused(client_context, dl)?;
|
||||
}
|
||||
|
||||
match stream.read(&mut read_buffer) {
|
||||
Ok(0) => {
|
||||
trace!(
|
||||
@@ -295,6 +311,17 @@ impl DownloadPeer {
|
||||
);
|
||||
download = Some(new_download);
|
||||
processor.received = true;
|
||||
if let Some(ref dl) = download {
|
||||
Self::send_download_status(
|
||||
client_context,
|
||||
dl,
|
||||
DownloadStatus::InProgress {
|
||||
bytes_downloaded: 0,
|
||||
total_bytes: dl.size,
|
||||
speed_bytes_per_sec: 0.0,
|
||||
},
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -318,11 +345,7 @@ impl DownloadPeer {
|
||||
total_bytes: dl.size,
|
||||
speed_bytes_per_sec: speed,
|
||||
};
|
||||
let _ = dl.sender.send(status.clone());
|
||||
client_context
|
||||
.write()
|
||||
.unwrap()
|
||||
.update_download_with_status(dl.token, status);
|
||||
Self::send_download_status(client_context, dl, status);
|
||||
|
||||
last_update_time = Instant::now();
|
||||
}
|
||||
@@ -353,6 +376,39 @@ impl DownloadPeer {
|
||||
Ok((processor.buffer, download))
|
||||
}
|
||||
|
||||
fn send_download_status(
|
||||
client_context: &Arc<RwLock<ClientContext>>,
|
||||
download: &Download,
|
||||
status: DownloadStatus,
|
||||
) {
|
||||
let _ = download.sender.send(status.clone());
|
||||
client_context
|
||||
.write()
|
||||
.unwrap()
|
||||
.update_download_with_status(download.token, status);
|
||||
}
|
||||
|
||||
fn wait_while_paused(
|
||||
&self,
|
||||
client_context: &Arc<RwLock<ClientContext>>,
|
||||
download: &Download,
|
||||
) -> Result<(), DownloadError> {
|
||||
loop {
|
||||
let status = client_context
|
||||
.read()
|
||||
.map_err(|_| DownloadError::LockPoisoned)?
|
||||
.get_download_by_token(download.token)
|
||||
.map(|download| download.status.clone())
|
||||
.ok_or(DownloadError::TokenNotFound(download.token))?;
|
||||
|
||||
if !matches!(status, DownloadStatus::Paused { .. }) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
thread::sleep(Duration::from_millis(200));
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_download_path(
|
||||
&self,
|
||||
download: &Download,
|
||||
|
||||
@@ -112,6 +112,9 @@ impl Download {
|
||||
match &self.status {
|
||||
DownloadStatus::InProgress {
|
||||
bytes_downloaded, ..
|
||||
}
|
||||
| DownloadStatus::Paused {
|
||||
bytes_downloaded, ..
|
||||
} => *bytes_downloaded,
|
||||
DownloadStatus::Completed => self.size,
|
||||
_ => 0,
|
||||
@@ -138,6 +141,10 @@ pub enum DownloadStatus {
|
||||
total_bytes: u64,
|
||||
speed_bytes_per_sec: f64,
|
||||
},
|
||||
Paused {
|
||||
bytes_downloaded: u64,
|
||||
total_bytes: u64,
|
||||
},
|
||||
Completed,
|
||||
Failed,
|
||||
TimedOut,
|
||||
|
||||
@@ -2,8 +2,8 @@ use crate::models::DownloadEntry;
|
||||
use crate::ui::{
|
||||
COLOR_PRIMARY, HIGHLIGHT_SYMBOL, border_style, border_type, error_style,
|
||||
format_bytes_progress, format_progress_bar, format_shortcuts_styled,
|
||||
format_speed, header_style, highlight_style, inactive_style, primary_style,
|
||||
warning_style,
|
||||
format_speed, header_style, highlight_style, inactive_style, info_style,
|
||||
primary_style, warning_style,
|
||||
};
|
||||
use color_eyre::Result;
|
||||
use ratatui::{
|
||||
@@ -229,6 +229,7 @@ impl MultiDownloadProgress {
|
||||
let status_icon = match download.status {
|
||||
DownloadStatus::Queued => "⋯",
|
||||
DownloadStatus::InProgress { .. } => "⧗",
|
||||
DownloadStatus::Paused { .. } => "⏸",
|
||||
DownloadStatus::Completed => "✓",
|
||||
DownloadStatus::Failed => "✗",
|
||||
DownloadStatus::TimedOut => "⏱",
|
||||
@@ -269,6 +270,7 @@ impl MultiDownloadProgress {
|
||||
let style = match download.status {
|
||||
DownloadStatus::Queued => inactive_style(),
|
||||
DownloadStatus::InProgress { .. } => warning_style(),
|
||||
DownloadStatus::Paused { .. } => info_style(),
|
||||
DownloadStatus::Completed => primary_style(),
|
||||
DownloadStatus::Failed | DownloadStatus::TimedOut => {
|
||||
error_style()
|
||||
@@ -352,6 +354,10 @@ pub fn render_download_stats(
|
||||
.iter()
|
||||
.filter(|d| matches!(d.download.status, DownloadStatus::Queued))
|
||||
.count();
|
||||
let paused = downloads
|
||||
.iter()
|
||||
.filter(|d| matches!(d.download.status, DownloadStatus::Paused { .. }))
|
||||
.count();
|
||||
|
||||
let total_downloaded: u64 = downloads
|
||||
.iter()
|
||||
@@ -430,6 +436,14 @@ pub fn render_download_stats(
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(" queued"),
|
||||
Span::raw(", "),
|
||||
Span::styled(
|
||||
paused.to_string(),
|
||||
Style::default()
|
||||
.fg(COLOR_PRIMARY)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(" paused"),
|
||||
]);
|
||||
|
||||
let stats_paragraph = Paragraph::new(stats_line);
|
||||
|
||||
@@ -238,6 +238,8 @@ impl MainTui {
|
||||
vec![
|
||||
("1-3", "focus pane"),
|
||||
("↑↓", "navigate"),
|
||||
("p", "pause/resume"),
|
||||
("d", "delete queued"),
|
||||
("q", "quit"),
|
||||
]
|
||||
}
|
||||
@@ -617,11 +619,73 @@ impl MainTui {
|
||||
self.state.downloads_table_state.select(Some(new));
|
||||
}
|
||||
}
|
||||
KeyCode::Char('p') => {
|
||||
self.toggle_selected_download_pause();
|
||||
}
|
||||
KeyCode::Char('d') => {
|
||||
self.remove_selected_queued_download();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn toggle_selected_download_pause(&mut self) {
|
||||
let Some(index) = self.state.downloads_table_state.selected() else {
|
||||
return;
|
||||
};
|
||||
let Some(download_entry) = self.state.downloads.get(index) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let download = &download_entry.download;
|
||||
match download.status {
|
||||
DownloadStatus::InProgress { .. } => {
|
||||
self.client
|
||||
.pause_download(&download.username, &download.filename);
|
||||
}
|
||||
DownloadStatus::Paused { .. } => {
|
||||
self.client
|
||||
.resume_download(&download.username, &download.filename);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_selected_queued_download(&mut self) {
|
||||
let Some(index) = self.state.downloads_table_state.selected() else {
|
||||
return;
|
||||
};
|
||||
let Some(download_entry) = self.state.downloads.get(index) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let download = &download_entry.download;
|
||||
if !matches!(download.status, DownloadStatus::Queued) {
|
||||
return;
|
||||
}
|
||||
|
||||
if !self
|
||||
.client
|
||||
.remove_queued_download(&download.username, &download.filename)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
self.state.downloads.remove(index);
|
||||
self.select_download_after_removal(index);
|
||||
}
|
||||
|
||||
fn select_download_after_removal(&mut self, removed_index: usize) {
|
||||
if self.state.downloads.is_empty() {
|
||||
self.state.downloads_table_state.select(None);
|
||||
return;
|
||||
}
|
||||
|
||||
let next_index = removed_index.min(self.state.downloads.len() - 1);
|
||||
self.state.downloads_table_state.select(Some(next_index));
|
||||
}
|
||||
|
||||
fn handle_mouse_event(&mut self, mouse: MouseEvent) -> Result<()> {
|
||||
if let MouseEventKind::Down(MouseButton::Left) = mouse.kind {
|
||||
let (col, row) = (mouse.column, mouse.row);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use crate::models::DownloadEntry;
|
||||
use crate::ui::{
|
||||
HIGHLIGHT_SYMBOL, border_style, border_type, error_style, format_bytes,
|
||||
format_speed, header_style, highlight_style, inactive_style, success_style,
|
||||
warning_style,
|
||||
format_speed, header_style, highlight_style, inactive_style, info_style,
|
||||
success_style, warning_style,
|
||||
};
|
||||
use ratatui::{
|
||||
Frame,
|
||||
@@ -52,6 +52,7 @@ pub fn render_downloads_pane(
|
||||
let (status_icon, status_style) = match &download.status {
|
||||
DownloadStatus::Queued => ("⋯", inactive_style()),
|
||||
DownloadStatus::InProgress { .. } => ("⧗", warning_style()),
|
||||
DownloadStatus::Paused { .. } => ("⏸", info_style()),
|
||||
DownloadStatus::Completed => ("✓", success_style()),
|
||||
DownloadStatus::Failed => ("✗", error_style()),
|
||||
DownloadStatus::TimedOut => ("⏱", error_style()),
|
||||
@@ -74,6 +75,21 @@ pub fn render_downloads_pane(
|
||||
percent
|
||||
)
|
||||
}
|
||||
DownloadStatus::Paused { .. } => {
|
||||
let percent = if download.size > 0 {
|
||||
(download.bytes_downloaded() as f64
|
||||
/ download.size as f64
|
||||
* 100.0) as u8
|
||||
} else {
|
||||
0
|
||||
};
|
||||
format!(
|
||||
"Paused {}/{} ({}%)",
|
||||
format_bytes(download.bytes_downloaded()),
|
||||
format_bytes(download.size),
|
||||
percent
|
||||
)
|
||||
}
|
||||
DownloadStatus::Completed => "Completed".to_string(),
|
||||
DownloadStatus::Failed => "Failed".to_string(),
|
||||
DownloadStatus::TimedOut => "Timed out".to_string(),
|
||||
|
||||
@@ -139,6 +139,7 @@ pub fn status_color(status: &str) -> Color {
|
||||
match status {
|
||||
"queued" => COLOR_INACTIVE,
|
||||
"in_progress" => COLOR_WARNING,
|
||||
"paused" => COLOR_INFO,
|
||||
"completed" => COLOR_PRIMARY,
|
||||
"failed" | "timed_out" => COLOR_ERROR,
|
||||
_ => Color::default(),
|
||||
|
||||
Reference in New Issue
Block a user