feat: add torrent rename functionality

This commit is contained in:
Kristofers Solo 2026-01-01 04:09:34 +02:00
parent baab8a1984
commit 33b446d440
Signed by: kristoferssolo
GPG Key ID: 8687F2D3EEE6F0ED
8 changed files with 96 additions and 30 deletions

View File

@ -23,7 +23,9 @@ impl Torrents {
if !to_start.is_empty() { if !to_start.is_empty() {
let ids: Vec<_> = to_start.into_iter().map(|(id, _)| id).collect(); let ids: Vec<_> = to_start.into_iter().map(|(id, _)| id).collect();
self.client.torrent_action(TorrentAction::Start, ids).await?; self.client
.torrent_action(TorrentAction::Start, ids)
.await?;
} }
if !to_stop.is_empty() { if !to_stop.is_empty() {
let ids: Vec<_> = to_stop.into_iter().map(|(id, _)| id).collect(); let ids: Vec<_> = to_stop.into_iter().map(|(id, _)| id).collect();
@ -133,7 +135,11 @@ impl Torrents {
return Ok(()); return Ok(());
}; };
self.client self.client
.torrent_rename_path(vec![id], old_name.clone(), name.to_string_lossy().into_owned()) .torrent_rename_path(
vec![id],
old_name.clone(),
name.to_string_lossy().into_owned(),
)
.await?; .await?;
Ok(()) Ok(())
} }

View File

@ -14,6 +14,15 @@ use std::path::PathBuf;
use types::Selected; use types::Selected;
pub use {tab::Tab, torrent::Torrents}; pub use {tab::Tab, torrent::Torrents};
/// Input mode type for the application.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum InputMode {
#[default]
None,
Move,
Rename,
}
/// Main Application. /// Main Application.
#[derive(Debug)] #[derive(Debug)]
pub struct App { pub struct App {
@ -25,7 +34,7 @@ pub struct App {
pub show_help: bool, pub show_help: bool,
pub config: Config, pub config: Config,
pub input_handler: InputHandler, pub input_handler: InputHandler,
pub input_mode: bool, pub input_mode: InputMode,
} }
impl App { impl App {
@ -41,11 +50,11 @@ impl App {
tabs: vec![Tab::All, Tab::Active, Tab::Downloading], tabs: vec![Tab::All, Tab::Active, Tab::Downloading],
index: 0, index: 0,
state: TableState::default(), state: TableState::default(),
torrents: Torrents::new()?, // Handle the Result here torrents: Torrents::new()?,
show_help: false, show_help: false,
config, config,
input_handler: InputHandler::new(), input_handler: InputHandler::new(),
input_mode: false, input_mode: InputMode::None,
}) })
} }
@ -178,18 +187,49 @@ impl App {
self.torrents self.torrents
.move_torrents(ids, &self.input_handler.text) .move_torrents(ids, &self.input_handler.text)
.await?; .await?;
self.input_handler.clear(); self.clear_input();
self.input_mode = false;
self.close_help();
Ok(()) Ok(())
} }
/// Prepare move action by pre-filling current download directory.
pub fn prepare_move_action(&mut self) { pub fn prepare_move_action(&mut self) {
if let Some(download_dir) = self.get_current_download_dir() { if let Some(download_dir) = self.get_current_download_dir() {
self.input_handler self.input_handler
.set_text(download_dir.to_string_lossy().into_owned()); .set_text(download_dir.to_string_lossy().into_owned());
} }
self.input_mode = true; self.input_mode = InputMode::Move;
}
/// Rename the highlighted torrent.
///
/// # Errors
///
/// Returns an error if the RPC call fails.
pub async fn rename_torrent(&mut self) -> Result<()> {
let Some(torrent) = self.get_current_torrent() else {
self.clear_input();
return Ok(());
};
self.torrents
.rename(&torrent, std::path::Path::new(&self.input_handler.text))
.await?;
self.clear_input();
Ok(())
}
/// Prepare rename action by pre-filling current torrent name.
pub fn prepare_rename_action(&mut self) {
if let Some(name) = self.get_current_torrent_name() {
self.input_handler.set_text(name);
}
self.input_mode = InputMode::Rename;
}
/// Clear input and reset input mode.
fn clear_input(&mut self) {
self.input_handler.clear();
self.input_mode = InputMode::None;
self.close_help();
} }
pub fn select(&mut self) { pub fn select(&mut self) {
@ -222,6 +262,16 @@ impl App {
} }
fn get_current_download_dir(&self) -> Option<PathBuf> { fn get_current_download_dir(&self) -> Option<PathBuf> {
self.get_current_torrent()
.and_then(|t| t.download_dir)
.map(PathBuf::from)
}
fn get_current_torrent_name(&self) -> Option<String> {
self.get_current_torrent().and_then(|t| t.name)
}
fn get_current_torrent(&self) -> Option<transmission_rpc::types::Torrent> {
let Selected::Current(current_id) = self.selected(true) else { let Selected::Current(current_id) = self.selected(true) else {
return None; return None;
}; };
@ -229,7 +279,6 @@ impl App {
.torrents .torrents
.iter() .iter()
.find(|t| t.id == Some(current_id)) .find(|t| t.id == Some(current_id))
.and_then(|t| t.download_dir.as_deref()) .cloned()
.map(PathBuf::from)
} }
} }

