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:
michel
2026-04-26 17:19:26 +02:00
parent fa962a0dea
commit 574833bf61
12 changed files with 454 additions and 29 deletions
+12
View File
@@ -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!(
+58 -1
View File
@@ -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) => {
+33
View File
@@ -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();
+2 -14
View File
@@ -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
+10
View File
@@ -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 {
+1
View File
@@ -2,6 +2,7 @@
pub mod logger;
pub mod lock;
pub mod md5;
pub mod path;
pub mod thread_pool;
pub mod zlib;
+35
View File
@@ -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")
);
}
}