feat: mark browsed files with their download status

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.
This commit is contained in:
Michel de Graaf
2026-09-10 09:18:44 +02:00
parent e641644808
commit 3a49c551ea
2 changed files with 169 additions and 11 deletions
+1
View File
@@ -419,6 +419,7 @@ impl MainTui {
&self.state.browse,
&mut self.state.browse_table_state,
self.spinner_state,
&self.state.downloads,
);
}
+168 -11
View File
@@ -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}");
}
}