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:
@@ -189,6 +189,32 @@ impl MainTui {
|
||||
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) {
|
||||
if let Some(ref receiver) = self.state.downloads_receiver_channel {
|
||||
while let Ok((download, download_receiver)) = receiver.try_recv() {
|
||||
|
||||
@@ -29,6 +29,11 @@ impl MainTui {
|
||||
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
|
||||
if self.state.results_is_filtering
|
||||
&& self.state.focused_pane == FocusedPane::Results
|
||||
@@ -81,6 +86,10 @@ impl MainTui {
|
||||
self.start_rooms();
|
||||
return;
|
||||
}
|
||||
KeyCode::Char('o') => {
|
||||
self.open_settings();
|
||||
return;
|
||||
}
|
||||
KeyCode::Char('b') => {
|
||||
// From a highlighted search result, browse its owner directly;
|
||||
// otherwise prompt for a username.
|
||||
@@ -361,27 +370,24 @@ impl MainTui {
|
||||
}
|
||||
|
||||
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 {
|
||||
KeyCode::Up | KeyCode::Char('k')
|
||||
if !self.state.downloads.is_empty() =>
|
||||
{
|
||||
KeyCode::Up | KeyCode::Char('k') if rows > 0 => {
|
||||
let current =
|
||||
self.state.downloads_table_state.selected().unwrap_or(0);
|
||||
let new = if current == 0 {
|
||||
self.state.downloads.len() - 1
|
||||
} else {
|
||||
current - 1
|
||||
};
|
||||
let new = if current == 0 { rows - 1 } else { current - 1 };
|
||||
self.state.downloads_table_state.select(Some(new));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j')
|
||||
if !self.state.downloads.is_empty() =>
|
||||
{
|
||||
KeyCode::Down | KeyCode::Char('j') if rows > 0 => {
|
||||
let current =
|
||||
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));
|
||||
}
|
||||
KeyCode::Char('x') => {
|
||||
self.cancel_selected_upload();
|
||||
}
|
||||
KeyCode::Char('p') => {
|
||||
self.toggle_selected_download_pause();
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ mod input;
|
||||
mod render;
|
||||
mod rooms;
|
||||
mod search;
|
||||
mod settings;
|
||||
|
||||
use crate::models::AppState;
|
||||
use crate::persist::{
|
||||
@@ -177,6 +178,9 @@ impl MainTui {
|
||||
// Poll for chat-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.save_persisted_state();
|
||||
|
||||
@@ -134,6 +134,7 @@ impl MainTui {
|
||||
frame,
|
||||
downloads_chunks[0],
|
||||
&self.state.downloads,
|
||||
&self.state.uploads,
|
||||
&mut self.state.downloads_table_state,
|
||||
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.
|
||||
if self.state.show_rooms {
|
||||
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) {
|
||||
let area = centered_rect(70, 60, frame.area());
|
||||
|
||||
@@ -273,7 +340,16 @@ impl MainTui {
|
||||
"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()
|
||||
} else if self.state.show_browse {
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user