feat: add download info pane to TUI
Splits the downloads area 60/40 with a new info pane that adapts to the selected download's status: header (filename, user, size, status, save path, bitrate, length) plus queued-specific fields (queue position, peer slots, peer upload speed) or live progress/speed/ETA when transferring. Plumbs queue position via a new ClientOperation::PlaceInQueueUpdate so PlaceInQueueResponse messages from peers reach DownloadStore, and threads search-time metadata (bitrate, length, peer slots/speed) onto Download through a new download_with_metadata entry point.
This commit is contained in:
@@ -245,6 +245,18 @@ impl PeerActor {
|
||||
"[peer:{}] Place in queue response - file: {}, place: {}",
|
||||
username, filename, place
|
||||
);
|
||||
if let Err(e) =
|
||||
self.client_channel.send(ClientOperation::PlaceInQueueUpdate {
|
||||
username: username.clone(),
|
||||
filename,
|
||||
place,
|
||||
})
|
||||
{
|
||||
error!(
|
||||
"[peer:{}] failed to forward PlaceInQueueUpdate: {}",
|
||||
username, e
|
||||
);
|
||||
}
|
||||
}
|
||||
PeerMessage::SetUsername(username) => {
|
||||
trace!(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::actor::ActorHandle;
|
||||
use crate::actor::server_actor::{PeerAddress, ServerActor, ServerMessage};
|
||||
use crate::download_store::{DownloadStore, collect_failed_tokens};
|
||||
use crate::types::DownloadStatus;
|
||||
use crate::types::{DownloadMetadata, DownloadStatus};
|
||||
use crate::utils::logger;
|
||||
use crate::{
|
||||
Transfer,
|
||||
@@ -84,6 +84,11 @@ pub enum ClientOperation {
|
||||
obfuscated_port: u16,
|
||||
},
|
||||
UploadFailed(String, String),
|
||||
PlaceInQueueUpdate {
|
||||
username: String,
|
||||
filename: String,
|
||||
place: u32,
|
||||
},
|
||||
SetServerSender(Sender<ServerMessage>),
|
||||
}
|
||||
pub struct ClientContext {
|
||||
@@ -159,6 +164,8 @@ fn test_client_context_downloads() {
|
||||
download_directory: "test".to_string(),
|
||||
status: DownloadStatus::Queued,
|
||||
sender: mpsc::channel().0,
|
||||
queue_position: None,
|
||||
metadata: Default::default(),
|
||||
};
|
||||
context.add_download(download);
|
||||
assert!(context.get_download_by_token(123).is_some());
|
||||
@@ -191,6 +198,8 @@ fn test_client_pause_and_resume_download() {
|
||||
speed_bytes_per_sec: 10.0,
|
||||
},
|
||||
sender: download_sender,
|
||||
queue_position: None,
|
||||
metadata: Default::default(),
|
||||
};
|
||||
|
||||
client.context.write().unwrap().add_download(download);
|
||||
@@ -245,6 +254,8 @@ fn test_client_removes_only_queued_downloads() {
|
||||
download_directory: "test".to_string(),
|
||||
status: DownloadStatus::Queued,
|
||||
sender: mpsc::channel().0,
|
||||
queue_position: None,
|
||||
metadata: Default::default(),
|
||||
};
|
||||
let active_download = Download {
|
||||
username: "peer".to_string(),
|
||||
@@ -258,6 +269,8 @@ fn test_client_removes_only_queued_downloads() {
|
||||
speed_bytes_per_sec: 10.0,
|
||||
},
|
||||
sender: mpsc::channel().0,
|
||||
queue_position: None,
|
||||
metadata: Default::default(),
|
||||
};
|
||||
|
||||
{
|
||||
@@ -556,6 +569,23 @@ impl Client {
|
||||
username: String,
|
||||
size: u64,
|
||||
download_directory: String,
|
||||
) -> Result<(Download, Receiver<DownloadStatus>)> {
|
||||
self.download_with_metadata(
|
||||
filename,
|
||||
username,
|
||||
size,
|
||||
download_directory,
|
||||
DownloadMetadata::default(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn download_with_metadata(
|
||||
&self,
|
||||
filename: String,
|
||||
username: String,
|
||||
size: u64,
|
||||
download_directory: String,
|
||||
metadata: DownloadMetadata,
|
||||
) -> Result<(Download, Receiver<DownloadStatus>)> {
|
||||
info!("[client] Downloading {} from {}", filename, username);
|
||||
|
||||
@@ -575,6 +605,8 @@ impl Client {
|
||||
download_directory,
|
||||
status: DownloadStatus::Queued,
|
||||
sender: download_sender,
|
||||
queue_position: None,
|
||||
metadata,
|
||||
};
|
||||
|
||||
let mut context = self.context.write_safe()?;
|
||||
@@ -1025,6 +1057,8 @@ impl Client {
|
||||
.download_directory,
|
||||
status: download.status.clone(),
|
||||
sender: download.sender.clone(),
|
||||
queue_position: download.queue_position,
|
||||
metadata: download.metadata.clone(),
|
||||
});
|
||||
context.remove_download(old_token);
|
||||
}
|
||||
@@ -1039,6 +1073,29 @@ impl Client {
|
||||
Some(&filename),
|
||||
);
|
||||
}
|
||||
ClientOperation::PlaceInQueueUpdate {
|
||||
username,
|
||||
filename,
|
||||
place,
|
||||
} => match client_context.write_safe() {
|
||||
Ok(mut ctx) => {
|
||||
let updated = ctx
|
||||
.downloads
|
||||
.update_queue_position(
|
||||
&username, &filename, place,
|
||||
);
|
||||
if !updated {
|
||||
debug!(
|
||||
"[client] PlaceInQueueUpdate: no matching download for {}/{}",
|
||||
username, filename
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => error!(
|
||||
"[client] PlaceInQueueUpdate write: {}",
|
||||
e
|
||||
),
|
||||
},
|
||||
ClientOperation::SetServerSender(sender) => {
|
||||
match client_context.write_safe() {
|
||||
Ok(mut ctx) => {
|
||||
|
||||
@@ -50,6 +50,19 @@ impl DownloadStore {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_queue_position(
|
||||
&mut self,
|
||||
username: &str,
|
||||
filename: &str,
|
||||
position: u32,
|
||||
) -> bool {
|
||||
let Some(download) = self.get_by_file_mut(username, filename) else {
|
||||
return false;
|
||||
};
|
||||
download.queue_position = Some(position);
|
||||
true
|
||||
}
|
||||
|
||||
pub fn remove_queued_by_file(
|
||||
&mut self,
|
||||
username: &str,
|
||||
@@ -150,6 +163,8 @@ mod tests {
|
||||
download_directory: "test".to_string(),
|
||||
status,
|
||||
sender: mpsc::channel().0,
|
||||
queue_position: None,
|
||||
metadata: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,6 +182,24 @@ mod tests {
|
||||
assert!(store.list().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_queue_position_sets_field_when_match() {
|
||||
let mut store = DownloadStore::new();
|
||||
let mut download = make_download(1, DownloadStatus::Queued);
|
||||
download.username = "peer".to_string();
|
||||
download.filename = "song.mp3".to_string();
|
||||
store.add(download);
|
||||
|
||||
assert!(store.update_queue_position("peer", "song.mp3", 42));
|
||||
assert_eq!(
|
||||
store.get_by_token(1).unwrap().queue_position,
|
||||
Some(42)
|
||||
);
|
||||
|
||||
assert!(!store.update_queue_position("peer", "missing.mp3", 1));
|
||||
assert!(!store.update_queue_position("other", "song.mp3", 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pause_then_resume_in_progress_download() {
|
||||
let mut store = DownloadStore::new();
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::io::{self, Read, Write};
|
||||
use std::net::TcpStream;
|
||||
@@ -12,6 +11,7 @@ use crate::client::ClientContext;
|
||||
use crate::message::server::MessageFactory;
|
||||
use crate::trace;
|
||||
use crate::types::{Download, DownloadStatus};
|
||||
use crate::utils::path::expand_tilde;
|
||||
|
||||
const START_DOWNLOAD: [u8; 8] =
|
||||
[0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
|
||||
@@ -72,18 +72,6 @@ impl From<io::Error> for DownloadError {
|
||||
struct FileManager;
|
||||
|
||||
impl FileManager {
|
||||
fn expand_path(path: &str) -> PathBuf {
|
||||
if let Some(stripped) = path.strip_prefix('~') {
|
||||
if let Ok(home) = env::var("HOME") {
|
||||
PathBuf::from(home).join(stripped.trim_start_matches('/'))
|
||||
} else {
|
||||
PathBuf::from(path)
|
||||
}
|
||||
} else {
|
||||
PathBuf::from(path)
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_filename_from_path(full_path: &str) -> &str {
|
||||
full_path
|
||||
.split(['/', '\\'])
|
||||
@@ -414,7 +402,7 @@ impl DownloadPeer {
|
||||
download: &Download,
|
||||
) -> Result<String, DownloadError> {
|
||||
let download_directory = &download.download_directory;
|
||||
let mut expanded_path = FileManager::expand_path(download_directory);
|
||||
let mut expanded_path = expand_tilde(download_directory);
|
||||
|
||||
if !expanded_path.is_dir() {
|
||||
expanded_path = expanded_path
|
||||
|
||||
@@ -87,6 +87,14 @@ pub struct Transfer {
|
||||
pub filename: String,
|
||||
pub size: u64,
|
||||
}
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct DownloadMetadata {
|
||||
pub bitrate: Option<u32>,
|
||||
pub length_seconds: Option<u32>,
|
||||
pub peer_upload_speed: Option<u32>,
|
||||
pub peer_free_slots: Option<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Download {
|
||||
pub username: String,
|
||||
@@ -96,6 +104,8 @@ pub struct Download {
|
||||
pub download_directory: String,
|
||||
pub status: DownloadStatus,
|
||||
pub sender: Sender<DownloadStatus>,
|
||||
pub queue_position: Option<u32>,
|
||||
pub metadata: DownloadMetadata,
|
||||
}
|
||||
|
||||
impl Download {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
pub mod logger;
|
||||
pub mod lock;
|
||||
pub mod md5;
|
||||
pub mod path;
|
||||
pub mod thread_pool;
|
||||
pub mod zlib;
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub fn expand_tilde(path: &str) -> PathBuf {
|
||||
if let Some(stripped) = path.strip_prefix('~')
|
||||
&& let Ok(home) = std::env::var("HOME")
|
||||
{
|
||||
return PathBuf::from(home).join(stripped.trim_start_matches('/'));
|
||||
}
|
||||
PathBuf::from(path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn no_tilde_returned_as_is() {
|
||||
assert_eq!(expand_tilde("/abs/path"), PathBuf::from("/abs/path"));
|
||||
assert_eq!(expand_tilde("rel/path"), PathBuf::from("rel/path"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tilde_expanded_when_home_set() {
|
||||
// SAFETY: tests run single-threaded by default in this crate; if you
|
||||
// ever run with --test-threads >1 this needs serialization.
|
||||
unsafe {
|
||||
std::env::set_var("HOME", "/home/test");
|
||||
}
|
||||
assert_eq!(expand_tilde("~"), PathBuf::from("/home/test"));
|
||||
assert_eq!(
|
||||
expand_tilde("~/Downloads"),
|
||||
PathBuf::from("/home/test/Downloads")
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user