Add TUI download pause and queued delete

This commit is contained in:
michel
2026-04-26 11:50:37 +02:00
parent 807dea5f17
commit de58c8cbe2
7 changed files with 354 additions and 9 deletions
+187
View File
@@ -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,
+61 -5
View File
@@ -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,
+7
View File
@@ -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,
+16 -2
View File
@@ -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);
+64
View File
@@ -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);
+18 -2
View File
@@ -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(),
+1
View File
@@ -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(),