feat: settings popup and uploads in the transfers pane
Press 'o' for a settings popup: edit the download folder, add/remove share paths (applied live via a rescan and persisted to config.toml), and force a re-index with 'r' for files changed on disk. The state machine is unit-tested; feedback shows the shared file/folder counts. The downloads pane is now Downloads/Uploads: uploads being served are listed after downloads with an up-arrow marker, live progress, and status; 'x' cancels an in-progress upload. Client::shared_counts exposes the index size for the status line.
This commit is contained in:
@@ -671,6 +671,14 @@ impl Client {
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `(folders, files)` currently shared with peers.
|
||||||
|
#[must_use]
|
||||||
|
pub fn shared_counts(&self) -> (u32, u32) {
|
||||||
|
self.context.read_safe().map_or((0, 0), |ctx| {
|
||||||
|
(ctx.shares.folder_count(), ctx.shares.file_count())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Snapshot of the uploads served this session (active and finished),
|
/// Snapshot of the uploads served this session (active and finished),
|
||||||
/// most recent last.
|
/// most recent last.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use crate::models::{BrowseTabs, FileDisplayData, RoomsState};
|
use crate::models::{BrowseTabs, FileDisplayData, RoomsState, SettingsState};
|
||||||
use ratatui::{layout::Rect, widgets::TableState};
|
use ratatui::{layout::Rect, widgets::TableState};
|
||||||
use soulseek_rs::{DownloadStatus, types::Download};
|
use soulseek_rs::{DownloadStatus, types::Download};
|
||||||
use std::sync::atomic::AtomicBool;
|
use std::sync::atomic::AtomicBool;
|
||||||
@@ -104,6 +104,12 @@ pub struct AppState {
|
|||||||
pub show_rooms: bool,
|
pub show_rooms: bool,
|
||||||
pub rooms_list_table_state: TableState,
|
pub rooms_list_table_state: TableState,
|
||||||
|
|
||||||
|
// Settings popup (download folder + share paths)
|
||||||
|
pub settings: Option<SettingsState>,
|
||||||
|
|
||||||
|
// Uploads we are serving (refreshed from the client every tick)
|
||||||
|
pub uploads: Vec<soulseek_rs::types::UploadInfo>,
|
||||||
|
|
||||||
// Pane areas for mouse interaction
|
// Pane areas for mouse interaction
|
||||||
pub searches_pane_area: Option<Rect>,
|
pub searches_pane_area: Option<Rect>,
|
||||||
pub results_pane_area: Option<Rect>,
|
pub results_pane_area: Option<Rect>,
|
||||||
@@ -160,6 +166,10 @@ impl AppState {
|
|||||||
show_rooms: false,
|
show_rooms: false,
|
||||||
rooms_list_table_state: TableState::default(),
|
rooms_list_table_state: TableState::default(),
|
||||||
|
|
||||||
|
settings: None,
|
||||||
|
|
||||||
|
uploads: Vec::new(),
|
||||||
|
|
||||||
searches_pane_area: None,
|
searches_pane_area: None,
|
||||||
results_pane_area: None,
|
results_pane_area: None,
|
||||||
downloads_pane_area: None,
|
downloads_pane_area: None,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ mod app_state;
|
|||||||
mod browse;
|
mod browse;
|
||||||
mod file_display_data;
|
mod file_display_data;
|
||||||
mod rooms;
|
mod rooms;
|
||||||
|
mod settings;
|
||||||
|
|
||||||
pub use app_state::{
|
pub use app_state::{
|
||||||
AppState, ChatMessage, CommandBarMode, DownloadEntry, FocusedPane,
|
AppState, ChatMessage, CommandBarMode, DownloadEntry, FocusedPane,
|
||||||
@@ -12,3 +13,4 @@ pub use browse::{
|
|||||||
};
|
};
|
||||||
pub use file_display_data::FileDisplayData;
|
pub use file_display_data::FileDisplayData;
|
||||||
pub use rooms::{RoomLine, RoomsState, RoomsView};
|
pub use rooms::{RoomLine, RoomsState, RoomsView};
|
||||||
|
pub use settings::{SettingsAction, SettingsMode, SettingsState};
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
//! State machine for the settings popup (download folder + share paths).
|
||||||
|
//! Pure — no client or terminal — so every transition is unit-testable.
|
||||||
|
|
||||||
|
use ratatui::crossterm::event::{KeyCode, KeyEvent};
|
||||||
|
|
||||||
|
/// Which row is highlighted: 0 = download dir, 1.. = share paths.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum SettingsMode {
|
||||||
|
/// Moving between rows.
|
||||||
|
Navigate,
|
||||||
|
/// Typing a new download directory.
|
||||||
|
EditingDownloadDir,
|
||||||
|
/// Typing a new share path to add.
|
||||||
|
AddingShare,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the TUI should do after a key was handled.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum SettingsAction {
|
||||||
|
None,
|
||||||
|
/// Settings changed: persist config and apply the new share set live.
|
||||||
|
Apply,
|
||||||
|
/// Re-scan the current share paths (files changed on disk).
|
||||||
|
Reindex,
|
||||||
|
/// Close the popup.
|
||||||
|
Close,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SettingsState {
|
||||||
|
pub download_dir: String,
|
||||||
|
pub share_dirs: Vec<String>,
|
||||||
|
/// 0 = download dir row; 1 + i = share path i.
|
||||||
|
pub selected: usize,
|
||||||
|
pub mode: SettingsMode,
|
||||||
|
/// Edit buffer while typing.
|
||||||
|
pub input: String,
|
||||||
|
/// One-line feedback ("Re-indexed: 1234 files in 56 folders").
|
||||||
|
pub status: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SettingsState {
|
||||||
|
#[must_use]
|
||||||
|
pub const fn new(download_dir: String, share_dirs: Vec<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
download_dir,
|
||||||
|
share_dirs,
|
||||||
|
selected: 0,
|
||||||
|
mode: SettingsMode::Navigate,
|
||||||
|
input: String::new(),
|
||||||
|
status: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn rows(&self) -> usize {
|
||||||
|
1 + self.share_dirs.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle_key(&mut self, key: KeyEvent) -> SettingsAction {
|
||||||
|
match self.mode {
|
||||||
|
SettingsMode::Navigate => self.handle_navigate(key),
|
||||||
|
SettingsMode::EditingDownloadDir | SettingsMode::AddingShare => {
|
||||||
|
self.handle_typing(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_navigate(&mut self, key: KeyEvent) -> SettingsAction {
|
||||||
|
self.status = None;
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Esc | KeyCode::Char('q' | 'o') => SettingsAction::Close,
|
||||||
|
KeyCode::Up | KeyCode::Char('k') => {
|
||||||
|
self.selected = self.selected.saturating_sub(1);
|
||||||
|
SettingsAction::None
|
||||||
|
}
|
||||||
|
KeyCode::Down | KeyCode::Char('j') => {
|
||||||
|
self.selected = (self.selected + 1).min(self.rows() - 1);
|
||||||
|
SettingsAction::None
|
||||||
|
}
|
||||||
|
KeyCode::Enter | KeyCode::Char('e') if self.selected == 0 => {
|
||||||
|
self.mode = SettingsMode::EditingDownloadDir;
|
||||||
|
self.input = self.download_dir.clone();
|
||||||
|
SettingsAction::None
|
||||||
|
}
|
||||||
|
KeyCode::Char('a') => {
|
||||||
|
self.mode = SettingsMode::AddingShare;
|
||||||
|
self.input.clear();
|
||||||
|
SettingsAction::None
|
||||||
|
}
|
||||||
|
KeyCode::Char('d') if self.selected > 0 => {
|
||||||
|
self.share_dirs.remove(self.selected - 1);
|
||||||
|
self.selected = self.selected.min(self.rows() - 1);
|
||||||
|
SettingsAction::Apply
|
||||||
|
}
|
||||||
|
KeyCode::Char('r') => SettingsAction::Reindex,
|
||||||
|
_ => SettingsAction::None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_typing(&mut self, key: KeyEvent) -> SettingsAction {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Esc => {
|
||||||
|
self.mode = SettingsMode::Navigate;
|
||||||
|
self.input.clear();
|
||||||
|
SettingsAction::None
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
let value = self.input.trim().to_string();
|
||||||
|
let adding = self.mode == SettingsMode::AddingShare;
|
||||||
|
self.mode = SettingsMode::Navigate;
|
||||||
|
self.input.clear();
|
||||||
|
if value.is_empty() {
|
||||||
|
return SettingsAction::None;
|
||||||
|
}
|
||||||
|
if adding {
|
||||||
|
if self.share_dirs.contains(&value) {
|
||||||
|
return SettingsAction::None;
|
||||||
|
}
|
||||||
|
self.share_dirs.push(value);
|
||||||
|
self.selected = self.rows() - 1;
|
||||||
|
} else {
|
||||||
|
self.download_dir = value;
|
||||||
|
}
|
||||||
|
SettingsAction::Apply
|
||||||
|
}
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
self.input.pop();
|
||||||
|
SettingsAction::None
|
||||||
|
}
|
||||||
|
KeyCode::Char(c) => {
|
||||||
|
self.input.push(c);
|
||||||
|
SettingsAction::None
|
||||||
|
}
|
||||||
|
_ => SettingsAction::None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use ratatui::crossterm::event::KeyModifiers;
|
||||||
|
|
||||||
|
fn key(code: KeyCode) -> KeyEvent {
|
||||||
|
KeyEvent::new(code, KeyModifiers::NONE)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn type_str(state: &mut SettingsState, text: &str) {
|
||||||
|
for c in text.chars() {
|
||||||
|
state.handle_key(key(KeyCode::Char(c)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state() -> SettingsState {
|
||||||
|
SettingsState::new("/dl".into(), vec!["/dl".into(), "/music".into()])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn escape_closes_from_navigation() {
|
||||||
|
assert_eq!(
|
||||||
|
state().handle_key(key(KeyCode::Esc)),
|
||||||
|
SettingsAction::Close
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn adding_a_share_path_applies() {
|
||||||
|
let mut s = state();
|
||||||
|
s.handle_key(key(KeyCode::Char('a')));
|
||||||
|
assert_eq!(s.mode, SettingsMode::AddingShare);
|
||||||
|
type_str(&mut s, "/flacs");
|
||||||
|
let action = s.handle_key(key(KeyCode::Enter));
|
||||||
|
assert_eq!(action, SettingsAction::Apply);
|
||||||
|
assert_eq!(s.share_dirs, vec!["/dl", "/music", "/flacs"]);
|
||||||
|
assert_eq!(s.mode, SettingsMode::Navigate);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn adding_a_duplicate_share_is_a_no_op() {
|
||||||
|
let mut s = state();
|
||||||
|
s.handle_key(key(KeyCode::Char('a')));
|
||||||
|
type_str(&mut s, "/music");
|
||||||
|
assert_eq!(s.handle_key(key(KeyCode::Enter)), SettingsAction::None);
|
||||||
|
assert_eq!(s.share_dirs.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deleting_the_selected_share_applies() {
|
||||||
|
let mut s = state();
|
||||||
|
s.handle_key(key(KeyCode::Down)); // select share 0
|
||||||
|
s.handle_key(key(KeyCode::Down)); // select share 1 (/music)
|
||||||
|
let action = s.handle_key(key(KeyCode::Char('d')));
|
||||||
|
assert_eq!(action, SettingsAction::Apply);
|
||||||
|
assert_eq!(s.share_dirs, vec!["/dl"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_on_the_download_dir_row_does_nothing() {
|
||||||
|
let mut s = state();
|
||||||
|
assert_eq!(s.handle_key(key(KeyCode::Char('d'))), SettingsAction::None);
|
||||||
|
assert_eq!(s.share_dirs.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn editing_the_download_dir_applies() {
|
||||||
|
let mut s = state();
|
||||||
|
s.handle_key(key(KeyCode::Enter));
|
||||||
|
assert_eq!(s.mode, SettingsMode::EditingDownloadDir);
|
||||||
|
assert_eq!(s.input, "/dl");
|
||||||
|
s.handle_key(key(KeyCode::Backspace));
|
||||||
|
s.handle_key(key(KeyCode::Backspace));
|
||||||
|
s.handle_key(key(KeyCode::Backspace));
|
||||||
|
type_str(&mut s, "/new");
|
||||||
|
assert_eq!(s.handle_key(key(KeyCode::Enter)), SettingsAction::Apply);
|
||||||
|
assert_eq!(s.download_dir, "/new");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn escape_cancels_an_edit_without_applying() {
|
||||||
|
let mut s = state();
|
||||||
|
s.handle_key(key(KeyCode::Enter));
|
||||||
|
type_str(&mut s, "junk");
|
||||||
|
assert_eq!(s.handle_key(key(KeyCode::Esc)), SettingsAction::None);
|
||||||
|
assert_eq!(s.download_dir, "/dl");
|
||||||
|
assert_eq!(s.mode, SettingsMode::Navigate);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn r_requests_a_reindex() {
|
||||||
|
assert_eq!(
|
||||||
|
state().handle_key(key(KeyCode::Char('r'))),
|
||||||
|
SettingsAction::Reindex
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selection_is_clamped_to_the_row_count() {
|
||||||
|
let mut s = state();
|
||||||
|
for _ in 0..10 {
|
||||||
|
s.handle_key(key(KeyCode::Down));
|
||||||
|
}
|
||||||
|
assert_eq!(s.selected, 2);
|
||||||
|
for _ in 0..10 {
|
||||||
|
s.handle_key(key(KeyCode::Up));
|
||||||
|
}
|
||||||
|
assert_eq!(s.selected, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -189,6 +189,32 @@ impl MainTui {
|
|||||||
self.state.results_selected_indices.clear();
|
self.state.results_selected_indices.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cancel the selected transfer when it is an upload row (uploads are
|
||||||
|
/// listed after the downloads in the shared pane).
|
||||||
|
pub(super) fn cancel_selected_upload(&self) {
|
||||||
|
let Some(index) = self.state.downloads_table_state.selected() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(upload) = self
|
||||||
|
.state
|
||||||
|
.uploads
|
||||||
|
.get(index.wrapping_sub(self.state.downloads.len()))
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if upload.status == soulseek_rs::types::UploadStatus::InProgress
|
||||||
|
&& !self
|
||||||
|
.client
|
||||||
|
.cancel_upload(&upload.username, &upload.filename)
|
||||||
|
{
|
||||||
|
soulseek_rs::warn!(
|
||||||
|
"No in-progress upload of {} to {} to cancel",
|
||||||
|
upload.filename,
|
||||||
|
upload.username
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn update_downloads(&mut self) {
|
pub(super) fn update_downloads(&mut self) {
|
||||||
if let Some(ref receiver) = self.state.downloads_receiver_channel {
|
if let Some(ref receiver) = self.state.downloads_receiver_channel {
|
||||||
while let Ok((download, download_receiver)) = receiver.try_recv() {
|
while let Ok((download, download_receiver)) = receiver.try_recv() {
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ impl MainTui {
|
|||||||
return self.handle_rooms_input(key);
|
return self.handle_rooms_input(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Settings popup takes over navigation while open.
|
||||||
|
if self.state.settings.is_some() {
|
||||||
|
return self.handle_settings_input(key);
|
||||||
|
}
|
||||||
|
|
||||||
// Filter mode in Results pane
|
// Filter mode in Results pane
|
||||||
if self.state.results_is_filtering
|
if self.state.results_is_filtering
|
||||||
&& self.state.focused_pane == FocusedPane::Results
|
&& self.state.focused_pane == FocusedPane::Results
|
||||||
@@ -81,6 +86,10 @@ impl MainTui {
|
|||||||
self.start_rooms();
|
self.start_rooms();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
KeyCode::Char('o') => {
|
||||||
|
self.open_settings();
|
||||||
|
return;
|
||||||
|
}
|
||||||
KeyCode::Char('b') => {
|
KeyCode::Char('b') => {
|
||||||
// From a highlighted search result, browse its owner directly;
|
// From a highlighted search result, browse its owner directly;
|
||||||
// otherwise prompt for a username.
|
// otherwise prompt for a username.
|
||||||
@@ -361,27 +370,24 @@ impl MainTui {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn handle_downloads_input(&mut self, key: KeyEvent) {
|
fn handle_downloads_input(&mut self, key: KeyEvent) {
|
||||||
|
// The pane lists downloads first, then uploads; navigation spans both.
|
||||||
|
let rows = self.state.downloads.len() + self.state.uploads.len();
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Up | KeyCode::Char('k')
|
KeyCode::Up | KeyCode::Char('k') if rows > 0 => {
|
||||||
if !self.state.downloads.is_empty() =>
|
|
||||||
{
|
|
||||||
let current =
|
let current =
|
||||||
self.state.downloads_table_state.selected().unwrap_or(0);
|
self.state.downloads_table_state.selected().unwrap_or(0);
|
||||||
let new = if current == 0 {
|
let new = if current == 0 { rows - 1 } else { current - 1 };
|
||||||
self.state.downloads.len() - 1
|
|
||||||
} else {
|
|
||||||
current - 1
|
|
||||||
};
|
|
||||||
self.state.downloads_table_state.select(Some(new));
|
self.state.downloads_table_state.select(Some(new));
|
||||||
}
|
}
|
||||||
KeyCode::Down | KeyCode::Char('j')
|
KeyCode::Down | KeyCode::Char('j') if rows > 0 => {
|
||||||
if !self.state.downloads.is_empty() =>
|
|
||||||
{
|
|
||||||
let current =
|
let current =
|
||||||
self.state.downloads_table_state.selected().unwrap_or(0);
|
self.state.downloads_table_state.selected().unwrap_or(0);
|
||||||
let new = (current + 1) % self.state.downloads.len();
|
let new = (current + 1) % rows;
|
||||||
self.state.downloads_table_state.select(Some(new));
|
self.state.downloads_table_state.select(Some(new));
|
||||||
}
|
}
|
||||||
|
KeyCode::Char('x') => {
|
||||||
|
self.cancel_selected_upload();
|
||||||
|
}
|
||||||
KeyCode::Char('p') => {
|
KeyCode::Char('p') => {
|
||||||
self.toggle_selected_download_pause();
|
self.toggle_selected_download_pause();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ mod input;
|
|||||||
mod render;
|
mod render;
|
||||||
mod rooms;
|
mod rooms;
|
||||||
mod search;
|
mod search;
|
||||||
|
mod settings;
|
||||||
|
|
||||||
use crate::models::AppState;
|
use crate::models::AppState;
|
||||||
use crate::persist::{
|
use crate::persist::{
|
||||||
@@ -177,6 +178,9 @@ impl MainTui {
|
|||||||
// Poll for chat-room events
|
// Poll for chat-room events
|
||||||
self.poll_room_events();
|
self.poll_room_events();
|
||||||
|
|
||||||
|
// Refresh the uploads we are serving to peers
|
||||||
|
self.state.uploads = self.client.uploads();
|
||||||
|
|
||||||
self.spinner_state = (self.spinner_state + 1) % 10;
|
self.spinner_state = (self.spinner_state + 1) % 10;
|
||||||
|
|
||||||
self.save_persisted_state();
|
self.save_persisted_state();
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ impl MainTui {
|
|||||||
frame,
|
frame,
|
||||||
downloads_chunks[0],
|
downloads_chunks[0],
|
||||||
&self.state.downloads,
|
&self.state.downloads,
|
||||||
|
&self.state.uploads,
|
||||||
&mut self.state.downloads_table_state,
|
&mut self.state.downloads_table_state,
|
||||||
self.state.focused_pane == FocusedPane::Downloads,
|
self.state.focused_pane == FocusedPane::Downloads,
|
||||||
);
|
);
|
||||||
@@ -170,6 +171,11 @@ impl MainTui {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Settings popup overlays everything when open.
|
||||||
|
if self.state.settings.is_some() {
|
||||||
|
self.render_settings_popup(frame);
|
||||||
|
}
|
||||||
|
|
||||||
// Chat rooms overlay everything when open.
|
// Chat rooms overlay everything when open.
|
||||||
if self.state.show_rooms {
|
if self.state.show_rooms {
|
||||||
let area = centered_rect(85, 80, frame.area());
|
let area = centered_rect(85, 80, frame.area());
|
||||||
@@ -183,6 +189,67 @@ impl MainTui {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render_settings_popup(&self, frame: &mut Frame) {
|
||||||
|
use crate::models::SettingsMode;
|
||||||
|
let Some(settings) = self.state.settings.as_ref() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let area = centered_rect(70, 60, frame.area());
|
||||||
|
frame.render_widget(ratatui::widgets::Clear, area);
|
||||||
|
|
||||||
|
let mut lines: Vec<ratatui::text::Line> = Vec::new();
|
||||||
|
let marker = |selected: bool| if selected { "> " } else { " " };
|
||||||
|
|
||||||
|
let download_line = if settings.mode == SettingsMode::EditingDownloadDir
|
||||||
|
{
|
||||||
|
format!("> Download folder: {}▏", settings.input)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"{}Download folder: {}",
|
||||||
|
marker(settings.selected == 0),
|
||||||
|
settings.download_dir
|
||||||
|
)
|
||||||
|
};
|
||||||
|
lines.push(ratatui::text::Line::from(download_line));
|
||||||
|
lines.push(ratatui::text::Line::from(""));
|
||||||
|
lines.push(ratatui::text::Line::from(format!(
|
||||||
|
"Shared folders ({}):",
|
||||||
|
settings.share_dirs.len()
|
||||||
|
)));
|
||||||
|
for (i, dir) in settings.share_dirs.iter().enumerate() {
|
||||||
|
lines.push(ratatui::text::Line::from(format!(
|
||||||
|
"{}{}",
|
||||||
|
marker(settings.selected == i + 1),
|
||||||
|
dir
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if settings.share_dirs.is_empty() {
|
||||||
|
lines.push(ratatui::text::Line::from(
|
||||||
|
" (nothing shared — press 'a' to add a folder)",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if settings.mode == SettingsMode::AddingShare {
|
||||||
|
lines.push(ratatui::text::Line::from(format!(
|
||||||
|
" Add share: {}▏",
|
||||||
|
settings.input
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if let Some(status) = &settings.status {
|
||||||
|
lines.push(ratatui::text::Line::from(""));
|
||||||
|
lines.push(ratatui::text::Line::from(status.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let block = ratatui::widgets::Block::default()
|
||||||
|
.borders(ratatui::widgets::Borders::ALL)
|
||||||
|
.title(" Settings ");
|
||||||
|
frame.render_widget(
|
||||||
|
ratatui::widgets::Paragraph::new(lines)
|
||||||
|
.block(block)
|
||||||
|
.wrap(ratatui::widgets::Wrap { trim: false }),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn render_messages_popup(&self, frame: &mut Frame) {
|
fn render_messages_popup(&self, frame: &mut Frame) {
|
||||||
let area = centered_rect(70, 60, frame.area());
|
let area = centered_rect(70, 60, frame.area());
|
||||||
|
|
||||||
@@ -273,7 +340,16 @@ impl MainTui {
|
|||||||
"chat".to_string()
|
"chat".to_string()
|
||||||
};
|
};
|
||||||
|
|
||||||
let shortcuts = if self.state.show_rooms {
|
let shortcuts = if self.state.settings.is_some() {
|
||||||
|
vec![
|
||||||
|
("↑↓", "move"),
|
||||||
|
("Enter/e", "edit download dir"),
|
||||||
|
("a", "add share"),
|
||||||
|
("d", "remove share"),
|
||||||
|
("r", "re-index"),
|
||||||
|
("Esc", "close"),
|
||||||
|
]
|
||||||
|
} else if self.state.show_rooms {
|
||||||
self.rooms_shortcuts()
|
self.rooms_shortcuts()
|
||||||
} else if self.state.show_browse {
|
} else if self.state.show_browse {
|
||||||
vec![
|
vec![
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
//! Settings popup behavior: open, key handling, and applying changes
|
||||||
|
//! (persist to config.toml + live share rescan on the client).
|
||||||
|
|
||||||
|
use super::MainTui;
|
||||||
|
use crate::models::{SettingsAction, SettingsState};
|
||||||
|
use ratatui::crossterm::event::KeyEvent;
|
||||||
|
|
||||||
|
impl MainTui {
|
||||||
|
pub(super) fn open_settings(&mut self) {
|
||||||
|
self.state.settings = Some(SettingsState::new(
|
||||||
|
self.download_dir.clone(),
|
||||||
|
self.client.shared_directories(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn handle_settings_input(&mut self, key: KeyEvent) {
|
||||||
|
let Some(settings) = self.state.settings.as_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match settings.handle_key(key) {
|
||||||
|
SettingsAction::None => {}
|
||||||
|
SettingsAction::Close => self.state.settings = None,
|
||||||
|
SettingsAction::Apply => self.apply_settings(),
|
||||||
|
SettingsAction::Reindex => self.reindex_shares(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist the edited settings and apply the share set live.
|
||||||
|
fn apply_settings(&mut self) {
|
||||||
|
let Some(settings) = self.state.settings.as_ref() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let download_dir = settings.download_dir.clone();
|
||||||
|
let share_dirs = settings.share_dirs.clone();
|
||||||
|
|
||||||
|
if let Err(e) = std::fs::create_dir_all(&download_dir) {
|
||||||
|
self.set_settings_status(format!(
|
||||||
|
"Cannot create {download_dir}: {e}"
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.download_dir.clone_from(&download_dir);
|
||||||
|
|
||||||
|
// Validate the share paths (tilde-expand, must exist) and apply.
|
||||||
|
let valid = crate::directories::resolve_shared_directories(&share_dirs);
|
||||||
|
let dropped = share_dirs.len() - valid.len();
|
||||||
|
let mut status = match self.client.set_shared_directories(valid) {
|
||||||
|
Ok(()) if dropped > 0 => {
|
||||||
|
format!("Applied ({dropped} invalid path(s) ignored)")
|
||||||
|
}
|
||||||
|
Ok(()) => format!("Applied · sharing {}", self.share_counts()),
|
||||||
|
Err(e) => format!("Could not apply shares: {e}"),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Persist to config.toml so the change survives a restart.
|
||||||
|
if let Some(path) = crate::persist::paths::config_file() {
|
||||||
|
let result = crate::persist::config::FileConfig::load(&path)
|
||||||
|
.and_then(|mut config| {
|
||||||
|
config.download_dir = Some(download_dir);
|
||||||
|
config.shared_dirs = Some(share_dirs);
|
||||||
|
config.save(&path)
|
||||||
|
});
|
||||||
|
if let Err(e) = result {
|
||||||
|
status = format!("Could not save config: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.set_settings_status(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_settings_status(&mut self, status: String) {
|
||||||
|
if let Some(settings) = self.state.settings.as_mut() {
|
||||||
|
settings.status = Some(status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-scan the current share paths (picks up files changed on disk).
|
||||||
|
fn reindex_shares(&mut self) {
|
||||||
|
let dirs = self.client.shared_directories();
|
||||||
|
let result = self.client.set_shared_directories(dirs);
|
||||||
|
let counts = self.share_counts();
|
||||||
|
if let Some(settings) = self.state.settings.as_mut() {
|
||||||
|
settings.status = Some(match result {
|
||||||
|
Ok(()) => format!("Re-indexed · sharing {counts}"),
|
||||||
|
Err(e) => format!("Re-index failed: {e}"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn share_counts(&self) -> String {
|
||||||
|
let (folders, files) = self.client.shared_counts();
|
||||||
|
format!("{files} files in {folders} folders")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,23 +13,25 @@ use ratatui::{
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
use soulseek_rs::DownloadStatus;
|
use soulseek_rs::DownloadStatus;
|
||||||
|
use soulseek_rs::types::{UploadInfo, UploadStatus};
|
||||||
|
|
||||||
pub fn render_downloads_pane(
|
pub fn render_downloads_pane(
|
||||||
frame: &mut Frame,
|
frame: &mut Frame,
|
||||||
area: Rect,
|
area: Rect,
|
||||||
downloads: &[DownloadEntry],
|
downloads: &[DownloadEntry],
|
||||||
|
uploads: &[UploadInfo],
|
||||||
table_state: &mut TableState,
|
table_state: &mut TableState,
|
||||||
focused: bool,
|
focused: bool,
|
||||||
) {
|
) {
|
||||||
if downloads.is_empty() {
|
if downloads.is_empty() && uploads.is_empty() {
|
||||||
let empty_block = Block::default()
|
let empty_block = Block::default()
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.border_style(border_style(focused))
|
.border_style(border_style(focused))
|
||||||
.border_type(border_type(focused))
|
.border_type(border_type(focused))
|
||||||
.title("[3] Downloads");
|
.title("[3] Downloads/Uploads");
|
||||||
|
|
||||||
let paragraph = Paragraph::new(
|
let paragraph = Paragraph::new(
|
||||||
"No downloads. Select files from Results and press Enter.",
|
"No transfers. Select files from Results and press Enter.",
|
||||||
)
|
)
|
||||||
.block(empty_block);
|
.block(empty_block);
|
||||||
frame.render_widget(paragraph, area);
|
frame.render_widget(paragraph, area);
|
||||||
@@ -45,7 +47,7 @@ pub fn render_downloads_pane(
|
|||||||
])
|
])
|
||||||
.height(1);
|
.height(1);
|
||||||
|
|
||||||
let rows: Vec<Row> = downloads
|
let mut rows: Vec<Row> = downloads
|
||||||
.iter()
|
.iter()
|
||||||
.map(|download_entry| {
|
.map(|download_entry| {
|
||||||
let download = &download_entry.download;
|
let download = &download_entry.download;
|
||||||
@@ -112,6 +114,46 @@ pub fn render_downloads_pane(
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
rows.extend(uploads.iter().map(|upload| {
|
||||||
|
let (status_icon, status_style) = match &upload.status {
|
||||||
|
UploadStatus::InProgress => ("⧗", warning_style()),
|
||||||
|
UploadStatus::Completed => ("✓", success_style()),
|
||||||
|
UploadStatus::Cancelled => ("✗", inactive_style()),
|
||||||
|
UploadStatus::Failed(_) => ("✗", error_style()),
|
||||||
|
};
|
||||||
|
let progress_text = match &upload.status {
|
||||||
|
UploadStatus::InProgress => {
|
||||||
|
let percent = if upload.size > 0 {
|
||||||
|
(upload.bytes_sent as f64 / upload.size as f64 * 100.0)
|
||||||
|
as u8
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
"{}/{} ({}%)",
|
||||||
|
format_bytes(upload.bytes_sent),
|
||||||
|
format_bytes(upload.size),
|
||||||
|
percent
|
||||||
|
)
|
||||||
|
}
|
||||||
|
UploadStatus::Completed => format_bytes(upload.size),
|
||||||
|
UploadStatus::Cancelled => "Cancelled".to_string(),
|
||||||
|
UploadStatus::Failed(_) => "Failed".to_string(),
|
||||||
|
};
|
||||||
|
let basename = upload
|
||||||
|
.filename
|
||||||
|
.rsplit(['\\', '/'])
|
||||||
|
.next()
|
||||||
|
.unwrap_or(&upload.filename);
|
||||||
|
Row::new(vec![
|
||||||
|
Cell::from(format!("↑ {status_icon}")).style(status_style),
|
||||||
|
Cell::from(basename.to_string()),
|
||||||
|
Cell::from(upload.username.clone()),
|
||||||
|
Cell::from(progress_text),
|
||||||
|
Cell::from(String::new()),
|
||||||
|
])
|
||||||
|
}));
|
||||||
|
|
||||||
let widths = [
|
let widths = [
|
||||||
ratatui::layout::Constraint::Length(8),
|
ratatui::layout::Constraint::Length(8),
|
||||||
ratatui::layout::Constraint::Fill(2),
|
ratatui::layout::Constraint::Fill(2),
|
||||||
@@ -130,7 +172,7 @@ pub fn render_downloads_pane(
|
|||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.border_style(border_style(focused))
|
.border_style(border_style(focused))
|
||||||
.border_type(border_type(focused))
|
.border_type(border_type(focused))
|
||||||
.title("[3] Downloads"),
|
.title("[3] Downloads/Uploads"),
|
||||||
);
|
);
|
||||||
|
|
||||||
frame.render_stateful_widget(table, area, table_state);
|
frame.render_stateful_widget(table, area, table_state);
|
||||||
|
|||||||
Reference in New Issue
Block a user