Compare commits

..

No commits in common. "be542551f3130f156e05538beaabbc147d7e6140" and "7402d6ff1d8783fba4df7f10752052d1c42a5552" have entirely different histories.

13 changed files with 181 additions and 234 deletions

29
Cargo.lock generated
View File

@ -199,9 +199,9 @@ dependencies = [
[[package]]
name = "convert_case"
version = "0.10.0"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7"
dependencies = [
"unicode-segmentation",
]
@ -316,23 +316,22 @@ dependencies = [
[[package]]
name = "derive_more"
version = "2.1.0"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10b768e943bed7bf2cab53df09f4bc34bfd217cdb57d971e769874c9a6710618"
checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678"
dependencies = [
"derive_more-impl",
]
[[package]]
name = "derive_more-impl"
version = "2.1.0"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d286bfdaf75e988b4a78e013ecd79c581e06399ab53fbacd2d916c2f904f30b"
checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3"
dependencies = [
"convert_case",
"proc-macro2",
"quote",
"rustc_version",
"syn",
"unicode-xid",
]
@ -1415,15 +1414,6 @@ version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]]
name = "rustc_version"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"semver",
]
[[package]]
name = "rustix"
version = "0.38.44"
@ -1503,12 +1493,6 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "semver"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
[[package]]
name = "serde"
version = "1.0.219"
@ -2089,6 +2073,7 @@ dependencies = [
"tracing",
"tracing-appender",
"tracing-bunyan-formatter",
"tracing-log 0.2.0",
"tracing-subscriber",
"transmission-rpc",
"url",

View File

@ -9,7 +9,7 @@ edition = "2024"
filecaster = { version = "0.2", features = ["derive", "merge"] }
color-eyre = "0.6"
crossterm = "0.29"
derive_more = { version = "2.1", features = ["display"] }
derive_more = { version = "2.0", features = ["display"] }
dirs = "6.0"
merge = "0.2"
ratatui = { version = "0.29" }
@ -20,6 +20,7 @@ toml = "0.9"
tracing = "0.1"
tracing-appender = "0.2"
tracing-bunyan-formatter = { version = "0.3", default-features = false }
tracing-log = "0.2.0"
tracing-subscriber = { version = "0.3", features = ["registry", "env-filter"] }
transmission-rpc = "0.5"
url = "2.5"

View File

@ -1,5 +1,5 @@
use super::{Torrents, types::Selected};
use crate::error::Result;
use color_eyre::{Result, eyre::eyre};
use std::{collections::HashSet, path::Path};
use transmission_rpc::types::{Torrent, TorrentAction, TorrentStatus};
@ -9,11 +9,11 @@ impl Torrents {
/// TODO: add error types
pub async fn toggle(&mut self, ids: Selected) -> Result<()> {
let ids: HashSet<_> = ids.into();
let torrents_to_toggle = self
let torrents_to_toggle: Vec<_> = self
.torrents
.iter()
.filter(|torrent| torrent.id.is_some_and(|id| ids.contains(&id)))
.collect::<Vec<_>>();
.collect();
for torrent in torrents_to_toggle {
let action = match torrent.status {
@ -21,7 +21,10 @@ impl Torrents {
_ => TorrentAction::Stop,
};
if let Some(id) = torrent.id() {
self.client.torrent_action(action, vec![id]).await?;
self.client
.torrent_action(action, vec![id])
.await
.map_err(|e| eyre!("Transmission RPC error: {}", e.to_string()))?;
}
}
Ok(())
@ -48,7 +51,10 @@ impl Torrents {
.collect();
for (id, action) in torrents_to_toggle {
self.client.torrent_action(action, vec![id]).await?;
self.client
.torrent_action(action, vec![id])
.await
.map_err(|e| eyre!("Transmission RPC error: {}", e.to_string()))?;
}
Ok(())
}
@ -79,7 +85,8 @@ impl Torrents {
if let Some(id) = torrent.id() {
self.client
.torrent_set_location(vec![id], location.to_string_lossy().into(), move_from)
.await?;
.await
.map_err(|e| eyre!("Transmission RPC error: {}", e.to_string()))?;
}
Ok(())
}
@ -90,7 +97,8 @@ impl Torrents {
pub async fn delete(&mut self, ids: Selected, delete_local_data: bool) -> Result<()> {
self.client
.torrent_remove(ids.into(), delete_local_data)
.await?;
.await
.map_err(|e| eyre!("Transmission RPC error: {}", e.to_string()))?;
Ok(())
}
@ -101,7 +109,8 @@ impl Torrents {
if let (Some(id), Some(old_name)) = (torrent.id(), torrent.name.clone()) {
self.client
.torrent_rename_path(vec![id], old_name, name.to_string_lossy().into())
.await?;
.await
.map_err(|e| eyre!("Transmission RPC error: {}", e.to_string()))?;
}
Ok(())
}
@ -116,7 +125,10 @@ impl Torrents {
.filter_map(Torrent::id)
.collect::<Vec<_>>();
self.client.torrent_action(action, ids).await?;
self.client
.torrent_action(action, ids)
.await
.map_err(|e| eyre!("Transmission RPC error: {}", e.to_string()))?;
Ok(())
}
}

View File

@ -1,7 +0,0 @@
pub const DEFAULT_TICK_RATE_MS: u64 = 250;
pub const TORRENT_UPDATE_INTERVAL_SECS: u64 = 2;
pub const DEFAULT_RPC_URL: &str = "http://localhost:9091/transmission/rpc";
pub const HELP_POPUP_HEIGHT: u16 = 15;
pub const INPUT_WIDTH_DIVISOR: u16 = 4;
pub const TAB_COUNT: usize = 3;

View File

@ -1,102 +0,0 @@
use crate::error::Result;
use std::path::{Path, PathBuf};
use tokio::fs;
#[derive(Debug, Default)]
pub struct InputHandler {
pub text: String,
pub cursor_position: usize,
pub completions: Vec<String>,
pub completion_idx: usize,
}
impl InputHandler {
pub fn new() -> Self {
Self::default()
}
pub fn insert_char(&mut self, ch: char) {
self.text.insert(self.cursor_position, ch);
self.cursor_position += 1;
}
pub fn delete_char(&mut self) {
if self.cursor_position > 0 {
self.cursor_position -= 1;
self.text.remove(self.cursor_position);
}
}
pub fn clear(&mut self) {
self.text.clear();
self.cursor_position = 0;
self.completions.clear();
self.completion_idx = 0;
}
pub fn set_text(&mut self, text: String) {
self.cursor_position = text.len();
self.text = text;
}
pub async fn complete(&mut self) -> Result<()> {
let path = PathBuf::from(&self.text);
let (base_path, partial_name) = split_path_components(path);
let matches = find_matching_entries(&base_path, &partial_name).await?;
self.update_completions(matches);
self.update_from_completions();
Ok(())
}
fn update_completions(&mut self, matches: Vec<String>) {
if matches.is_empty() {
self.completions.clear();
self.completion_idx = 0;
} else if matches != self.completions {
self.completions = matches;
self.completion_idx = 0;
} else {
self.completion_idx = (self.completion_idx + 1) % self.completions.len();
}
}
fn update_from_completions(&mut self) {
if let Some(completions) = self.completions.get(self.completion_idx) {
self.set_text(completions.clone());
}
}
}
fn split_path_components(path: PathBuf) -> (PathBuf, String) {
if path.is_dir() {
return (path, String::new());
}
let partial = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let base = path
.parent()
.unwrap_or_else(|| Path::new("/"))
.to_path_buf();
(base, partial)
}
async fn find_matching_entries(base_path: &Path, partial_name: &str) -> Result<Vec<String>> {
let mut entries = fs::read_dir(&base_path).await?;
let mut matches = Vec::new();
while let Some(entry) = entries.next_entry().await? {
let file_name = entry.file_name().to_string_lossy().to_string();
if file_name
.to_lowercase()
.starts_with(&partial_name.to_lowercase())
{
matches.push(format!("{}/{}", base_path.to_string_lossy(), file_name));
}
}
Ok(matches)
}

View File

@ -1,34 +1,36 @@
pub mod action;
mod command;
pub mod constants;
mod input;
mod tab;
mod torrent;
pub mod types;
pub mod utils;
use crate::error::Result;
use crate::{app::input::InputHandler, config::Config};
use crate::config::Config;
use color_eyre::Result;
use ratatui::widgets::TableState;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use tokio::fs;
use types::Selected;
pub use {tab::Tab, torrent::Torrents};
/// Main Application.
#[derive(Debug)]
pub struct App {
pub struct App<'a> {
pub running: bool,
index: usize,
tabs: Vec<Tab>,
tabs: &'a [Tab],
pub state: TableState,
pub torrents: Torrents,
pub show_help: bool,
pub config: Config,
pub input_handler: InputHandler,
pub input: String,
pub cursor_position: usize,
pub input_mode: bool,
pub completions: Vec<String>,
pub completion_idx: usize,
}
impl App {
impl App<'_> {
/// Constructs a new instance of [`App`].
/// Returns instance of `Self`.
///
@ -38,14 +40,17 @@ impl App {
pub fn new(config: Config) -> Result<Self> {
Ok(Self {
running: true,
tabs: vec![Tab::All, Tab::Active, Tab::Downloading],
tabs: &[Tab::All, Tab::Active, Tab::Downloading],
index: 0,
state: TableState::default(),
torrents: Torrents::new()?, // Handle the Result here
show_help: false,
config,
input_handler: InputHandler::new(),
input: String::new(),
cursor_position: 0,
input_mode: false,
completions: Vec::new(),
completion_idx: 0,
})
}
@ -53,7 +58,14 @@ impl App {
///
/// TODO: add error types
pub async fn complete_input(&mut self) -> Result<()> {
self.input_handler.complete().await
let path = PathBuf::from(&self.input);
let (base_path, partial_name) = split_path_components(path);
let matches = find_matching_entries(&base_path, &partial_name).await?;
self.update_completions(matches);
self.update_input_with_matches();
Ok(())
}
/// Handles the tick event of the terminal.
@ -137,8 +149,8 @@ impl App {
/// Returns [`Tab`] slice
#[inline]
#[must_use]
pub fn tabs(&self) -> &[Tab] {
&self.tabs
pub const fn tabs(&self) -> &[Tab] {
self.tabs
}
#[inline]
@ -180,18 +192,16 @@ impl App {
///
/// TODO: add error types
pub async fn move_torrent(&mut self) -> Result<()> {
self.torrents
.move_selection(&self.input_handler.text)
.await?;
self.input_handler.clear();
self.torrents.move_selection(&self.input).await?;
self.input.clear();
self.cursor_position = 0;
self.input_mode = false;
Ok(())
}
pub fn prepare_move_action(&mut self) {
if let Some(download_dir) = self.get_current_downlaod_dir() {
self.input_handler
.set_text(download_dir.to_string_lossy().to_string());
self.update_cursor(&download_dir);
}
self.input_mode = true;
}
@ -225,6 +235,29 @@ impl App {
Selected::List(selected_torrents)
}
fn update_completions(&mut self, matches: Vec<String>) {
if matches.is_empty() {
self.completions.clear();
self.completion_idx = 0;
return;
}
if matches != self.completions {
self.completions = matches;
self.completion_idx = 0;
return;
}
self.completion_idx = (self.completion_idx + 1) % self.completions.len();
}
fn update_input_with_matches(&mut self) {
if let Some(completion) = self.completions.get(self.completion_idx) {
self.input = completion.clone();
self.cursor_position = self.input.len();
}
}
fn get_current_downlaod_dir(&self) -> Option<PathBuf> {
match self.selected(true) {
Selected::Current(current_id) => self
@ -237,4 +270,46 @@ impl App {
Selected::List(_) => None,
}
}
fn update_cursor(&mut self, path: &Path) {
self.input = path.to_string_lossy().to_string();
self.cursor_position = self.input.len();
}
}
fn split_path_components(path: PathBuf) -> (PathBuf, String) {
if path.is_dir() {
return (path, String::new());
}
let partial = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let base = path
.parent()
.unwrap_or_else(|| Path::new("/"))
.to_path_buf();
(base, partial)
}
async fn find_matching_entries(base_path: &Path, partial_name: &str) -> Result<Vec<String>> {
let mut entries = fs::read_dir(&base_path).await?;
let mut matches = Vec::new();
while let Some(entry) = entries.next_entry().await? {
let file_name = entry.file_name().to_string_lossy().to_string();
if file_name
.to_lowercase()
.starts_with(&partial_name.to_lowercase())
{
matches.push(format!("{}/{}", base_path.to_string_lossy(), file_name));
}
}
Ok(matches)
}

View File

@ -2,7 +2,7 @@ use std::fmt::Display;
use transmission_rpc::types::TorrentGetField;
/// Available tabs.
#[derive(Debug, Clone, Default)]
#[derive(Debug, Default)]
pub enum Tab {
#[default]
All,

View File

@ -1,4 +1,4 @@
use crate::{app::constants::DEFAULT_RPC_URL, error::Result};
use color_eyre::{Result, eyre::eyre};
use std::{collections::HashSet, fmt::Debug};
use transmission_rpc::{
TransClient,
@ -22,7 +22,7 @@ impl Torrents {
///
/// TODO: add error types
pub fn new() -> Result<Self> {
let url = Url::parse(DEFAULT_RPC_URL)?;
let url = Url::parse("http://localhost:9091/transmission/rpc")?;
Ok(Self {
client: TransClient::new(url),
torrents: Vec::new(),
@ -70,7 +70,8 @@ impl Torrents {
self.torrents = self
.client
.torrent_get(self.fields.clone(), None)
.await?
.await
.map_err(|e| eyre!("Transmission RPC error: {}", e.to_string()))?
.arguments
.torrents;
Ok(self)
@ -83,7 +84,8 @@ impl Torrents {
let ids: Vec<Id> = self.selected.iter().map(|id| Id::Id(*id)).collect();
self.client
.torrent_set_location(ids, location.to_string(), Some(true))
.await?;
.await
.map_err(|e| eyre!("Transmission RPC error: {}", e.to_string()))?;
Ok(())
}
}

View File

@ -1,30 +0,0 @@
use thiserror::Error;
#[derive(Debug, Error)]
pub enum TraxorError {
#[error("Transmission RPC error: {0}")]
TransmissionRpc(String),
#[error("Configuration error: {0}")]
Config(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("URL parse error: {0}")]
UrlParse(#[from] url::ParseError),
#[error("No torrent selected")]
NoSelection,
#[error("Invalid torrent ID: {0}")]
InvalidTorrentId(i64),
}
impl From<Box<dyn std::error::Error + Send + Sync>> for TraxorError {
fn from(e: Box<dyn std::error::Error + Send + Sync>) -> Self {
Self::TransmissionRpc(e.to_string())
}
}
pub type Result<T> = std::result::Result<T, TraxorError>;

View File

@ -1,23 +1,25 @@
use crate::app::{App, action::Action};
use crate::error::Result;
use color_eyre::Result;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use thiserror::Error;
use tracing::{debug, info};
#[tracing::instrument(name = "Handling input", skip(app))]
async fn handle_input(key_event: KeyEvent, app: &mut App) -> Result<Option<Action>> {
async fn handle_input(key_event: KeyEvent, app: &mut App<'_>) -> Result<Option<Action>> {
match key_event.code {
KeyCode::Enter => Ok(Some(Action::Submit)),
KeyCode::Tab => {
app.complete_input().await?;
Ok(None)
}
KeyCode::Char(ch) => {
app.input_handler.insert_char(ch);
KeyCode::Char(c) => {
app.input.push(c);
app.cursor_position = app.input.len();
Ok(None)
}
KeyCode::Backspace => {
app.input_handler.delete_char();
app.input.pop();
app.cursor_position = app.input.len();
Ok(None)
}
KeyCode::Esc => Ok(Some(Action::Cancel)),
@ -31,7 +33,7 @@ async fn handle_input(key_event: KeyEvent, app: &mut App) -> Result<Option<Actio
///
/// TODO: add error types
#[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 {
return handle_input(key_event, app).await;
}
@ -72,7 +74,7 @@ pub async fn get_action(key_event: KeyEvent, app: &mut App) -> Result<Option<Act
///
/// TODO: add error types
#[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);
match action {
Action::Quit => app.quit(),
@ -92,7 +94,7 @@ pub async fn update(app: &mut App, action: Action) -> Result<()> {
Action::Select => app.select(),
Action::Submit => app.move_torrent().await?,
Action::Cancel => {
app.input_handler.clear();
app.input.clear();
app.input_mode = false;
}
}
@ -116,7 +118,7 @@ pub enum ParseKeybingError {
UnknownPart(String),
}
fn parse_keybind(key_str: &str) -> std::result::Result<KeyEvent, ParseKeybingError> {
fn parse_keybind(key_str: &str) -> Result<KeyEvent, ParseKeybingError> {
let mut modifiers = KeyModifiers::NONE;
let mut key_code = None;

View File

@ -1,6 +1,5 @@
pub mod app;
pub mod config;
pub mod error;
pub mod event;
pub mod handler;
pub mod telemetry;

View File

@ -3,15 +3,11 @@ use ratatui::{Terminal, backend::CrosstermBackend};
use std::{io, sync::Arc};
use tokio::{
sync::Mutex,
task::JoinHandle,
time::{self, Duration},
};
use tracing::warn;
use tracing::{trace, warn};
use traxor::{
app::{
App,
constants::{DEFAULT_TICK_RATE_MS, TORRENT_UPDATE_INTERVAL_SECS},
},
app::App,
config::Config,
event::{Event, EventHandler},
handler::{get_action, update},
@ -22,19 +18,36 @@ use traxor::{
#[tokio::main]
async fn main() -> Result<()> {
color_eyre::install()?;
let config = Config::load()?;
setup_logger(&config)?;
// Wrap App in Arc<Mutex<>> so we can share it between UI and updater
let app = Arc::new(Mutex::new(App::new(config)?));
spawn_torrent_updater(app.clone());
// Clone for updater task
let app_clone = app.clone();
tokio::spawn(async move {
let mut interval = time::interval(Duration::from_secs(2));
loop {
interval.tick().await;
let mut app = app_clone.lock().await;
if let Err(e) = app.torrents.update().await {
warn!("Failed to update torrents: {e}");
}
}
});
// TUI setup
let backend = CrosstermBackend::new(io::stderr());
let terminal = Terminal::new(backend)?;
let events = EventHandler::new(DEFAULT_TICK_RATE_MS);
let events = EventHandler::new(250); // Update time in ms
let mut tui = Tui::new(terminal, events);
tui.init()?;
// Main loop
loop {
{
let app_guard = app.lock().await;
@ -49,29 +62,22 @@ async fn main() -> Result<()> {
}
match tui.events.next()? {
Event::Tick => {}
Event::Key(key_event) => {
let mut app_guard = app.lock().await;
if let Some(action) = get_action(key_event, &mut app_guard).await? {
update(&mut app_guard, action).await?;
}
}
Event::Mouse(_) | Event::Resize(_, _) | Event::Tick => {}
Event::Mouse(mouse_event) => {
trace!(target: "app", "Event::Mouse: {:?}", mouse_event);
}
Event::Resize(x, y) => {
trace!(target: "app", "Event::Resize: ({}, {})", x, y);
}
}
}
tui.exit()?;
Ok(())
}
fn spawn_torrent_updater(app: Arc<Mutex<App>>) -> JoinHandle<()> {
tokio::spawn(async move {
let mut interval = time::interval(Duration::from_secs(TORRENT_UPDATE_INTERVAL_SECS));
loop {
interval.tick().await;
let mut app = app.lock().await;
if let Err(e) = app.torrents.update().await {
warn!("Failed to update torrents: {e}");
}
}
})
}

View File

@ -5,7 +5,7 @@ use ratatui::{
};
use tracing::warn;
pub fn render(f: &mut Frame, app: &App) {
pub fn render(f: &mut Frame, app: &mut App) {
let size = f.area();
let input_area = Rect::new(size.width / 4, size.height / 2 - 1, size.width / 2, 3);
@ -13,7 +13,7 @@ pub fn render(f: &mut Frame, app: &App) {
f.render_widget(Clear, input_area);
f.render_widget(block, input_area);
let input = Paragraph::new(app.input_handler.text.as_str()).block(Block::default());
let input = Paragraph::new(app.input.as_str()).block(Block::default());
f.render_widget(
input,
input_area.inner(Margin {
@ -22,10 +22,14 @@ pub fn render(f: &mut Frame, app: &App) {
}),
);
let cursor_offset = u16::try_from(app.input_handler.cursor_position).unwrap_or_else(|_| {
warn!("cursor_position out of range, clamping");
0
});
let cursor_offset = u16::try_from(app.cursor_position)
.map_err(|_| {
warn!(
"cursor_position {} out of u16 range. Clamping to 0",
app.cursor_position
);
})
.unwrap_or_default();
f.set_cursor_position(Position::new(
input_area.x + cursor_offset + 1,