From 60896619d7504c532416c3e5e103a7a4f701eec3 Mon Sep 17 00:00:00 2001 From: michel Date: Sun, 19 Jul 2026 20:38:34 +0200 Subject: [PATCH] =?UTF-8?q?feat(tui):=20tabbed=20browsing=20=E2=80=94=20br?= =?UTF-8?q?owse=20several=20users=20at=20once?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the single browse view with BrowseTabs (one tab per user), mirroring the chat-room tabs. Browsing a new user (from search results or a chat room's member list) opens or focuses their tab; Tab/Shift-Tab switch between open users, 'w' closes the active tab, Esc hides the popup while keeping the tabs. A tab bar shows the open users when more than one is open, and each tab polls/loads independently. Re-browsing a timed-out user retries. BrowseTabs is unit-tested. --- soulseek-rs/src/models/app_state.rs | 8 +- soulseek-rs/src/models/browse.rs | 118 ++++++++++++++++++++++++ soulseek-rs/src/models/mod.rs | 4 +- soulseek-rs/src/ui/main_tui.rs | 109 ++++++++++++++-------- soulseek-rs/src/ui/panes/browse_pane.rs | 56 +++++++++-- 5 files changed, 246 insertions(+), 49 deletions(-) diff --git a/soulseek-rs/src/models/app_state.rs b/soulseek-rs/src/models/app_state.rs index 47da45b..8eec704 100644 --- a/soulseek-rs/src/models/app_state.rs +++ b/soulseek-rs/src/models/app_state.rs @@ -1,4 +1,4 @@ -use crate::models::{BrowseState, FileDisplayData, RoomsState}; +use crate::models::{BrowseTabs, FileDisplayData, RoomsState}; use ratatui::{layout::Rect, widgets::TableState}; use soulseek_rs::{DownloadStatus, types::Download}; use std::sync::atomic::AtomicBool; @@ -94,8 +94,8 @@ pub struct AppState { /// Incoming private messages received while the inbox was closed. pub unread_messages: usize, - // Browse a user's shared files - pub browse: Option, + // Browse users' shared files (one tab per user) + pub browse: BrowseTabs, pub show_browse: bool, pub browse_table_state: TableState, @@ -151,7 +151,7 @@ impl AppState { show_messages: false, unread_messages: 0, - browse: None, + browse: BrowseTabs::new(), show_browse: false, browse_table_state: TableState::default(), diff --git a/soulseek-rs/src/models/browse.rs b/soulseek-rs/src/models/browse.rs index 5075d73..e4726d2 100644 --- a/soulseek-rs/src/models/browse.rs +++ b/soulseek-rs/src/models/browse.rs @@ -319,6 +319,82 @@ impl BrowseState { } } +/// A set of open browse views (tabs), one per user, like the chat-room tabs. +#[derive(Default)] +pub struct BrowseTabs { + pub tabs: Vec, + pub active: usize, +} + +impl BrowseTabs { + #[must_use] + pub const fn new() -> Self { + Self { + tabs: Vec::new(), + active: 0, + } + } + + #[must_use] + pub const fn is_empty(&self) -> bool { + self.tabs.is_empty() + } + + #[must_use] + pub fn active_tab(&self) -> Option<&BrowseState> { + self.tabs.get(self.active) + } + + #[must_use] + pub fn active_tab_mut(&mut self) -> Option<&mut BrowseState> { + self.tabs.get_mut(self.active) + } + + /// Focus the tab for `username`, or open a fresh loading one. Returns + /// `true` if the caller should (re)issue a browse request — i.e. the tab is + /// new or was retried after a timeout. + pub fn open(&mut self, username: &str) -> bool { + if let Some(idx) = self.tabs.iter().position(|t| t.username == username) + { + self.active = idx; + // Re-request only if the previous attempt gave up. + if self.tabs[idx].status == BrowseStatus::TimedOut { + self.tabs[idx] = BrowseState::loading(username.to_string()); + true + } else { + false + } + } else { + self.tabs.push(BrowseState::loading(username.to_string())); + self.active = self.tabs.len() - 1; + true + } + } + + /// Close the active tab, returning `false` when no tabs remain. + pub fn close_active(&mut self) -> bool { + if self.active < self.tabs.len() { + self.tabs.remove(self.active); + } + if self.active >= self.tabs.len() { + self.active = self.tabs.len().saturating_sub(1); + } + !self.tabs.is_empty() + } + + pub const fn next_tab(&mut self) { + if !self.tabs.is_empty() { + self.active = (self.active + 1) % self.tabs.len(); + } + } + + pub const fn prev_tab(&mut self) { + if !self.tabs.is_empty() { + self.active = (self.active + self.tabs.len() - 1) % self.tabs.len(); + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -427,6 +503,48 @@ mod tests { assert_eq!(all.len(), 3); } + #[test] + fn browse_tabs_open_focus_close_and_cycle() { + let mut tabs = BrowseTabs::new(); + assert!(tabs.is_empty()); + + // New users open new tabs and request a browse. + assert!(tabs.open("alice")); + assert!(tabs.open("bob")); + assert_eq!(tabs.tabs.len(), 2); + assert_eq!(tabs.active, 1); + assert_eq!(tabs.active_tab().unwrap().username, "bob"); + + // Re-opening a live tab just focuses it (no new request). + assert!(!tabs.open("alice")); + assert_eq!(tabs.active, 0); + assert_eq!(tabs.tabs.len(), 2); + + // Cycling wraps. + tabs.next_tab(); + assert_eq!(tabs.active, 1); + tabs.next_tab(); + assert_eq!(tabs.active, 0); + tabs.prev_tab(); + assert_eq!(tabs.active, 1); + + // Closing the active tab returns whether any remain. + assert!(tabs.close_active()); + assert_eq!(tabs.tabs.len(), 1); + assert!(!tabs.close_active()); + assert!(tabs.is_empty()); + } + + #[test] + fn browse_tabs_reopen_after_timeout_requests_again() { + let mut tabs = BrowseTabs::new(); + tabs.open("alice"); + tabs.active_tab_mut().unwrap().status = BrowseStatus::TimedOut; + // Re-opening a timed-out tab retries (returns true) and resets it. + assert!(tabs.open("alice")); + assert_eq!(tabs.active_tab().unwrap().status, BrowseStatus::Loading); + } + #[test] fn browse_state_load_auto_expands_top_level() { let mut state = BrowseState::loading("bob".to_string()); diff --git a/soulseek-rs/src/models/mod.rs b/soulseek-rs/src/models/mod.rs index 737be9b..44c74d0 100644 --- a/soulseek-rs/src/models/mod.rs +++ b/soulseek-rs/src/models/mod.rs @@ -7,6 +7,8 @@ pub use app_state::{ AppState, ChatMessage, CommandBarMode, DownloadEntry, FocusedPane, MessageDirection, SearchEntry, SearchStatus, }; -pub use browse::{BrowseState, BrowseStatus, files_under, find_node}; +pub use browse::{ + BrowseState, BrowseStatus, BrowseTabs, files_under, find_node, +}; pub use file_display_data::FileDisplayData; pub use rooms::{RoomLine, RoomsState, RoomsView}; diff --git a/soulseek-rs/src/ui/main_tui.rs b/soulseek-rs/src/ui/main_tui.rs index 44df1bb..d982d01 100644 --- a/soulseek-rs/src/ui/main_tui.rs +++ b/soulseek-rs/src/ui/main_tui.rs @@ -1,7 +1,7 @@ use crate::models::{ - AppState, BrowseState, BrowseStatus, ChatMessage, CommandBarMode, - DownloadEntry, FileDisplayData, FocusedPane, MessageDirection, RoomsView, - SearchEntry, SearchStatus, files_under, find_node, + AppState, BrowseStatus, ChatMessage, CommandBarMode, DownloadEntry, + FileDisplayData, FocusedPane, MessageDirection, RoomsView, SearchEntry, + SearchStatus, files_under, find_node, }; use crate::ui::panes::{ ResultsPaneParams, render_browse_pane, render_download_info_pane, @@ -264,15 +264,13 @@ impl MainTui { } // Browse tree overlays everything when open. - if self.state.show_browse - && let Some(browse) = self.state.browse.as_ref() - { + if self.state.show_browse && !self.state.browse.is_empty() { let area = centered_rect(80, 80, frame.area()); frame.render_widget(ratatui::widgets::Clear, area); render_browse_pane( frame, area, - browse, + &self.state.browse, &mut self.state.browse_table_state, self.spinner_state, ); @@ -389,7 +387,9 @@ impl MainTui { ("→←", "expand/collapse"), ("Enter", "open/download"), ("d", "download folder"), - ("Esc", "close"), + ("Tab", "switch user"), + ("w", "close tab"), + ("Esc", "hide"), ] } else if self.state.command_bar_active { match self.state.command_bar_mode { @@ -612,9 +612,31 @@ impl MainTui { return; } + // Tab management: switch between browsed users or close the active tab. + match key.code { + KeyCode::Tab => { + self.state.browse.next_tab(); + self.sync_browse_selection(); + return; + } + KeyCode::BackTab => { + self.state.browse.prev_tab(); + self.sync_browse_selection(); + return; + } + KeyCode::Char('w') => { + if !self.state.browse.close_active() { + self.state.show_browse = false; + } + self.sync_browse_selection(); + return; + } + _ => {} + } + // Snapshot the flattened rows + current selection, then drop the borrow. let (rows, sel, row) = { - let Some(browse) = self.state.browse.as_ref() else { + let Some(browse) = self.state.browse.active_tab() else { self.state.show_browse = false; return; }; @@ -652,7 +674,7 @@ impl MainTui { } // Navigation and expand/collapse mutate the browse state. - if let Some(browse) = self.state.browse.as_mut() { + if let Some(browse) = self.state.browse.active_tab_mut() { match key.code { KeyCode::Up | KeyCode::Char('k') => { browse.selected_row = sel.saturating_sub(1); @@ -692,24 +714,29 @@ impl MainTui { browse.selected_row.min(new_len.saturating_sub(1)); } - let selected = self.state.browse.as_ref().map(|b| b.selected_row); + self.sync_browse_selection(); + } + + /// Point the browse table cursor at the active tab's selected row. + fn sync_browse_selection(&mut self) { + let selected = self.state.browse.active_tab().map(|b| b.selected_row); self.state.browse_table_state.select(selected); } - /// Files (`path`, `size`) under the browse-tree folder at `path`. + /// Files (`path`, `size`) under the active browse tab's folder at `path`. fn browse_folder_files(&self, path: &str) -> Vec<(String, u64)> { self.state .browse - .as_ref() + .active_tab() .and_then(|b| find_node(&b.tree, path)) .map(files_under) .unwrap_or_default() } - /// Queue downloads of `files` (path, size) from the currently-browsed user. + /// Queue downloads of `files` (path, size) from the active browse tab's user. fn queue_browse_files(&mut self, files: Vec<(String, u64)>) { let Some(username) = - self.state.browse.as_ref().map(|b| b.username.clone()) + self.state.browse.active_tab().map(|b| b.username.clone()) else { return; }; @@ -1527,16 +1554,19 @@ impl MainTui { } } - /// Request a user's shared files and open the browse popup. + /// Request a user's shared files and open (or focus) their browse tab. fn start_browse(&mut self, username: String) { let username = username.trim().to_string(); if username.is_empty() { return; } - let _ = self.client.browse_user(&username); - self.state.browse = Some(BrowseState::loading(username)); + // Open/focus the tab; only (re)issue the request when it's new or a + // previous attempt timed out. + if self.state.browse.open(&username) { + let _ = self.client.browse_user(&username); + } self.state.show_browse = true; - self.state.browse_table_state.select(Some(0)); + self.sync_browse_selection(); } /// The username of the highlighted search result (filter-aware). @@ -1550,28 +1580,33 @@ impl MainTui { items.get(selected).map(|f| f.username.clone()) } - /// Drain a browse response into the browse state, or time it out. + /// Drain browse responses into any loading tabs, or time them out. fn poll_browse_result(&mut self) { - let Some((username, status, requested_at)) = self + // Which loading tabs are waiting, and for whom. + let loading: Vec<(usize, String, std::time::Instant)> = self .state .browse - .as_ref() - .map(|b| (b.username.clone(), b.status, b.requested_at)) - else { - return; - }; - if status != BrowseStatus::Loading { - return; - } - if let Some(directories) = self.client.take_browse_result(&username) { - if let Some(browse) = self.state.browse.as_mut() { - browse.load(&directories); + .tabs + .iter() + .enumerate() + .filter(|(_, b)| b.status == BrowseStatus::Loading) + .map(|(i, b)| (i, b.username.clone(), b.requested_at)) + .collect(); + + for (idx, username, requested_at) in loading { + if let Some(directories) = self.client.take_browse_result(&username) + { + if let Some(browse) = self.state.browse.tabs.get_mut(idx) { + browse.load(&directories); + } + if idx == self.state.browse.active { + self.state.browse_table_state.select(Some(0)); + } + } else if requested_at.elapsed() > BROWSE_TIMEOUT + && let Some(browse) = self.state.browse.tabs.get_mut(idx) + { + browse.status = BrowseStatus::TimedOut; } - self.state.browse_table_state.select(Some(0)); - } else if requested_at.elapsed() > BROWSE_TIMEOUT - && let Some(browse) = self.state.browse.as_mut() - { - browse.status = BrowseStatus::TimedOut; } } diff --git a/soulseek-rs/src/ui/panes/browse_pane.rs b/soulseek-rs/src/ui/panes/browse_pane.rs index 2dd05f6..4230ca0 100644 --- a/soulseek-rs/src/ui/panes/browse_pane.rs +++ b/soulseek-rs/src/ui/panes/browse_pane.rs @@ -1,20 +1,62 @@ -use crate::models::{BrowseState, BrowseStatus}; +use crate::models::{BrowseState, BrowseStatus, BrowseTabs}; use crate::ui::{ - HIGHLIGHT_SYMBOL, border_style, border_type, error_style, format_bytes, - get_spinner_char, highlight_style, primary_style, + HIGHLIGHT_SYMBOL, border_style, border_type, dimmed_style, error_style, + format_bytes, get_spinner_char, highlight_style, primary_style, }; use ratatui::{ Frame, - layout::{Constraint, Rect}, - text::Line, + layout::{Constraint, Layout, Rect}, + style::Modifier, + text::{Line, Span}, widgets::{ Block, Borders, Cell, HighlightSpacing, Paragraph, Row, Table, TableState, }, }; -/// Render the browse popup: a collapsible tree of a peer's shared files. +/// 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. pub fn render_browse_pane( + frame: &mut Frame, + area: Rect, + tabs: &BrowseTabs, + table_state: &mut TableState, + spinner_state: usize, +) { + let Some(active) = tabs.active_tab() else { + return; + }; + + // With multiple users open, reserve the top row for a tab bar. + let tree_area = if tabs.tabs.len() > 1 { + let chunks = + Layout::vertical([Constraint::Length(1), Constraint::Fill(1)]) + .split(area); + render_browse_tabs(frame, chunks[0], tabs); + chunks[1] + } else { + area + }; + + render_browse_one(frame, tree_area, active, table_state, spinner_state); +} + +fn render_browse_tabs(frame: &mut Frame, area: Rect, tabs: &BrowseTabs) { + let mut spans: Vec = Vec::new(); + for (i, tab) in tabs.tabs.iter().enumerate() { + let style = if i == tabs.active { + highlight_style().add_modifier(Modifier::BOLD) + } else { + dimmed_style() + }; + spans.push(Span::styled(format!(" {} ", tab.username), style)); + spans.push(Span::raw(" ")); + } + frame.render_widget(Paragraph::new(Line::from(spans)), area); +} + +/// Render one browsed user's shared-file tree. +fn render_browse_one( frame: &mut Frame, area: Rect, browse: &BrowseState, @@ -23,7 +65,7 @@ pub fn render_browse_pane( ) { let title = match browse.status { BrowseStatus::Loaded => format!( - " Browse {} — {} files, {} folders (Enter/d: download, Esc: close) ", + " Browse {} — {} files, {} folders (Enter/d: download, Tab: user, w: close, Esc: hide) ", browse.username, browse.file_count, browse.folder_count ), _ => format!(" Browse {} ", browse.username),