View File

@ -75,7 +75,6 @@ impl Torrents {
.torrents; .torrents;
Ok(self) Ok(self)
} }
} }
impl Debug for Torrents { impl Debug for Torrents {

View File

@ -32,4 +32,6 @@ pub struct KeybindsConfig {
pub toggle_help: String, pub toggle_help: String,
#[from_file(default = "m")] #[from_file(default = "m")]
pub move_torrent: String, pub move_torrent: String,
#[from_file(default = "r")]
pub rename_torrent: String,
} }

View File

@ -1,4 +1,4 @@
use crate::app::{App, action::Action}; use crate::app::{App, InputMode, action::Action};
use crate::error::Result; use crate::error::Result;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use thiserror::Error; use thiserror::Error;
@ -29,10 +29,10 @@ async fn handle_input(key_event: KeyEvent, app: &mut App) -> Result<Option<Actio
/// ///
/// # Errors /// # Errors
/// ///
/// TODO: add error types /// Returns an error if input handling fails.
#[tracing::instrument(name = "Getting action", skip(app))] #[tracing::instrument(name = "Getting action", skip(app))]
pub async fn get_action(key_event: KeyEvent, app: &mut App) -> Result<Option<Action>> { pub async fn get_action(key_event: KeyEvent, app: &mut App) -> Result<Option<Action>> {
if app.input_mode { if app.input_mode != InputMode::None {
return handle_input(key_event, app).await; return handle_input(key_event, app).await;
} }
@ -56,6 +56,7 @@ pub async fn get_action(key_event: KeyEvent, app: &mut App) -> Result<Option<Act
(Action::Select, &keybinds.select), (Action::Select, &keybinds.select),
(Action::ToggleHelp, &keybinds.toggle_help), (Action::ToggleHelp, &keybinds.toggle_help),
(Action::Move, &keybinds.move_torrent), (Action::Move, &keybinds.move_torrent),
(Action::Rename, &keybinds.rename_torrent),
] ]
.into_iter() .into_iter()
.find_map(|(action, keybind)| matches_keybind(&key_event, keybind).then_some(action))) .find_map(|(action, keybind)| matches_keybind(&key_event, keybind).then_some(action)))
@ -65,7 +66,7 @@ pub async fn get_action(key_event: KeyEvent, app: &mut App) -> Result<Option<Act
/// ///
/// # Errors /// # Errors
/// ///
/// TODO: add error types /// Returns an error if the action fails.
#[tracing::instrument(name = "Update", skip(app))] #[tracing::instrument(name = "Update", skip(app))]
pub async fn update(app: &mut App, action: Action) -> Result<()> { pub async fn update(app: &mut App, action: Action) -> Result<()> {
info!("updating app with action: {}", action); info!("updating app with action: {}", action);
@ -82,13 +83,17 @@ pub async fn update(app: &mut App, action: Action) -> Result<()> {
Action::PauseAll => app.torrents.stop_all().await?, Action::PauseAll => app.torrents.stop_all().await?,
Action::StartAll => app.torrents.start_all().await?, Action::StartAll => app.torrents.start_all().await?,
Action::Move => app.prepare_move_action(), Action::Move => app.prepare_move_action(),
Action::Rename => app.prepare_rename_action(),
Action::Delete(x) => app.delete(x).await?, Action::Delete(x) => app.delete(x).await?,
Action::Rename => unimplemented!(),
Action::Select => app.select(), Action::Select => app.select(),
Action::Submit => app.move_torrent().await?, Action::Submit => match app.input_mode {
InputMode::Move => app.move_torrent().await?,
InputMode::Rename => app.rename_torrent().await?,
InputMode::None => {}
},
Action::Cancel => { Action::Cancel => {
app.input_handler.clear(); app.input_handler.clear();
app.input_mode = false; app.input_mode = InputMode::None;
} }
} }
Ok(()) Ok(())
@ -162,11 +167,10 @@ fn parse_keybind(key_str: &str) -> std::result::Result<KeyEvent, ParseKeybindErr
// function keys F1...F<N> // function keys F1...F<N>
f if f.starts_with('f') => { f if f.starts_with('f') => {
key_code = Some(KeyCode::F( key_code =
f[1..] Some(KeyCode::F(f[1..].parse().map_err(|_| {
.parse() ParseKeybindError::UnknownPart(part.to_owned())
.map_err(|_| ParseKeybindError::UnknownPart(part.to_owned()))?, })?));
));
} }
// single-character fallback // single-character fallback

View File

@ -1,4 +1,4 @@
use crate::app::App; use crate::app::{App, InputMode};
use ratatui::{ use ratatui::{
prelude::*, prelude::*,
widgets::{Block, Borders, Clear, Paragraph}, widgets::{Block, Borders, Clear, Paragraph},
@ -9,7 +9,13 @@ pub fn render(f: &mut Frame, app: &App) {
let size = f.area(); let size = f.area();
let input_area = Rect::new(size.width / 4, size.height / 2 - 1, size.width / 2, 3); let input_area = Rect::new(size.width / 4, size.height / 2 - 1, size.width / 2, 3);
let block = Block::default().title("Move to").borders(Borders::ALL); let title = match app.input_mode {
InputMode::Move => "Move to",
InputMode::Rename => "Rename",
InputMode::None => return,
};
let block = Block::default().title(title).borders(Borders::ALL);
f.render_widget(Clear, input_area); f.render_widget(Clear, input_area);
f.render_widget(block, input_area); f.render_widget(block, input_area);

View File

@ -3,7 +3,7 @@ mod input;
mod table; mod table;
use crate::{ use crate::{
app::{App, Tab}, app::{App, InputMode, Tab},
config::color::ColorConfig, config::color::ColorConfig,
}; };
use help::render_help; use help::render_help;
@ -58,7 +58,7 @@ pub fn render(app: &mut App, frame: &mut Frame) {
render_help(frame, app); render_help(frame, app);
} }
if app.input_mode { if app.input_mode != InputMode::None {
input::render(frame, app); input::render(frame, app);
} }
} }

View File

@ -1,5 +1,5 @@
use crossterm::event::{KeyCode, KeyEvent}; use crossterm::event::{KeyCode, KeyEvent};
use traxor::{app::App, app::action::Action, config::Config, handler::get_action}; use traxor::{app::App, app::InputMode, app::action::Action, config::Config, handler::get_action};
#[tokio::test] #[tokio::test]
async fn test_get_action_quit() { async fn test_get_action_quit() {
@ -137,7 +137,7 @@ async fn test_get_action_toggle_help() {
async fn test_get_action_input_mode() { async fn test_get_action_input_mode() {
let config = Config::load().unwrap(); let config = Config::load().unwrap();
let mut app = App::new(config).unwrap(); let mut app = App::new(config).unwrap();
app.input_mode = true; app.input_mode = InputMode::Move;
assert_eq!( assert_eq!(
get_action(KeyEvent::from(KeyCode::Enter), &mut app) get_action(KeyEvent::from(KeyCode::Enter), &mut app)
.await .await