feat(cli)!: replace the one-shot commands with a scriptable surface
The one-shot half of the binary could not finish its headline task without a human at the keyboard: `search` opened a full-screen picker, panicked with exit 101 when there was no terminal, and no command could fetch a known file at all. Everything exited 0 whatever happened, wrote emoji prose to stdout, and ignored --download-dir, SOULSEEK_DOWNLOAD_DIR and config.toml. The commands are now built for scripts and agents: search download get browse room list|say|listen message send|read stdout carries records only (tab-separated, or NDJSON under --json), stderr carries progress, and the exit code carries the verdict: 2 usage, 3 connect, 4 no results, 5 timed out, 6 transfer failed. `get` does search, rank and fetch in one step, and `search --json | jq ... | download --stdin` composes. Global flags parse in any position and every one has a SOULSEEK_ variable and a config.toml key resolved through a single precedence chain. Defects found and fixed along the way, each covered by a test: - a download with an unknown size was finalized against zero bytes and saved as an empty file; the size now comes from the peer's share listing - transfers are keyed by a hash of the remote path, so the same path from two peers shadowed itself and two files sharing a basename overwrote each other; both collisions are now dropped with a warning - `message send` and `room say` slept 500ms and claimed success; they now wait for a server reply queued behind the message before reporting - Client::login waited on an unbounded channel and never returned when the server went quiet Deleted the TUI picker the old `search` drove (download_selector, MultiDownloadProgress, SearchConfig): 1,573 lines out, 926 in. The interactive TUI is otherwise untouched. BREAKING CHANGE: `rooms` and `chat` become `room list|say|listen` and `message send|read`; `--disable-listener` becomes `--no-listener`; the unprefixed DISABLE_LISTENER, LISTENER_PORT, LOG_FILE and MAX_CONCURRENT_DOWNLOADS variables are now SOULSEEK_-prefixed; `search` no longer opens a picker or downloads anything.
This commit is contained in:
@@ -1,636 +0,0 @@
|
||||
use crate::models::FileDisplayData;
|
||||
use crate::ui::{
|
||||
BYTES_PER_MB, HIGHLIGHT_SYMBOL, format_bytes, format_shortcuts_styled,
|
||||
get_bitrate, get_spinner_char, header_style, highlight_style, pane_block,
|
||||
plain_title, primary_style, success_style, warning_style,
|
||||
};
|
||||
use color_eyre::Result;
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::{
|
||||
DefaultTerminal, Frame,
|
||||
crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, poll},
|
||||
layout::{Alignment, Constraint, Layout},
|
||||
style::Style,
|
||||
widgets::{
|
||||
Cell, HighlightSpacing, Paragraph, Row, StatefulWidget, Table,
|
||||
TableState, Wrap,
|
||||
},
|
||||
};
|
||||
use soulseek_rs::Client;
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
pub struct FileSelector {
|
||||
all_items: Vec<FileDisplayData>,
|
||||
items: Vec<FileDisplayData>,
|
||||
filtered_indices: Vec<usize>,
|
||||
state: TableState,
|
||||
should_exit: bool,
|
||||
selected_indices: HashSet<usize>,
|
||||
filter_query: String,
|
||||
is_filtering: bool,
|
||||
client: Option<Arc<Client>>,
|
||||
soulseek_query: String,
|
||||
search_timeout: Duration,
|
||||
search_start_time: Instant,
|
||||
search_cancel_flag: Arc<AtomicBool>,
|
||||
search_active: bool,
|
||||
spinner_state: usize,
|
||||
last_spinner_update: Instant,
|
||||
last_result_count: usize,
|
||||
}
|
||||
|
||||
impl FileSelector {
|
||||
pub fn new_with_live_search(
|
||||
client: Arc<Client>,
|
||||
query: String,
|
||||
timeout: Duration,
|
||||
cancel_flag: Arc<AtomicBool>,
|
||||
) -> Self {
|
||||
let mut state = TableState::default();
|
||||
state.select(Some(0));
|
||||
|
||||
Self {
|
||||
all_items: Vec::new(),
|
||||
items: Vec::new(),
|
||||
filtered_indices: Vec::new(),
|
||||
state,
|
||||
should_exit: false,
|
||||
selected_indices: HashSet::new(),
|
||||
filter_query: String::new(),
|
||||
is_filtering: false,
|
||||
client: Some(client),
|
||||
soulseek_query: query,
|
||||
search_timeout: timeout,
|
||||
search_start_time: Instant::now(),
|
||||
search_cancel_flag: cancel_flag,
|
||||
search_active: true,
|
||||
spinner_state: 0,
|
||||
last_spinner_update: Instant::now(),
|
||||
last_result_count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run(
|
||||
&mut self,
|
||||
mut terminal: DefaultTerminal,
|
||||
) -> Result<(DefaultTerminal, Vec<usize>)> {
|
||||
while !self.should_exit {
|
||||
terminal.draw(|frame| self.render(frame))?;
|
||||
self.spinner_state = (self.spinner_state + 1) % 10;
|
||||
|
||||
self.last_spinner_update = Instant::now();
|
||||
|
||||
// Poll for new search results if active
|
||||
if self.search_active {
|
||||
if let Some(ref client) = self.client {
|
||||
let current_count =
|
||||
client.get_search_results_count(&self.soulseek_query);
|
||||
if current_count != self.last_result_count {
|
||||
self.update_results_from_client();
|
||||
self.last_result_count = current_count;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if search timeout reached
|
||||
if self.search_start_time.elapsed() >= self.search_timeout {
|
||||
self.search_active = false;
|
||||
self.search_cancel_flag.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
let timeout = if self.search_active {
|
||||
Duration::from_millis(50)
|
||||
} else {
|
||||
Duration::from_millis(100)
|
||||
};
|
||||
|
||||
if poll(timeout)?
|
||||
&& let Event::Key(key) = event::read()?
|
||||
{
|
||||
self.handle_key(key);
|
||||
}
|
||||
}
|
||||
|
||||
Ok((terminal, self.selected_indices.iter().copied().collect()))
|
||||
}
|
||||
|
||||
fn update_results_from_client(&mut self) {
|
||||
if let Some(ref client) = self.client {
|
||||
let search_results =
|
||||
client.get_search_results(&self.soulseek_query);
|
||||
|
||||
// Convert search results to FileDisplayData
|
||||
let mut new_items = Vec::new();
|
||||
for result in &search_results {
|
||||
for file in &result.files {
|
||||
new_items.push(FileDisplayData {
|
||||
filename: file.name.clone(),
|
||||
size: file.size,
|
||||
username: result.username.clone(),
|
||||
speed: result.speed,
|
||||
slots: result.slots,
|
||||
bitrate: get_bitrate(&file.attribs),
|
||||
length_seconds: file.attribs.get(&1).copied(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let len = new_items.len();
|
||||
self.all_items = new_items.clone();
|
||||
self.items = new_items;
|
||||
self.filtered_indices = (0..len).collect();
|
||||
|
||||
// Keep selection valid or set to 0
|
||||
if self.state.selected().is_none() && !self.items.is_empty() {
|
||||
self.state.select(Some(0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_selection(&mut self) {
|
||||
if let Some(filtered_idx) = self.state.selected()
|
||||
&& let Some(&original_idx) = self.filtered_indices.get(filtered_idx)
|
||||
{
|
||||
if self.selected_indices.contains(&original_idx) {
|
||||
self.selected_indices.remove(&original_idx);
|
||||
} else {
|
||||
self.selected_indices.insert(original_idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_key(&mut self, key: KeyEvent) {
|
||||
if key.kind != KeyEventKind::Press {
|
||||
return;
|
||||
}
|
||||
|
||||
if self.is_filtering {
|
||||
match key.code {
|
||||
KeyCode::Char(' ') => {
|
||||
self.toggle_selection();
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
self.filter_query.push(c);
|
||||
self.apply_filter();
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
self.filter_query.pop();
|
||||
self.apply_filter();
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
self.filter_query.clear();
|
||||
self.apply_filter();
|
||||
self.is_filtering = false;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
// If nothing selected, select the current item under cursor
|
||||
if self.selected_indices.is_empty()
|
||||
&& let Some(filtered_idx) = self.state.selected()
|
||||
&& let Some(&original_idx) =
|
||||
self.filtered_indices.get(filtered_idx)
|
||||
{
|
||||
self.selected_indices.insert(original_idx);
|
||||
}
|
||||
self.should_exit = true;
|
||||
}
|
||||
KeyCode::Up | KeyCode::Down => {
|
||||
if key.code == KeyCode::Up {
|
||||
self.select_previous();
|
||||
} else {
|
||||
self.select_next();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
} else {
|
||||
match key.code {
|
||||
KeyCode::Char(' ') => {
|
||||
self.toggle_selection();
|
||||
}
|
||||
KeyCode::Char('/') => {
|
||||
self.is_filtering = true;
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') => self.select_previous(),
|
||||
KeyCode::Down | KeyCode::Char('j') => self.select_next(),
|
||||
KeyCode::Home | KeyCode::Char('g') => self.select_first(),
|
||||
KeyCode::End | KeyCode::Char('G') => self.select_last(),
|
||||
KeyCode::Char('a') => {
|
||||
// Select all visible/filtered items
|
||||
for &original_idx in &self.filtered_indices {
|
||||
self.selected_indices.insert(original_idx);
|
||||
}
|
||||
}
|
||||
KeyCode::Char('A') => {
|
||||
// Deselect all items
|
||||
self.selected_indices.clear();
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
// If nothing selected, select the current item under cursor
|
||||
if self.selected_indices.is_empty()
|
||||
&& let Some(filtered_idx) = self.state.selected()
|
||||
&& let Some(&original_idx) =
|
||||
self.filtered_indices.get(filtered_idx)
|
||||
{
|
||||
self.selected_indices.insert(original_idx);
|
||||
}
|
||||
self.should_exit = true;
|
||||
}
|
||||
KeyCode::Esc | KeyCode::Char('q') => {
|
||||
self.selected_indices.clear();
|
||||
self.should_exit = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Previous index with wrap-around, or None when the list is empty.
|
||||
const fn wrapping_prev(
|
||||
selected: Option<usize>,
|
||||
len: usize,
|
||||
) -> Option<usize> {
|
||||
if len == 0 {
|
||||
return None;
|
||||
}
|
||||
Some(match selected {
|
||||
Some(i) if i > 0 => i - 1,
|
||||
_ => len - 1,
|
||||
})
|
||||
}
|
||||
|
||||
/// Next index with wrap-around, or None when the list is empty.
|
||||
const fn wrapping_next(
|
||||
selected: Option<usize>,
|
||||
len: usize,
|
||||
) -> Option<usize> {
|
||||
if len == 0 {
|
||||
return None;
|
||||
}
|
||||
Some(match selected {
|
||||
Some(i) if i < len - 1 => i + 1,
|
||||
_ => 0,
|
||||
})
|
||||
}
|
||||
|
||||
const fn select_previous(&mut self) {
|
||||
let i = Self::wrapping_prev(self.state.selected(), self.items.len());
|
||||
self.state.select(i);
|
||||
}
|
||||
|
||||
const fn select_next(&mut self) {
|
||||
let i = Self::wrapping_next(self.state.selected(), self.items.len());
|
||||
self.state.select(i);
|
||||
}
|
||||
|
||||
fn select_first(&mut self) {
|
||||
self.state.select((!self.items.is_empty()).then_some(0));
|
||||
}
|
||||
|
||||
const fn select_last(&mut self) {
|
||||
self.state.select(self.items.len().checked_sub(1));
|
||||
}
|
||||
|
||||
fn apply_filter(&mut self) {
|
||||
let query_lower = self.filter_query.to_lowercase();
|
||||
|
||||
if query_lower.is_empty() {
|
||||
self.items = self.all_items.clone();
|
||||
self.filtered_indices = (0..self.all_items.len()).collect();
|
||||
} else {
|
||||
self.items.clear();
|
||||
self.filtered_indices.clear();
|
||||
|
||||
for (i, item) in self.all_items.iter().enumerate() {
|
||||
if item.filename.to_lowercase().contains(&query_lower)
|
||||
|| item.username.to_lowercase().contains(&query_lower)
|
||||
{
|
||||
self.items.push(item.clone());
|
||||
self.filtered_indices.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if self.items.is_empty() {
|
||||
self.state.select(None);
|
||||
} else {
|
||||
self.state.select(Some(0));
|
||||
}
|
||||
}
|
||||
|
||||
fn render(&mut self, frame: &mut Frame) {
|
||||
let area = frame.area();
|
||||
|
||||
// Always split layout into table, optional info, and controls footer
|
||||
let has_info = self.is_filtering
|
||||
|| !self.selected_indices.is_empty()
|
||||
|| !self.filter_query.is_empty();
|
||||
|
||||
let chunks = if has_info {
|
||||
Layout::vertical([
|
||||
Constraint::Min(0),
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(3),
|
||||
])
|
||||
.split(area)
|
||||
} else {
|
||||
Layout::vertical([Constraint::Min(0), Constraint::Length(3)])
|
||||
.split(area)
|
||||
};
|
||||
|
||||
let table_area = chunks[0];
|
||||
let (info_area, controls_area) = if has_info {
|
||||
(Some(chunks[1]), chunks[2])
|
||||
} else {
|
||||
(None, chunks[1])
|
||||
};
|
||||
|
||||
self.render_results_table(frame, table_area);
|
||||
|
||||
if self.items.is_empty() {
|
||||
self.render_loading_message(frame, table_area);
|
||||
}
|
||||
|
||||
if let Some(info_area) = info_area {
|
||||
self.render_info(frame, info_area);
|
||||
}
|
||||
|
||||
self.render_controls(frame, controls_area);
|
||||
}
|
||||
|
||||
fn table_title(&self) -> String {
|
||||
if self.search_active {
|
||||
// Live Soulseek search is active
|
||||
let spinner = get_spinner_char(self.spinner_state);
|
||||
let elapsed = self.search_start_time.elapsed().as_secs();
|
||||
let total = self.search_timeout.as_secs();
|
||||
format!(
|
||||
"{} Searching: '{}' - {} results ({}/{}s)",
|
||||
spinner,
|
||||
self.soulseek_query,
|
||||
self.all_items.len(),
|
||||
elapsed,
|
||||
total
|
||||
)
|
||||
} else if self.is_filtering {
|
||||
format!(
|
||||
"Multi-select files to download ({}/{} matches)",
|
||||
self.items.len(),
|
||||
self.all_items.len()
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"Multi-select files to download ({} selected)",
|
||||
self.selected_indices.len()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn render_results_table(
|
||||
&mut self,
|
||||
frame: &mut Frame,
|
||||
table_area: ratatui::layout::Rect,
|
||||
) {
|
||||
let title = self.table_title();
|
||||
|
||||
let header = Row::new(vec![
|
||||
Cell::from(""),
|
||||
Cell::from("Filename"),
|
||||
Cell::from("Size"),
|
||||
Cell::from("Username"),
|
||||
Cell::from("Speed"),
|
||||
Cell::from("Slots"),
|
||||
Cell::from("Bitrate"),
|
||||
])
|
||||
.style(header_style())
|
||||
.height(1);
|
||||
|
||||
let rows: Vec<Row> = self
|
||||
.items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(filtered_idx, item)| {
|
||||
let original_idx = self.filtered_indices[filtered_idx];
|
||||
let is_selected = self.selected_indices.contains(&original_idx);
|
||||
let checkbox = if is_selected { "[✓]" } else { "[ ]" };
|
||||
|
||||
let speed_mb = f64::from(item.speed) / BYTES_PER_MB;
|
||||
let speed_str = if speed_mb > 0.0 {
|
||||
format!("{speed_mb:.1} MB/s")
|
||||
} else {
|
||||
"-".to_string()
|
||||
};
|
||||
|
||||
let slots_str = format!("{}", item.slots);
|
||||
|
||||
let bitrate_str = match item.bitrate {
|
||||
Some(br) => format!("{br} kbps"),
|
||||
None => "-".to_string(),
|
||||
};
|
||||
|
||||
let cells = vec![
|
||||
Cell::from(checkbox),
|
||||
Cell::from(item.filename.clone()),
|
||||
Cell::from(format_bytes(item.size)),
|
||||
Cell::from(item.username.clone()),
|
||||
Cell::from(speed_str),
|
||||
Cell::from(slots_str),
|
||||
Cell::from(bitrate_str),
|
||||
];
|
||||
|
||||
let style = if is_selected {
|
||||
success_style()
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
|
||||
Row::new(cells).style(style).height(1)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let widths = [
|
||||
Constraint::Length(5),
|
||||
Constraint::Min(30),
|
||||
Constraint::Length(12),
|
||||
Constraint::Length(15),
|
||||
Constraint::Length(12),
|
||||
Constraint::Length(8),
|
||||
Constraint::Length(10),
|
||||
];
|
||||
|
||||
let table = Table::new(rows, widths)
|
||||
.header(header)
|
||||
.block(pane_block(true).title(title))
|
||||
.column_spacing(1)
|
||||
.row_highlight_style(highlight_style())
|
||||
.highlight_symbol(HIGHLIGHT_SYMBOL)
|
||||
.highlight_spacing(HighlightSpacing::Always);
|
||||
|
||||
StatefulWidget::render(
|
||||
table,
|
||||
table_area,
|
||||
frame.buffer_mut(),
|
||||
&mut self.state,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_loading_message(
|
||||
&self,
|
||||
frame: &mut Frame,
|
||||
table_area: ratatui::layout::Rect,
|
||||
) {
|
||||
let spinner = get_spinner_char(self.spinner_state);
|
||||
|
||||
let mut loading_message: Vec<Span> = Vec::new();
|
||||
if self.items.is_empty() && self.search_active {
|
||||
loading_message.extend(vec![
|
||||
Span::raw(spinner),
|
||||
Span::raw(" Searching for '"),
|
||||
Span::styled(self.soulseek_query.clone(), primary_style()),
|
||||
Span::raw(format!(
|
||||
"' [{}/{}s]",
|
||||
self.search_start_time.elapsed().as_secs(),
|
||||
self.search_timeout.as_secs()
|
||||
)),
|
||||
]);
|
||||
} else {
|
||||
loading_message.extend(vec![
|
||||
Span::raw(spinner),
|
||||
Span::raw(" Searching for '"),
|
||||
Span::styled(self.soulseek_query.clone(), primary_style()),
|
||||
Span::raw("'; No results yet"),
|
||||
]);
|
||||
}
|
||||
|
||||
// Center the loading message
|
||||
let vertical = Layout::vertical([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(3),
|
||||
Constraint::Fill(1),
|
||||
])
|
||||
.split(table_area);
|
||||
|
||||
let loading_message_line = Line::from(loading_message);
|
||||
// Calculate responsive width: text + generous padding for borders and spacing, max 80% of screen
|
||||
let text_width =
|
||||
loading_message_line.to_string().chars().count() as u16 + 5; // +10 for borders, padding, and safety margin
|
||||
let max_width = (table_area.width * 80) / 100;
|
||||
let widget_width = text_width.min(max_width);
|
||||
|
||||
let horizontal = Layout::horizontal([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(widget_width),
|
||||
Constraint::Fill(1),
|
||||
])
|
||||
.split(vertical[1]);
|
||||
|
||||
let loading_widget = Paragraph::new(loading_message_line)
|
||||
.style(Style::default())
|
||||
.block(pane_block(false))
|
||||
.alignment(Alignment::Center)
|
||||
.wrap(Wrap { trim: true });
|
||||
|
||||
frame.render_widget(loading_widget, horizontal[1]);
|
||||
}
|
||||
|
||||
fn render_info(&self, frame: &mut Frame, info_area: ratatui::layout::Rect) {
|
||||
let (info_text, title, style) = if self.is_filtering {
|
||||
(
|
||||
format!("Filter: {}", self.filter_query),
|
||||
"Filter",
|
||||
warning_style(),
|
||||
)
|
||||
} else if !self.filter_query.is_empty() {
|
||||
(
|
||||
format!(
|
||||
"Current filter: {} (press / to modify, Esc to clear)",
|
||||
self.filter_query
|
||||
),
|
||||
"Filter",
|
||||
primary_style(),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
format!(
|
||||
"{} file(s) selected for download",
|
||||
self.selected_indices.len()
|
||||
),
|
||||
"Selection",
|
||||
success_style(),
|
||||
)
|
||||
};
|
||||
|
||||
let info_widget = Paragraph::new(info_text)
|
||||
.block(pane_block(false).title(plain_title(title, false)))
|
||||
.style(style);
|
||||
|
||||
frame.render_widget(info_widget, info_area);
|
||||
}
|
||||
|
||||
fn render_controls(&self, frame: &mut Frame, area: ratatui::layout::Rect) {
|
||||
let controls_line = if self.is_filtering {
|
||||
format_shortcuts_styled(&[
|
||||
("Space", "toggle"),
|
||||
("a", "select-all"),
|
||||
("A", "deselect-all"),
|
||||
("Enter", "download"),
|
||||
("Esc", "exit filter"),
|
||||
])
|
||||
} else if self.search_active {
|
||||
format_shortcuts_styled(&[
|
||||
("Space", "toggle"),
|
||||
("a", "select-all"),
|
||||
("A", "deselect-all"),
|
||||
("Enter", "download"),
|
||||
("Esc/q", "cancel"),
|
||||
])
|
||||
} else {
|
||||
format_shortcuts_styled(&[
|
||||
("Space", "toggle"),
|
||||
("a", "select-all"),
|
||||
("A", "deselect-all"),
|
||||
("Enter", "download"),
|
||||
("Esc/q", "cancel"),
|
||||
("/", "filter"),
|
||||
])
|
||||
};
|
||||
|
||||
let controls_widget = Paragraph::new(controls_line)
|
||||
.block(pane_block(false).title(plain_title("Controls", false)));
|
||||
|
||||
frame.render_widget(controls_widget, area);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::FileSelector;
|
||||
|
||||
// Navigating an empty list (e.g. a filter that matched nothing) must not
|
||||
// underflow `len - 1`; it should yield no selection.
|
||||
#[test]
|
||||
fn wrapping_prev_on_empty_list_is_none() {
|
||||
assert_eq!(FileSelector::wrapping_prev(None, 0), None);
|
||||
assert_eq!(FileSelector::wrapping_prev(Some(0), 0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapping_next_on_empty_list_is_none() {
|
||||
assert_eq!(FileSelector::wrapping_next(None, 0), None);
|
||||
assert_eq!(FileSelector::wrapping_next(Some(0), 0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapping_navigation_wraps_around() {
|
||||
assert_eq!(FileSelector::wrapping_prev(Some(0), 3), Some(2));
|
||||
assert_eq!(FileSelector::wrapping_prev(Some(2), 3), Some(1));
|
||||
assert_eq!(FileSelector::wrapping_next(Some(2), 3), Some(0));
|
||||
assert_eq!(FileSelector::wrapping_next(Some(1), 3), Some(2));
|
||||
}
|
||||
}
|
||||
@@ -1,305 +1,20 @@
|
||||
use crate::models::DownloadEntry;
|
||||
use crate::ui::{
|
||||
BYTES_PER_MB, HIGHLIGHT_SYMBOL, accent_style, body_style, dimmed_style,
|
||||
download_status_glyph, error_style, format_bytes_progress,
|
||||
format_progress_bar, format_shortcuts_styled, format_speed, header_style,
|
||||
highlight_style, info_style, pane_block, plain_title, primary_style,
|
||||
success_style, warning_style,
|
||||
BYTES_PER_MB, accent_style, dimmed_style, error_style,
|
||||
format_bytes_progress, format_progress_bar, info_style, pane_block,
|
||||
plain_title, primary_style, success_style, warning_style,
|
||||
};
|
||||
use color_eyre::Result;
|
||||
use ratatui::{
|
||||
DefaultTerminal, Frame,
|
||||
crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, poll},
|
||||
Frame,
|
||||
layout::{Alignment, Constraint, Layout},
|
||||
style::{Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{
|
||||
Cell, HighlightSpacing, Paragraph, Row, StatefulWidget, Table,
|
||||
TableState,
|
||||
},
|
||||
};
|
||||
use soulseek_rs::{Client, DownloadStatus};
|
||||
use std::{
|
||||
sync::{Arc, mpsc, mpsc::Receiver},
|
||||
thread,
|
||||
time::Duration,
|
||||
widgets::Paragraph,
|
||||
};
|
||||
use soulseek_rs::DownloadStatus;
|
||||
|
||||
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
pub struct MultiDownloadProgress {
|
||||
downloads: Vec<DownloadEntry>,
|
||||
receiver_channel:
|
||||
Receiver<(soulseek_rs::types::Download, Receiver<DownloadStatus>)>,
|
||||
list_state: TableState,
|
||||
max_concurrent: usize,
|
||||
active_count: usize,
|
||||
should_exit: bool,
|
||||
queuing_status: String,
|
||||
}
|
||||
|
||||
impl MultiDownloadProgress {
|
||||
pub fn new(
|
||||
receiver_channel: Receiver<(
|
||||
soulseek_rs::types::Download,
|
||||
Receiver<DownloadStatus>,
|
||||
)>,
|
||||
max_concurrent: usize,
|
||||
) -> Self {
|
||||
let mut list_state = TableState::default();
|
||||
list_state.select(Some(0));
|
||||
|
||||
Self {
|
||||
downloads: Vec::new(),
|
||||
receiver_channel,
|
||||
list_state,
|
||||
max_concurrent,
|
||||
active_count: 0,
|
||||
should_exit: false,
|
||||
queuing_status: String::from("Queuing downloads..."),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run(&mut self, mut terminal: DefaultTerminal) -> Result<()> {
|
||||
self.start_next_batch();
|
||||
|
||||
loop {
|
||||
terminal.draw(|frame| self.render(frame))?;
|
||||
|
||||
while let Ok((download, receiver)) =
|
||||
self.receiver_channel.try_recv()
|
||||
{
|
||||
self.downloads.push(DownloadEntry {
|
||||
download,
|
||||
receiver: Some(receiver),
|
||||
});
|
||||
self.queuing_status =
|
||||
format!("{} downloads queued", self.downloads.len());
|
||||
}
|
||||
|
||||
// Poll all receivers for status updates
|
||||
let mut need_start_next = false;
|
||||
for download_entry in &mut self.downloads {
|
||||
if let Some(receiver) = &download_entry.receiver
|
||||
&& let Ok(status) = receiver.try_recv()
|
||||
{
|
||||
let was_active = !download_entry.download.is_finished();
|
||||
download_entry.download.status = status;
|
||||
let is_finished = download_entry.download.is_finished();
|
||||
|
||||
// If download just finished, decrement active count
|
||||
if was_active && is_finished {
|
||||
self.active_count = self.active_count.saturating_sub(1);
|
||||
need_start_next = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if need_start_next {
|
||||
self.start_next_batch();
|
||||
}
|
||||
|
||||
if poll(Duration::from_millis(100))?
|
||||
&& let Event::Key(key) = event::read()?
|
||||
{
|
||||
self.handle_key(key);
|
||||
}
|
||||
|
||||
// Exit when user cancels or all downloads finished
|
||||
if self.should_exit || self.all_finished() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn start_next_batch(&mut self) {
|
||||
for download_entry in &self.downloads {
|
||||
if self.active_count >= self.max_concurrent {
|
||||
break;
|
||||
}
|
||||
|
||||
// Only start if download is queued AND receiver is available
|
||||
if matches!(download_entry.download.status, DownloadStatus::Queued)
|
||||
&& download_entry.receiver.is_some()
|
||||
{
|
||||
self.active_count += 1;
|
||||
// Download will start automatically since receiver exists
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn all_finished(&self) -> bool {
|
||||
self.downloads.iter().all(|d| d.download.is_finished())
|
||||
}
|
||||
|
||||
fn handle_key(&mut self, key: KeyEvent) {
|
||||
if key.kind != KeyEventKind::Press {
|
||||
return;
|
||||
}
|
||||
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => self.select_previous(),
|
||||
KeyCode::Down | KeyCode::Char('j') => self.select_next(),
|
||||
KeyCode::Home | KeyCode::Char('g') => self.select_first(),
|
||||
KeyCode::End | KeyCode::Char('G') => self.select_last(),
|
||||
KeyCode::Esc | KeyCode::Char('q') => {
|
||||
self.should_exit = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
const fn select_previous(&mut self) {
|
||||
let i = match self.list_state.selected() {
|
||||
Some(i) if i > 0 => i - 1,
|
||||
_ => self.downloads.len().saturating_sub(1),
|
||||
};
|
||||
self.list_state.select(Some(i));
|
||||
}
|
||||
|
||||
const fn select_next(&mut self) {
|
||||
let i = match self.list_state.selected() {
|
||||
Some(i) if i < self.downloads.len().saturating_sub(1) => i + 1,
|
||||
_ => 0,
|
||||
};
|
||||
self.list_state.select(Some(i));
|
||||
}
|
||||
|
||||
const fn select_first(&mut self) {
|
||||
self.list_state.select(Some(0));
|
||||
}
|
||||
|
||||
const fn select_last(&mut self) {
|
||||
if !self.downloads.is_empty() {
|
||||
self.list_state.select(Some(self.downloads.len() - 1));
|
||||
}
|
||||
}
|
||||
|
||||
fn render(&mut self, frame: &mut Frame) {
|
||||
let area = frame.area();
|
||||
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(3),
|
||||
Constraint::Min(0),
|
||||
Constraint::Length(3),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
// Top: Overall stats
|
||||
self.render_stats(frame, chunks[0]);
|
||||
|
||||
// Middle: List of downloads
|
||||
self.render_downloads_list(frame, chunks[1]);
|
||||
|
||||
// Bottom: Controls
|
||||
Self::render_controls(frame, chunks[2]);
|
||||
}
|
||||
|
||||
fn render_stats(&self, frame: &mut Frame, area: ratatui::layout::Rect) {
|
||||
render_download_stats(frame, area, &self.downloads, self.active_count);
|
||||
}
|
||||
|
||||
fn render_downloads_list(
|
||||
&mut self,
|
||||
frame: &mut Frame,
|
||||
area: ratatui::layout::Rect,
|
||||
) {
|
||||
let header = Row::new(vec![
|
||||
Cell::from("St"),
|
||||
Cell::from("Filename"),
|
||||
Cell::from("Username"),
|
||||
Cell::from("Size"),
|
||||
Cell::from("Progress"),
|
||||
Cell::from("Speed"),
|
||||
])
|
||||
.style(header_style())
|
||||
.height(1);
|
||||
|
||||
let rows: Vec<Row> = self
|
||||
.downloads
|
||||
.iter()
|
||||
.map(|download_entry| {
|
||||
let download = &download_entry.download;
|
||||
let (status_icon, status_style) =
|
||||
download_status_glyph(&download.status);
|
||||
|
||||
let progress = if download.size > 0 {
|
||||
download.bytes_downloaded() as f64 / download.size as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let percent = (progress * 100.0) as u8;
|
||||
let bar_width = 20;
|
||||
let progress_bar =
|
||||
format_progress_bar(progress, bar_width, percent);
|
||||
|
||||
let size_str = format_bytes_progress(
|
||||
download.bytes_downloaded(),
|
||||
download.size,
|
||||
);
|
||||
|
||||
let speed_str = match download.status {
|
||||
DownloadStatus::InProgress { .. } => {
|
||||
format_speed(download.speed_bytes_per_sec())
|
||||
}
|
||||
_ => "-".to_string(),
|
||||
};
|
||||
|
||||
let cells = 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(size_str).style(warning_style()),
|
||||
Cell::from(progress_bar),
|
||||
Cell::from(speed_str).style(dimmed_style()),
|
||||
];
|
||||
|
||||
Row::new(cells).height(1)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let widths = [
|
||||
Constraint::Length(3),
|
||||
Constraint::Min(30),
|
||||
Constraint::Length(15),
|
||||
Constraint::Length(20),
|
||||
Constraint::Length(28),
|
||||
Constraint::Length(12),
|
||||
];
|
||||
|
||||
let table = Table::new(rows, widths)
|
||||
.header(header)
|
||||
.block(pane_block(true).title(plain_title("Downloads", true)))
|
||||
.column_spacing(1)
|
||||
.row_highlight_style(highlight_style())
|
||||
.highlight_symbol(HIGHLIGHT_SYMBOL)
|
||||
.highlight_spacing(HighlightSpacing::Always);
|
||||
|
||||
StatefulWidget::render(
|
||||
table,
|
||||
area,
|
||||
frame.buffer_mut(),
|
||||
&mut self.list_state,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_controls(frame: &mut Frame, area: ratatui::layout::Rect) {
|
||||
let controls_line = format_shortcuts_styled(&[
|
||||
("↑↓/jk", "scroll"),
|
||||
("Home/End", "jump"),
|
||||
("Esc/q", "cancel all"),
|
||||
]);
|
||||
|
||||
let controls_widget = Paragraph::new(controls_line)
|
||||
.block(pane_block(false).title(plain_title("Controls", false)));
|
||||
|
||||
frame.render_widget(controls_widget, area);
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes transferred, bytes expected and combined speed across the downloads
|
||||
/// that are actually running — or `None` when none are, so the caller can hide
|
||||
/// the progress readout entirely.
|
||||
@@ -432,49 +147,6 @@ pub fn render_download_stats(
|
||||
frame.render_widget(progress_paragraph, chunks[1]);
|
||||
}
|
||||
|
||||
pub fn show_multi_download_progress(
|
||||
terminal: DefaultTerminal,
|
||||
client: Arc<Client>,
|
||||
selected_files: Vec<(String, String, u64)>,
|
||||
download_dir: String,
|
||||
max_concurrent: usize,
|
||||
) -> Result<()> {
|
||||
soulseek_rs::utils::logger::enable_buffering();
|
||||
|
||||
let (tx, rx) = mpsc::channel();
|
||||
|
||||
// Spawn background thread to initialize downloads
|
||||
let init_client = client;
|
||||
thread::spawn(move || {
|
||||
for (filename, username, size) in selected_files {
|
||||
// Initiate download
|
||||
match init_client.download(
|
||||
filename.clone(),
|
||||
username.clone(),
|
||||
size,
|
||||
download_dir.clone(),
|
||||
) {
|
||||
Ok((download, receiver)) => {
|
||||
let _ = tx.send((download, receiver));
|
||||
}
|
||||
Err(e) => {
|
||||
soulseek_rs::warn!(
|
||||
"Failed to start download for {filename}: {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let mut progress = MultiDownloadProgress::new(rx, max_concurrent);
|
||||
let result = progress.run(terminal);
|
||||
ratatui::restore();
|
||||
|
||||
soulseek_rs::utils::logger::flush_buffered_logs();
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{DownloadEntry, DownloadStatus, active_progress};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
mod download_selector;
|
||||
mod downloads;
|
||||
pub mod login;
|
||||
mod main_tui;
|
||||
@@ -6,8 +5,7 @@ mod panes;
|
||||
mod styles;
|
||||
mod utils;
|
||||
|
||||
pub use download_selector::FileSelector;
|
||||
pub use downloads::{render_download_stats, show_multi_download_progress};
|
||||
pub use downloads::render_download_stats;
|
||||
pub use main_tui::launch_main_tui;
|
||||
pub use styles::*;
|
||||
pub use utils::*;
|
||||
|
||||
@@ -30,12 +30,6 @@ pub fn format_speed(speed_bytes_per_sec: f64) -> String {
|
||||
format!("{mb:.1} MB/s")
|
||||
}
|
||||
|
||||
pub fn get_bitrate(
|
||||
attribs: &std::collections::HashMap<u32, u32>,
|
||||
) -> Option<u32> {
|
||||
attribs.get(&0).copied()
|
||||
}
|
||||
|
||||
const SPINNER_CHARS: [&str; 10] =
|
||||
["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user