Files
soulseek-rs-modded/soulseek-rs/src/ui/panes/downloads_pane.rs
T
Michel de Graaf aa61a1a434 feat: cancel a download from the TUI, the CLI, and the daemon
Closes #54.

A download can now be cancelled whether it is queued, in progress, or
paused. Cancelled is a terminal status: the store refuses to overwrite
it, the status channel relays nothing after it, the partial file is
deleted, and the transfer thread releases its socket at its next
one-second poll instead of the 30s read deadline. A queued download that
is cancelled declines the peer's later offer with TransferResponse
"Cancelled", and an inbound file connection for it is refused. A fresh
download of the same file evicts the dead row so it is not shadowed,
and a cancelled transfer is not re-queued after a restart.

Surfaces: Client::cancel_download, download.cancel over RPC, `transfer
cancel <user> <path>` on the CLI (which also stops an upload by that
name), and x in the transfers pane, where d dismisses the row.
2026-09-06 21:40:45 +02:00

187 lines
6.8 KiB
Rust

use crate::models::DownloadEntry;
use crate::ui::{
GLYPH_ACTIVE, GLYPH_DONE, GLYPH_FAILED, GLYPH_QUEUED, HIGHLIGHT_SYMBOL,
accent_style, body_style, dimmed_style, download_status_glyph, error_style,
format_bytes, format_speed, header_style, inactive_style, info_style,
pane_block, pane_title, primary_style, row_highlight_style, success_style,
warning_style,
};
use ratatui::{
Frame,
layout::Rect,
widgets::{Cell, HighlightSpacing, Paragraph, Row, Table, TableState},
};
use soulseek_rs::DownloadStatus;
use soulseek_rs::types::{UploadInfo, UploadStatus};
pub fn render_downloads_pane(
frame: &mut Frame,
area: Rect,
downloads: &[DownloadEntry],
uploads: &[UploadInfo],
table_state: &mut TableState,
focused: bool,
) {
if downloads.is_empty() && uploads.is_empty() {
let empty_block = pane_block(focused).title(pane_title(
"3",
"Downloads/Uploads",
focused,
));
let paragraph = Paragraph::new(
"No transfers. Select files in [2] Results and press Enter.",
)
.style(dimmed_style())
.block(empty_block);
frame.render_widget(paragraph, area);
return;
}
let header = Row::new(vec![
Cell::from("Status").style(header_style()),
Cell::from("Filename").style(header_style()),
Cell::from("User").style(header_style()),
Cell::from("Progress").style(header_style()),
Cell::from("Speed").style(header_style()),
])
.height(1);
let mut rows: Vec<Row> = downloads
.iter()
.map(|download_entry| {
let download = &download_entry.download;
let (status_icon, status_style) =
download_status_glyph(&download.status);
let progress_text = match &download.status {
DownloadStatus::Queued => "Queued".to_string(),
DownloadStatus::InProgress { .. }
| DownloadStatus::Paused { .. } => {
let prefix = if matches!(
download.status,
DownloadStatus::Paused { .. }
) {
"Paused "
} else {
""
};
let percent = if download.size > 0 {
(download.bytes_downloaded() as f64
/ download.size as f64
* 100.0) as u8
} else {
0
};
format!(
"{prefix}{}/{} ({}%)",
format_bytes(download.bytes_downloaded()),
format_bytes(download.size),
percent
)
}
DownloadStatus::Completed => "Completed".to_string(),
DownloadStatus::Cancelled => "Cancelled".to_string(),
DownloadStatus::Failed(_) => "Failed".to_string(),
DownloadStatus::TimedOut => "Timed out".to_string(),
};
let speed_text = match &download.status {
DownloadStatus::InProgress { .. } => {
format_speed(download.speed_bytes_per_sec())
}
_ => "-".to_string(),
};
let progress_style = match &download.status {
DownloadStatus::Completed => success_style(),
DownloadStatus::Failed(_) | DownloadStatus::TimedOut => {
error_style()
}
DownloadStatus::Queued | DownloadStatus::Cancelled => {
inactive_style()
}
_ => primary_style(),
};
Row::new(vec![
Cell::from(status_icon).style(status_style),
Cell::from(download.filename.clone()).style(body_style()),
Cell::from(download.username.clone()).style(info_style()),
Cell::from(progress_text).style(progress_style),
Cell::from(speed_text).style(warning_style()),
])
})
.collect();
rows.extend(uploads.iter().map(|upload| {
let (status_icon, status_style) = match &upload.status {
UploadStatus::Queued(_) => (GLYPH_QUEUED, warning_style()),
UploadStatus::InProgress => (GLYPH_ACTIVE, accent_style()),
UploadStatus::Completed => (GLYPH_DONE, success_style()),
UploadStatus::Cancelled => (GLYPH_FAILED, inactive_style()),
UploadStatus::Failed(_) => (GLYPH_FAILED, error_style()),
};
let progress_text = match &upload.status {
UploadStatus::Queued(place) => format!("Queued #{place}"),
UploadStatus::InProgress => {
let percent = if upload.size > 0 {
(upload.bytes_sent as f64 / upload.size as f64 * 100.0)
as u8
} else {
0
};
format!(
"{}/{} ({}%)",
format_bytes(upload.bytes_sent),
format_bytes(upload.size),
percent
)
}
UploadStatus::Completed => "Completed".to_string(),
UploadStatus::Cancelled => "Cancelled".to_string(),
UploadStatus::Failed(_) => "Failed".to_string(),
};
// Same rule as a download: a rate only while the transfer is running.
let speed_text = match &upload.status {
UploadStatus::InProgress => {
format_speed(upload.speed_bytes_per_sec)
}
_ => "-".to_string(),
};
let basename = upload
.filename
.rsplit(['\\', '/'])
.next()
.unwrap_or(&upload.filename);
Row::new(vec![
Cell::from(format!("↑ {status_icon}")).style(status_style),
Cell::from(basename.to_string()).style(body_style()),
Cell::from(upload.username.clone()).style(info_style()),
Cell::from(progress_text).style(primary_style()),
Cell::from(speed_text).style(warning_style()),
])
}));
let widths = [
ratatui::layout::Constraint::Length(8),
ratatui::layout::Constraint::Fill(2),
ratatui::layout::Constraint::Length(15),
ratatui::layout::Constraint::Length(25),
ratatui::layout::Constraint::Length(12),
];
let table = Table::new(rows, widths)
.header(header)
.row_highlight_style(row_highlight_style())
.highlight_symbol(HIGHLIGHT_SYMBOL)
.highlight_spacing(HighlightSpacing::Always)
.block(pane_block(focused).title(pane_title(
"3",
"Downloads/Uploads",
focused,
)));
frame.render_stateful_widget(table, area, table_state);
}