From 3a49c551ea8eb8b837c08db564e5b704bb70187a Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Thu, 10 Sep 2026 09:18:44 +0200 Subject: [PATCH] feat: mark browsed files with their download status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Queueing a file or folder from a user's browse tree left no trace in the window it was queued from. A row whose download is in the transfer list now carries the same status glyph the Downloads pane uses — ⋯ queued, ↯ active, ⏸ paused, ✓ done — and an active row shows its percent beside the size. Folders mark through their files: queue a folder and watch the ✓ spread down it. --- soulseek-rs/src/ui/main_tui/render.rs | 1 + soulseek-rs/src/ui/panes/browse_pane.rs | 179 ++++++++++++++++++++++-- 2 files changed, 169 insertions(+), 11 deletions(-) diff --git a/soulseek-rs/src/ui/main_tui/render.rs b/soulseek-rs/src/ui/main_tui/render.rs index 5d88d74..c99ed49 100644 --- a/soulseek-rs/src/ui/main_tui/render.rs +++ b/soulseek-rs/src/ui/main_tui/render.rs @@ -419,6 +419,7 @@ impl MainTui { &self.state.browse, &mut self.state.browse_table_state, self.spinner_state, + &self.state.downloads, ); } diff --git a/soulseek-rs/src/ui/panes/browse_pane.rs b/soulseek-rs/src/ui/panes/browse_pane.rs index 6da44c4..fde39b9 100644 --- a/soulseek-rs/src/ui/panes/browse_pane.rs +++ b/soulseek-rs/src/ui/panes/browse_pane.rs @@ -1,8 +1,9 @@ -use crate::models::{BrowseState, BrowseStatus, BrowseTabs}; +use crate::models::{BrowseState, BrowseStatus, BrowseTabs, DownloadEntry}; use crate::ui::{ - HIGHLIGHT_SYMBOL, body_style, dimmed_style, error_style, filter_title, - format_bytes, get_spinner_char, highlight_style, page_window, pane_block, - primary_style, render_page, row_highlight_style, warning_style, + HIGHLIGHT_SYMBOL, body_style, dimmed_style, download_status_glyph, + error_style, filter_title, format_bytes, get_spinner_char, highlight_style, + page_window, pane_block, primary_style, render_page, row_highlight_style, + warning_style, }; use ratatui::{ Frame, @@ -11,15 +12,21 @@ use ratatui::{ text::{Line, Span}, widgets::{Cell, HighlightSpacing, Paragraph, Row, Table, TableState}, }; +use soulseek_rs::DownloadStatus; +use std::collections::HashMap; /// Render the browse popup: a tab bar of browsed users (when more than one is /// open) above the active user's collapsible shared-file tree. +/// +/// `downloads` is the window's transfer list; a file row whose download is +/// there shows its status glyph, the way the results pane marks its own. pub fn render_browse_pane( frame: &mut Frame, area: Rect, tabs: &BrowseTabs, table_state: &mut TableState, spinner_state: usize, + downloads: &[DownloadEntry], ) { let Some(active) = tabs.active_tab() else { return; @@ -36,7 +43,14 @@ pub fn render_browse_pane( area }; - render_browse_one(frame, tree_area, active, table_state, spinner_state); + render_browse_one( + frame, + tree_area, + active, + table_state, + spinner_state, + downloads, + ); } fn render_browse_tabs(frame: &mut Frame, area: Rect, tabs: &BrowseTabs) { @@ -60,6 +74,7 @@ fn render_browse_one( browse: &BrowseState, table_state: &mut TableState, spinner_state: usize, + downloads: &[DownloadEntry], ) { let title = match browse.status { BrowseStatus::Loaded => { @@ -128,6 +143,15 @@ fn render_browse_one( } BrowseStatus::Loaded => { let all = browse.rows(); + // Queued files carry the download's full share path, so a row + // matches a transfer of this user by path alone. + let status_of: HashMap<&str, &DownloadStatus> = downloads + .iter() + .filter(|entry| entry.download.username == browse.username) + .map(|entry| { + (entry.download.filename.as_str(), &entry.download.status) + }) + .collect(); table_state.select(Some(browse.selected_row)); let window = page_window(table_state, all.len(), area, 0); let start = window.start; @@ -146,13 +170,39 @@ fn render_browse_one( Cell::from(String::new()), ) } else { + let status = status_of.get(row.path.as_str()).copied(); + // The two spaces before a file's name become the + // transfer's glyph while its download lives in the + // list: ⋯ queued, ↯ active, ⏸ paused, ✓ done. + let marker = status.map_or_else( + || Span::raw(" ".to_string()), + |status| { + let (glyph, style) = + download_status_glyph(status); + Span::styled(format!("{glyph} "), style) + }, + ); + let plain_size = + row.size.map(format_bytes).unwrap_or_default(); + let size_text = match status { + Some(DownloadStatus::InProgress { + bytes_downloaded, + total_bytes, + .. + }) if *total_bytes > 0 => { + let percent = + bytes_downloaded * 100 / total_bytes; + format!("{plain_size} {percent}%") + } + _ => plain_size, + }; ( - Cell::from(format!("{indent} {}", row.name)) - .style(body_style()), - Cell::from( - row.size.map(format_bytes).unwrap_or_default(), - ) - .style(warning_style()), + Cell::from(Line::from(vec![ + Span::raw(indent), + marker, + Span::styled(row.name.clone(), body_style()), + ])), + Cell::from(size_text).style(warning_style()), ) }; Row::new(vec![label, size]) @@ -170,3 +220,110 @@ fn render_browse_one( } } } + +#[cfg(test)] +mod tests { + use super::render_browse_pane; + use crate::models::{BrowseState, BrowseTabs, DownloadEntry}; + use crate::ui::{GLYPH_ACTIVE, GLYPH_DONE}; + use ratatui::{Terminal, backend::TestBackend, widgets::TableState}; + use soulseek_rs::types::{Download, DownloadMetadata, DownloadStatus}; + use soulseek_rs::{SharedDirectory, SharedFileEntry}; + use std::sync::mpsc; + + fn share() -> SharedDirectory { + SharedDirectory { + name: "share".to_string(), + files: ["active.mp3", "done.mp3", "plain.mp3"] + .iter() + .map(|name| SharedFileEntry { + name: (*name).to_string(), + size: 4096, + attributes: Vec::new(), + }) + .collect(), + } + } + + fn download(filename: &str, status: DownloadStatus) -> DownloadEntry { + let (sender, _receiver) = mpsc::channel(); + DownloadEntry { + download: Download { + username: "bob".to_string(), + // Queued files carry the full share path, as in the tree. + filename: format!("share\\{filename}"), + token: 1, + size: 4096, + download_directory: "/tmp".to_string(), + status, + sender, + queue_position: None, + metadata: DownloadMetadata::default(), + }, + receiver: None, + } + } + + fn screen_with(downloads: &[DownloadEntry]) -> String { + let mut browse = BrowseState::loading("bob".to_string()); + browse.load(&[share()]); + let mut tabs = BrowseTabs::default(); + tabs.tabs.push(browse); + let mut state = TableState::default(); + let mut terminal = + Terminal::new(TestBackend::new(60, 8)).expect("backend"); + terminal + .draw(|frame| { + render_browse_pane( + frame, + frame.area(), + &tabs, + &mut state, + 0, + downloads, + ); + }) + .expect("draw"); + terminal.backend().to_string() + } + + #[test] + fn a_downloading_file_carries_its_transfer_glyph() { + let screen = screen_with(&[ + download( + "active.mp3", + DownloadStatus::InProgress { + bytes_downloaded: 2048, + total_bytes: 4096, + speed_bytes_per_sec: 1.0, + }, + ), + download("done.mp3", DownloadStatus::Completed), + ]); + assert!( + screen.contains(&format!("{GLYPH_ACTIVE} active.mp3")), + "{screen}" + ); + assert!( + screen.contains(&format!("{GLYPH_DONE} done.mp3")), + "{screen}" + ); + assert!( + screen.contains("50%"), + "the size cell tells the progress: {screen}" + ); + assert!( + screen.contains(" plain.mp3"), + "an unqueued row keeps the two plain spaces: {screen}" + ); + } + + #[test] + fn another_user_s_transfers_mark_nothing() { + let mut theirs = download("active.mp3", DownloadStatus::Completed); + theirs.download.username = "carol".to_string(); + let screen = screen_with(&[theirs]); + assert!(!screen.contains(GLYPH_ACTIVE), "{screen}"); + assert!(!screen.contains(GLYPH_DONE), "{screen}"); + } +}