mirror of
https://github.com/kristoferssolo/traxor.git
synced 2025-12-31 05:32:43 +00:00
This commit is contained in:
parent
3abeb83660
commit
be542551f3
28
Cargo.lock
generated
28
Cargo.lock
generated
@ -199,9 +199,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "convert_case"
|
name = "convert_case"
|
||||||
version = "0.7.1"
|
version = "0.10.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7"
|
checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
]
|
]
|
||||||
@ -316,22 +316,23 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "derive_more"
|
name = "derive_more"
|
||||||
version = "2.0.1"
|
version = "2.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678"
|
checksum = "10b768e943bed7bf2cab53df09f4bc34bfd217cdb57d971e769874c9a6710618"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"derive_more-impl",
|
"derive_more-impl",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "derive_more-impl"
|
name = "derive_more-impl"
|
||||||
version = "2.0.1"
|
version = "2.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3"
|
checksum = "6d286bfdaf75e988b4a78e013ecd79c581e06399ab53fbacd2d916c2f904f30b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"convert_case",
|
"convert_case",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
|
"rustc_version",
|
||||||
"syn",
|
"syn",
|
||||||
"unicode-xid",
|
"unicode-xid",
|
||||||
]
|
]
|
||||||
@ -1414,6 +1415,15 @@ version = "2.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
|
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]]
|
[[package]]
|
||||||
name = "rustix"
|
name = "rustix"
|
||||||
version = "0.38.44"
|
version = "0.38.44"
|
||||||
@ -1493,6 +1503,12 @@ version = "1.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "semver"
|
||||||
|
version = "1.0.27"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde"
|
name = "serde"
|
||||||
version = "1.0.219"
|
version = "1.0.219"
|
||||||
|
|||||||
@ -9,7 +9,7 @@ edition = "2024"
|
|||||||
filecaster = { version = "0.2", features = ["derive", "merge"] }
|
filecaster = { version = "0.2", features = ["derive", "merge"] }
|
||||||
color-eyre = "0.6"
|
color-eyre = "0.6"
|
||||||
crossterm = "0.29"
|
crossterm = "0.29"
|
||||||
derive_more = { version = "2.0", features = ["display"] }
|
derive_more = { version = "2.1", features = ["display"] }
|
||||||
dirs = "6.0"
|
dirs = "6.0"
|
||||||
merge = "0.2"
|
merge = "0.2"
|
||||||
ratatui = { version = "0.29" }
|
ratatui = { version = "0.29" }
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
use super::{Torrents, types::Selected};
|
use super::{Torrents, types::Selected};
|
||||||
use color_eyre::{Result, eyre::eyre};
|
use crate::error::Result;
|
||||||
use std::{collections::HashSet, path::Path};
|
use std::{collections::HashSet, path::Path};
|
||||||
use transmission_rpc::types::{Torrent, TorrentAction, TorrentStatus};
|
use transmission_rpc::types::{Torrent, TorrentAction, TorrentStatus};
|
||||||
|
|
||||||
@ -9,11 +9,11 @@ impl Torrents {
|
|||||||
/// TODO: add error types
|
/// TODO: add error types
|
||||||
pub async fn toggle(&mut self, ids: Selected) -> Result<()> {
|
pub async fn toggle(&mut self, ids: Selected) -> Result<()> {
|
||||||
let ids: HashSet<_> = ids.into();
|
let ids: HashSet<_> = ids.into();
|
||||||
let torrents_to_toggle: Vec<_> = self
|
let torrents_to_toggle = self
|
||||||
.torrents
|
.torrents
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|torrent| torrent.id.is_some_and(|id| ids.contains(&id)))
|
.filter(|torrent| torrent.id.is_some_and(|id| ids.contains(&id)))
|
||||||
.collect();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
for torrent in torrents_to_toggle {
|
for torrent in torrents_to_toggle {
|
||||||
let action = match torrent.status {
|
let action = match torrent.status {
|
||||||
@ -21,10 +21,7 @@ impl Torrents {
|
|||||||
_ => TorrentAction::Stop,
|
_ => TorrentAction::Stop,
|
||||||
};
|
};
|
||||||
if let Some(id) = torrent.id() {
|
if let Some(id) = torrent.id() {
|
||||||
self.client
|
self.client.torrent_action(action, vec![id]).await?;
|
||||||
.torrent_action(action, vec![id])
|
|
||||||
.await
|
|
||||||
.map_err(|e| eyre!("Transmission RPC error: {}", e.to_string()))?;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@ -51,10 +48,7 @@ impl Torrents {
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
for (id, action) in torrents_to_toggle {
|
for (id, action) in torrents_to_toggle {
|
||||||
self.client
|
self.client.torrent_action(action, vec![id]).await?;
|
||||||
.torrent_action(action, vec![id])
|
|
||||||
.await
|
|
||||||
.map_err(|e| eyre!("Transmission RPC error: {}", e.to_string()))?;
|
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@ -85,8 +79,7 @@ impl Torrents {
|
|||||||
if let Some(id) = torrent.id() {
|
if let Some(id) = torrent.id() {
|
||||||
self.client
|
self.client
|
||||||
.torrent_set_location(vec![id], location.to_string_lossy().into(), move_from)
|
.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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@ -97,8 +90,7 @@ impl Torrents {
|
|||||||
pub async fn delete(&mut self, ids: Selected, delete_local_data: bool) -> Result<()> {
|
pub async fn delete(&mut self, ids: Selected, delete_local_data: bool) -> Result<()> {
|
||||||
self.client
|
self.client
|
||||||
.torrent_remove(ids.into(), delete_local_data)
|
.torrent_remove(ids.into(), delete_local_data)
|
||||||
.await
|
.await?;
|
||||||
.map_err(|e| eyre!("Transmission RPC error: {}", e.to_string()))?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -109,8 +101,7 @@ impl Torrents {
|
|||||||
if let (Some(id), Some(old_name)) = (torrent.id(), torrent.name.clone()) {
|
if let (Some(id), Some(old_name)) = (torrent.id(), torrent.name.clone()) {
|
||||||
self.client
|
self.client
|
||||||
.torrent_rename_path(vec![id], old_name, name.to_string_lossy().into())
|
.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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@ -125,10 +116,7 @@ impl Torrents {
|
|||||||
.filter_map(Torrent::id)
|
.filter_map(Torrent::id)
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
self.client
|
self.client.torrent_action(action, ids).await?;
|
||||||
.torrent_action(action, ids)
|
|
||||||
.await
|
|
||||||
.map_err(|e| eyre!("Transmission RPC error: {}", e.to_string()))?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
7
src/app/constants.rs
Normal file
7
src/app/constants.rs
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
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;
|
||||||
102
src/app/input.rs
Normal file
102
src/app/input.rs
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
115
src/app/mod.rs
115
src/app/mod.rs
@ -1,36 +1,34 @@
|
|||||||
pub mod action;
|
pub mod action;
|
||||||
mod command;
|
mod command;
|
||||||
|
pub mod constants;
|
||||||
|
mod input;
|
||||||
mod tab;
|
mod tab;
|
||||||
mod torrent;
|
mod torrent;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
pub mod utils;
|
pub mod utils;
|
||||||
|
|
||||||
use crate::config::Config;
|
use crate::error::Result;
|
||||||
use color_eyre::Result;
|
use crate::{app::input::InputHandler, config::Config};
|
||||||
use ratatui::widgets::TableState;
|
use ratatui::widgets::TableState;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::PathBuf;
|
||||||
use tokio::fs;
|
|
||||||
use types::Selected;
|
use types::Selected;
|
||||||
pub use {tab::Tab, torrent::Torrents};
|
pub use {tab::Tab, torrent::Torrents};
|
||||||
|
|
||||||
/// Main Application.
|
/// Main Application.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct App<'a> {
|
pub struct App {
|
||||||
pub running: bool,
|
pub running: bool,
|
||||||
index: usize,
|
index: usize,
|
||||||
tabs: &'a [Tab],
|
tabs: Vec<Tab>,
|
||||||
pub state: TableState,
|
pub state: TableState,
|
||||||
pub torrents: Torrents,
|
pub torrents: Torrents,
|
||||||
pub show_help: bool,
|
pub show_help: bool,
|
||||||
pub config: Config,
|
pub config: Config,
|
||||||
pub input: String,
|
pub input_handler: InputHandler,
|
||||||
pub cursor_position: usize,
|
|
||||||
pub input_mode: bool,
|
pub input_mode: bool,
|
||||||
pub completions: Vec<String>,
|
|
||||||
pub completion_idx: usize,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App<'_> {
|
impl App {
|
||||||
/// Constructs a new instance of [`App`].
|
/// Constructs a new instance of [`App`].
|
||||||
/// Returns instance of `Self`.
|
/// Returns instance of `Self`.
|
||||||
///
|
///
|
||||||
@ -40,17 +38,14 @@ impl App<'_> {
|
|||||||
pub fn new(config: Config) -> Result<Self> {
|
pub fn new(config: Config) -> Result<Self> {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
running: true,
|
running: true,
|
||||||
tabs: &[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()?, // Handle the Result here
|
||||||
show_help: false,
|
show_help: false,
|
||||||
config,
|
config,
|
||||||
input: String::new(),
|
input_handler: InputHandler::new(),
|
||||||
cursor_position: 0,
|
|
||||||
input_mode: false,
|
input_mode: false,
|
||||||
completions: Vec::new(),
|
|
||||||
completion_idx: 0,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -58,14 +53,7 @@ impl App<'_> {
|
|||||||
///
|
///
|
||||||
/// TODO: add error types
|
/// TODO: add error types
|
||||||
pub async fn complete_input(&mut self) -> Result<()> {
|
pub async fn complete_input(&mut self) -> Result<()> {
|
||||||
let path = PathBuf::from(&self.input);
|
self.input_handler.complete().await
|
||||||
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.
|
/// Handles the tick event of the terminal.
|
||||||
@ -149,8 +137,8 @@ impl App<'_> {
|
|||||||
/// Returns [`Tab`] slice
|
/// Returns [`Tab`] slice
|
||||||
#[inline]
|
#[inline]
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub const fn tabs(&self) -> &[Tab] {
|
pub fn tabs(&self) -> &[Tab] {
|
||||||
self.tabs
|
&self.tabs
|
||||||
}
|
}
|
||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
@ -192,16 +180,18 @@ impl App<'_> {
|
|||||||
///
|
///
|
||||||
/// TODO: add error types
|
/// TODO: add error types
|
||||||
pub async fn move_torrent(&mut self) -> Result<()> {
|
pub async fn move_torrent(&mut self) -> Result<()> {
|
||||||
self.torrents.move_selection(&self.input).await?;
|
self.torrents
|
||||||
self.input.clear();
|
.move_selection(&self.input_handler.text)
|
||||||
self.cursor_position = 0;
|
.await?;
|
||||||
|
self.input_handler.clear();
|
||||||
self.input_mode = false;
|
self.input_mode = false;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn prepare_move_action(&mut self) {
|
pub fn prepare_move_action(&mut self) {
|
||||||
if let Some(download_dir) = self.get_current_downlaod_dir() {
|
if let Some(download_dir) = self.get_current_downlaod_dir() {
|
||||||
self.update_cursor(&download_dir);
|
self.input_handler
|
||||||
|
.set_text(download_dir.to_string_lossy().to_string());
|
||||||
}
|
}
|
||||||
self.input_mode = true;
|
self.input_mode = true;
|
||||||
}
|
}
|
||||||
@ -235,29 +225,6 @@ impl App<'_> {
|
|||||||
Selected::List(selected_torrents)
|
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> {
|
fn get_current_downlaod_dir(&self) -> Option<PathBuf> {
|
||||||
match self.selected(true) {
|
match self.selected(true) {
|
||||||
Selected::Current(current_id) => self
|
Selected::Current(current_id) => self
|
||||||
@ -270,46 +237,4 @@ impl App<'_> {
|
|||||||
Selected::List(_) => None,
|
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)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,7 @@ use std::fmt::Display;
|
|||||||
use transmission_rpc::types::TorrentGetField;
|
use transmission_rpc::types::TorrentGetField;
|
||||||
|
|
||||||
/// Available tabs.
|
/// Available tabs.
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub enum Tab {
|
pub enum Tab {
|
||||||
#[default]
|
#[default]
|
||||||
All,
|
All,
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
use color_eyre::{Result, eyre::eyre};
|
use crate::{app::constants::DEFAULT_RPC_URL, error::Result};
|
||||||
use std::{collections::HashSet, fmt::Debug};
|
use std::{collections::HashSet, fmt::Debug};
|
||||||
use transmission_rpc::{
|
use transmission_rpc::{
|
||||||
TransClient,
|
TransClient,
|
||||||
@ -22,7 +22,7 @@ impl Torrents {
|
|||||||
///
|
///
|
||||||
/// TODO: add error types
|
/// TODO: add error types
|
||||||
pub fn new() -> Result<Self> {
|
pub fn new() -> Result<Self> {
|
||||||
let url = Url::parse("http://localhost:9091/transmission/rpc")?;
|
let url = Url::parse(DEFAULT_RPC_URL)?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
client: TransClient::new(url),
|
client: TransClient::new(url),
|
||||||
torrents: Vec::new(),
|
torrents: Vec::new(),
|
||||||
@ -70,8 +70,7 @@ impl Torrents {
|
|||||||
self.torrents = self
|
self.torrents = self
|
||||||
.client
|
.client
|
||||||
.torrent_get(self.fields.clone(), None)
|
.torrent_get(self.fields.clone(), None)
|
||||||
.await
|
.await?
|
||||||
.map_err(|e| eyre!("Transmission RPC error: {}", e.to_string()))?
|
|
||||||
.arguments
|
.arguments
|
||||||
.torrents;
|
.torrents;
|
||||||
Ok(self)
|
Ok(self)
|
||||||
@ -84,8 +83,7 @@ impl Torrents {
|
|||||||
let ids: Vec<Id> = self.selected.iter().map(|id| Id::Id(*id)).collect();
|
let ids: Vec<Id> = self.selected.iter().map(|id| Id::Id(*id)).collect();
|
||||||
self.client
|
self.client
|
||||||
.torrent_set_location(ids, location.to_string(), Some(true))
|
.torrent_set_location(ids, location.to_string(), Some(true))
|
||||||
.await
|
.await?;
|
||||||
.map_err(|e| eyre!("Transmission RPC error: {}", e.to_string()))?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
30
src/error.rs
Normal file
30
src/error.rs
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
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>;
|
||||||
@ -1,25 +1,23 @@
|
|||||||
use crate::app::{App, action::Action};
|
use crate::app::{App, action::Action};
|
||||||
use color_eyre::Result;
|
use crate::error::Result;
|
||||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use tracing::{debug, info};
|
use tracing::{debug, info};
|
||||||
|
|
||||||
#[tracing::instrument(name = "Handling input", skip(app))]
|
#[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 {
|
match key_event.code {
|
||||||
KeyCode::Enter => Ok(Some(Action::Submit)),
|
KeyCode::Enter => Ok(Some(Action::Submit)),
|
||||||
KeyCode::Tab => {
|
KeyCode::Tab => {
|
||||||
app.complete_input().await?;
|
app.complete_input().await?;
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
KeyCode::Char(c) => {
|
KeyCode::Char(ch) => {
|
||||||
app.input.push(c);
|
app.input_handler.insert_char(ch);
|
||||||
app.cursor_position = app.input.len();
|
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
KeyCode::Backspace => {
|
KeyCode::Backspace => {
|
||||||
app.input.pop();
|
app.input_handler.delete_char();
|
||||||
app.cursor_position = app.input.len();
|
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
KeyCode::Esc => Ok(Some(Action::Cancel)),
|
KeyCode::Esc => Ok(Some(Action::Cancel)),
|
||||||
@ -33,7 +31,7 @@ async fn handle_input(key_event: KeyEvent, app: &mut App<'_>) -> Result<Option<A
|
|||||||
///
|
///
|
||||||
/// TODO: add error types
|
/// TODO: add error types
|
||||||
#[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 {
|
||||||
return handle_input(key_event, app).await;
|
return handle_input(key_event, app).await;
|
||||||
}
|
}
|
||||||
@ -74,7 +72,7 @@ pub async fn get_action(key_event: KeyEvent, app: &mut App<'_>) -> Result<Option
|
|||||||
///
|
///
|
||||||
/// TODO: add error types
|
/// TODO: add error types
|
||||||
#[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);
|
||||||
match action {
|
match action {
|
||||||
Action::Quit => app.quit(),
|
Action::Quit => app.quit(),
|
||||||
@ -94,7 +92,7 @@ pub async fn update(app: &mut App<'_>, action: Action) -> Result<()> {
|
|||||||
Action::Select => app.select(),
|
Action::Select => app.select(),
|
||||||
Action::Submit => app.move_torrent().await?,
|
Action::Submit => app.move_torrent().await?,
|
||||||
Action::Cancel => {
|
Action::Cancel => {
|
||||||
app.input.clear();
|
app.input_handler.clear();
|
||||||
app.input_mode = false;
|
app.input_mode = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -118,7 +116,7 @@ pub enum ParseKeybingError {
|
|||||||
UnknownPart(String),
|
UnknownPart(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_keybind(key_str: &str) -> Result<KeyEvent, ParseKeybingError> {
|
fn parse_keybind(key_str: &str) -> std::result::Result<KeyEvent, ParseKeybingError> {
|
||||||
let mut modifiers = KeyModifiers::NONE;
|
let mut modifiers = KeyModifiers::NONE;
|
||||||
let mut key_code = None;
|
let mut key_code = None;
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
pub mod app;
|
pub mod app;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod error;
|
||||||
pub mod event;
|
pub mod event;
|
||||||
pub mod handler;
|
pub mod handler;
|
||||||
pub mod telemetry;
|
pub mod telemetry;
|
||||||
|
|||||||
50
src/main.rs
50
src/main.rs
@ -3,11 +3,15 @@ use ratatui::{Terminal, backend::CrosstermBackend};
|
|||||||
use std::{io, sync::Arc};
|
use std::{io, sync::Arc};
|
||||||
use tokio::{
|
use tokio::{
|
||||||
sync::Mutex,
|
sync::Mutex,
|
||||||
|
task::JoinHandle,
|
||||||
time::{self, Duration},
|
time::{self, Duration},
|
||||||
};
|
};
|
||||||
use tracing::{trace, warn};
|
use tracing::warn;
|
||||||
use traxor::{
|
use traxor::{
|
||||||
app::App,
|
app::{
|
||||||
|
App,
|
||||||
|
constants::{DEFAULT_TICK_RATE_MS, TORRENT_UPDATE_INTERVAL_SECS},
|
||||||
|
},
|
||||||
config::Config,
|
config::Config,
|
||||||
event::{Event, EventHandler},
|
event::{Event, EventHandler},
|
||||||
handler::{get_action, update},
|
handler::{get_action, update},
|
||||||
@ -18,36 +22,19 @@ use traxor::{
|
|||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
color_eyre::install()?;
|
color_eyre::install()?;
|
||||||
|
|
||||||
let config = Config::load()?;
|
let config = Config::load()?;
|
||||||
setup_logger(&config)?;
|
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)?));
|
let app = Arc::new(Mutex::new(App::new(config)?));
|
||||||
|
|
||||||
// Clone for updater task
|
spawn_torrent_updater(app.clone());
|
||||||
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 backend = CrosstermBackend::new(io::stderr());
|
||||||
let terminal = Terminal::new(backend)?;
|
let terminal = Terminal::new(backend)?;
|
||||||
let events = EventHandler::new(250); // Update time in ms
|
let events = EventHandler::new(DEFAULT_TICK_RATE_MS);
|
||||||
let mut tui = Tui::new(terminal, events);
|
let mut tui = Tui::new(terminal, events);
|
||||||
tui.init()?;
|
tui.init()?;
|
||||||
|
|
||||||
// Main loop
|
|
||||||
loop {
|
loop {
|
||||||
{
|
{
|
||||||
let app_guard = app.lock().await;
|
let app_guard = app.lock().await;
|
||||||
@ -62,22 +49,29 @@ async fn main() -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
match tui.events.next()? {
|
match tui.events.next()? {
|
||||||
Event::Tick => {}
|
|
||||||
Event::Key(key_event) => {
|
Event::Key(key_event) => {
|
||||||
let mut app_guard = app.lock().await;
|
let mut app_guard = app.lock().await;
|
||||||
if let Some(action) = get_action(key_event, &mut app_guard).await? {
|
if let Some(action) = get_action(key_event, &mut app_guard).await? {
|
||||||
update(&mut app_guard, action).await?;
|
update(&mut app_guard, action).await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Event::Mouse(mouse_event) => {
|
Event::Mouse(_) | Event::Resize(_, _) | Event::Tick => {}
|
||||||
trace!(target: "app", "Event::Mouse: {:?}", mouse_event);
|
|
||||||
}
|
|
||||||
Event::Resize(x, y) => {
|
|
||||||
trace!(target: "app", "Event::Resize: ({}, {})", x, y);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tui.exit()?;
|
tui.exit()?;
|
||||||
Ok(())
|
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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@ -5,7 +5,7 @@ use ratatui::{
|
|||||||
};
|
};
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
|
|
||||||
pub fn render(f: &mut Frame, app: &mut App) {
|
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);
|
||||||
|
|
||||||
@ -13,7 +13,7 @@ pub fn render(f: &mut Frame, app: &mut App) {
|
|||||||
f.render_widget(Clear, input_area);
|
f.render_widget(Clear, input_area);
|
||||||
f.render_widget(block, input_area);
|
f.render_widget(block, input_area);
|
||||||
|
|
||||||
let input = Paragraph::new(app.input.as_str()).block(Block::default());
|
let input = Paragraph::new(app.input_handler.text.as_str()).block(Block::default());
|
||||||
f.render_widget(
|
f.render_widget(
|
||||||
input,
|
input,
|
||||||
input_area.inner(Margin {
|
input_area.inner(Margin {
|
||||||
@ -22,14 +22,10 @@ pub fn render(f: &mut Frame, app: &mut App) {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
let cursor_offset = u16::try_from(app.cursor_position)
|
let cursor_offset = u16::try_from(app.input_handler.cursor_position).unwrap_or_else(|_| {
|
||||||
.map_err(|_| {
|
warn!("cursor_position out of range, clamping");
|
||||||
warn!(
|
0
|
||||||
"cursor_position {} out of u16 range. Clamping to 0",
|
});
|
||||||
app.cursor_position
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
f.set_cursor_position(Position::new(
|
f.set_cursor_position(Position::new(
|
||||||
input_area.x + cursor_offset + 1,
|
input_area.x + cursor_offset + 1,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user