feat(tui): clear the whole queue from the transfers pane

Shift-C in the Downloads/Uploads pane now cancels every live download,
forgets every row — queued, finished or failed — in the session too,
and cancels uploads still streaming, so a shared queue empties for
every window at once. The existing c keeps clearing only finished
transfers.
This commit is contained in:
Michel de Graaf
2026-09-10 10:28:22 +02:00
parent f620ffa08f
commit 4ea1e9c47c
4 changed files with 135 additions and 17 deletions
+25
View File
@@ -89,6 +89,31 @@ impl MainTui {
}
}
/// Empty the whole transfer list: every live download is cancelled and
/// every row — live or finished — is forgotten by the session too, so a
/// shared queue empties for every window rather than only this one.
/// Uploads still streaming are cancelled; finished ones are the
/// session's history and refresh back from it regardless.
pub(super) fn clear_all_transfers(&mut self) {
for entry in &self.state.downloads {
let download = &entry.download;
if !download.is_finished() {
self.client
.cancel_download(&download.username, &download.filename);
}
self.client
.remove_download(&download.username, &download.filename);
}
for upload in &self.state.uploads {
if upload.status == UploadStatus::InProgress {
self.client
.cancel_upload(&upload.username, &upload.filename);
}
}
self.state.downloads.clear();
self.state.downloads_table_state.select(None);
}
pub(super) fn toggle_selected_download_pause(&self) {
let Some(index) = self.state.downloads_table_state.selected() else {
return;
+3
View File
@@ -569,6 +569,9 @@ impl MainTui {
KeyCode::Char('c') => {
self.clear_finished_downloads();
}
KeyCode::Char('C') => {
self.clear_all_transfers();
}
_ => {}
}
}
+105 -17
View File
@@ -295,8 +295,9 @@ mod tests {
download_dir_set: std::sync::Mutex<Option<String>>,
/// What `set_shared_directories` was last asked for, if anything.
shares_set: std::sync::Mutex<Option<Vec<String>>>,
download_cancelled: std::sync::Mutex<Option<(String, String)>>,
upload_cancelled: std::sync::Mutex<Option<(String, String)>>,
download_cancelled: std::sync::Mutex<Vec<(String, String)>>,
upload_cancelled: std::sync::Mutex<Vec<(String, String)>>,
download_removed: std::sync::Mutex<Vec<(String, String)>>,
}
fn queued(username: &str, filename: &str) -> soulseek_rs::types::Download {
@@ -449,8 +450,12 @@ mod tests {
fn remove_queued_download(&self, _u: &str, _f: &str) -> bool {
false
}
fn remove_download(&self, _u: &str, _f: &str) -> bool {
false
fn remove_download(&self, u: &str, f: &str) -> bool {
self.download_removed
.lock()
.expect("not poisoned")
.push((u.to_string(), f.to_string()));
true
}
fn uploads(&self) -> Vec<soulseek_rs::UploadInfo> {
Vec::new()
@@ -459,13 +464,17 @@ mod tests {
Vec::new()
}
fn cancel_upload(&self, u: &str, f: &str) -> bool {
*self.upload_cancelled.lock().expect("not poisoned") =
Some((u.to_string(), f.to_string()));
self.upload_cancelled
.lock()
.expect("not poisoned")
.push((u.to_string(), f.to_string()));
true
}
fn cancel_download(&self, u: &str, f: &str) -> bool {
*self.download_cancelled.lock().expect("not poisoned") =
Some((u.to_string(), f.to_string()));
self.download_cancelled
.lock()
.expect("not poisoned")
.push((u.to_string(), f.to_string()));
true
}
fn set_upload_slots(&self, _slots: usize) {}
@@ -614,11 +623,70 @@ mod tests {
assert_eq!(
*session.download_cancelled.lock().expect("not poisoned"),
Some(("bob".to_string(), "song.mp3".to_string()))
vec![("bob".to_string(), "song.mp3".to_string())]
);
assert!(
session
.upload_cancelled
.lock()
.expect("not poisoned")
.is_empty(),
"the download row cancels no upload"
);
}
#[test]
fn shift_c_clears_the_whole_transfer_list() {
let session = Arc::new(TalkativeSession::default());
let mut tui = attach(session.clone());
tui.state.downloads.push(crate::models::DownloadEntry {
download: queued("bob", "waiting.mp3"),
receiver: None,
});
tui.state.downloads.push(crate::models::DownloadEntry {
download: at_status(
"carol",
"live.mp3",
soulseek_rs::DownloadStatus::InProgress {
bytes_downloaded: 1,
total_bytes: 4096,
speed_bytes_per_sec: 1.0,
},
),
receiver: None,
});
tui.state.downloads.push(crate::models::DownloadEntry {
download: completed("dave", "done.mp3"),
receiver: None,
});
tui.state.uploads.push(uploading("alice", "served.mp3"));
tui.state.downloads_table_state.select(Some(0));
tui.state.focused_pane = FocusedPane::Downloads;
tui.handle_key_event(key('C'));
assert!(tui.state.downloads.is_empty(), "every row goes");
assert_eq!(
*session.download_cancelled.lock().expect("not poisoned"),
vec![
("bob".to_string(), "waiting.mp3".to_string()),
("carol".to_string(), "live.mp3".to_string()),
],
"live downloads are cancelled; the finished one is not"
);
assert_eq!(
*session.download_removed.lock().expect("not poisoned"),
vec![
("bob".to_string(), "waiting.mp3".to_string()),
("carol".to_string(), "live.mp3".to_string()),
("dave".to_string(), "done.mp3".to_string()),
],
"the session forgets every download, not just this window's rows"
);
assert_eq!(
*session.upload_cancelled.lock().expect("not poisoned"),
None
vec![("alice".to_string(), "served.mp3".to_string())],
"a streaming upload is cancelled too"
);
}
@@ -654,9 +722,13 @@ mod tests {
tui.handle_key_event(key('x'));
assert_eq!(
*session.download_cancelled.lock().expect("not poisoned"),
None
assert!(
session
.download_cancelled
.lock()
.expect("not poisoned")
.is_empty(),
"a finished download is not cancelled"
);
}
@@ -676,11 +748,15 @@ mod tests {
assert_eq!(
*session.upload_cancelled.lock().expect("not poisoned"),
Some(("alice".to_string(), "served.mp3".to_string()))
vec![("alice".to_string(), "served.mp3".to_string())]
);
assert_eq!(
*session.download_cancelled.lock().expect("not poisoned"),
None
assert!(
session
.download_cancelled
.lock()
.expect("not poisoned")
.is_empty(),
"the upload row cancels no download"
);
}
@@ -2042,6 +2118,18 @@ mod tests {
assert!(screen.contains("run the search again"), "{screen}");
}
#[test]
fn the_downloads_pane_lists_its_clear_everything_key() {
let mut tui = furnished_tui();
tui.state.focused_pane = FocusedPane::Downloads;
let screen = screen_sized(&mut tui, 300, 40);
assert!(screen.contains("[c → clear finished]"), "{screen}");
assert!(screen.contains("[C → clear all]"), "{screen}");
press(&mut tui, KeyCode::Char('?'));
let screen = screen_of(&mut tui);
assert!(screen.contains("cancelling the live ones"), "{screen}");
}
#[test]
fn shift_w_brings_every_pane_back_and_leaves_zoom() {
let mut tui = furnished_tui();
+2
View File
@@ -89,6 +89,7 @@ const HELP_RIGHT: &[(&str, &[(&str, &str)])] = &[
("r", "retry a failed one"),
("d", "delete a queued or finished one"),
("c", "clear every finished one"),
("C", "clear every one, cancelling the live ones"),
("b", "browse that user"),
],
),
@@ -702,6 +703,7 @@ impl MainTui {
("r", "retry failed"),
("d", "delete queued/done"),
("c", "clear finished"),
("C", "clear all"),
("b", "browse user"),
("h/l", "scroll name"),
],