mirror of
https://github.com/kristoferssolo/traxor.git
synced 2026-01-14 20:46:14 +00:00
feat: add live fuzzy search/filter for torrents
This commit is contained in:
parent
82389ef5b3
commit
1b145f9ace
10
Cargo.lock
generated
10
Cargo.lock
generated
@ -600,6 +600,15 @@ dependencies = [
|
||||
"pin-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fuzzy-matcher"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94"
|
||||
dependencies = [
|
||||
"thread_local",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
@ -2513,6 +2522,7 @@ dependencies = [
|
||||
"crossterm",
|
||||
"derive_more",
|
||||
"dirs",
|
||||
"fuzzy-matcher",
|
||||
"ratatui",
|
||||
"serde",
|
||||
"thiserror 2.0.12",
|
||||
|
||||
@ -10,6 +10,7 @@ color-eyre = "0.6"
|
||||
crossterm = "0.29"
|
||||
derive_more = { version = "2.1", features = ["display"] }
|
||||
dirs = "6.0"
|
||||
fuzzy-matcher = "0.3"
|
||||
ratatui = "0.30"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
thiserror = "2.0"
|
||||
|
||||
@ -35,6 +35,10 @@ rename_torrent = "r"
|
||||
delete = "d"
|
||||
delete_force = "D"
|
||||
|
||||
# Search/filter
|
||||
filter = "/"
|
||||
clear_filter = "escape"
|
||||
|
||||
# General
|
||||
toggle_help = "?"
|
||||
quit = "q"
|
||||
|
||||
@ -30,6 +30,10 @@ pub enum Action {
|
||||
Delete(bool),
|
||||
#[display("Rename Torrent")]
|
||||
Rename,
|
||||
#[display("Filter")]
|
||||
Filter,
|
||||
#[display("Clear Filter")]
|
||||
ClearFilter,
|
||||
#[display("Select")]
|
||||
Select,
|
||||
#[display("Submit")]
|
||||
|
||||
@ -9,6 +9,7 @@ pub mod utils;
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::{app::input::InputHandler, config::Config};
|
||||
use fuzzy_matcher::{FuzzyMatcher, skim::SkimMatcherV2};
|
||||
use ratatui::widgets::TableState;
|
||||
use std::path::PathBuf;
|
||||
use types::Selected;
|
||||
@ -21,6 +22,7 @@ pub enum InputMode {
|
||||
None,
|
||||
Move,
|
||||
Rename,
|
||||
Filter,
|
||||
/// Confirm delete dialog. Bool indicates whether to delete local data.
|
||||
ConfirmDelete(bool),
|
||||
}
|
||||
@ -37,6 +39,7 @@ pub struct App {
|
||||
pub config: Config,
|
||||
pub input_handler: InputHandler,
|
||||
pub input_mode: InputMode,
|
||||
pub filter_text: String,
|
||||
}
|
||||
|
||||
impl App {
|
||||
@ -58,6 +61,7 @@ impl App {
|
||||
config,
|
||||
input_handler: InputHandler::new(),
|
||||
input_mode: InputMode::None,
|
||||
filter_text: String::new(),
|
||||
})
|
||||
}
|
||||
|
||||
@ -121,12 +125,15 @@ impl App {
|
||||
#[inline]
|
||||
pub fn prev_tab(&mut self) {
|
||||
self.close_help();
|
||||
self.index = self.index.checked_sub(1).unwrap_or(self.tabs.len() - 1);
|
||||
self.index = self
|
||||
.index
|
||||
.checked_sub(1)
|
||||
.unwrap_or_else(|| self.tabs.len() - 1);
|
||||
}
|
||||
|
||||
/// Switches to the tab whose index is `idx` if it exists.
|
||||
#[inline]
|
||||
pub fn switch_tab(&mut self, idx: usize) {
|
||||
pub const fn switch_tab(&mut self, idx: usize) {
|
||||
if idx < self.tabs.len() {
|
||||
self.close_help();
|
||||
self.index = idx;
|
||||
@ -227,6 +234,61 @@ impl App {
|
||||
self.close_help();
|
||||
}
|
||||
|
||||
/// Start filter mode.
|
||||
pub fn start_filter(&mut self) {
|
||||
self.input_handler.set_text(self.filter_text.clone());
|
||||
self.input_mode = InputMode::Filter;
|
||||
}
|
||||
|
||||
/// Apply filter from input.
|
||||
pub fn apply_filter(&mut self) {
|
||||
self.filter_text = self.input_handler.text.clone();
|
||||
self.input_handler.clear();
|
||||
self.input_mode = InputMode::None;
|
||||
self.state.select(Some(0));
|
||||
}
|
||||
|
||||
/// Clear the active filter.
|
||||
pub fn clear_filter(&mut self) {
|
||||
self.filter_text.clear();
|
||||
self.input_handler.clear();
|
||||
self.input_mode = InputMode::None;
|
||||
}
|
||||
|
||||
/// Get the active filter text (live from input or saved).
|
||||
#[must_use]
|
||||
pub fn active_filter(&self) -> &str {
|
||||
if self.input_mode == InputMode::Filter {
|
||||
&self.input_handler.text
|
||||
} else {
|
||||
&self.filter_text
|
||||
}
|
||||
}
|
||||
|
||||
/// Get filtered torrents based on current filter text using fuzzy matching.
|
||||
#[must_use]
|
||||
pub fn filtered_torrents(&self) -> Vec<&transmission_rpc::types::Torrent> {
|
||||
let filter = self.active_filter();
|
||||
if filter.is_empty() {
|
||||
self.torrents.torrents.iter().collect()
|
||||
} else {
|
||||
let matcher = SkimMatcherV2::default();
|
||||
let mut scored: Vec<_> = self
|
||||
.torrents
|
||||
.torrents
|
||||
.iter()
|
||||
.filter_map(|t| {
|
||||
t.name
|
||||
.as_ref()
|
||||
.and_then(|name| matcher.fuzzy_match(name, filter).map(|score| (t, score)))
|
||||
})
|
||||
.collect();
|
||||
// Sort by score descending (best matches first)
|
||||
scored.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
scored.into_iter().map(|(t, _)| t).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Prepare delete confirmation dialog.
|
||||
pub const fn prepare_delete(&mut self, delete_local_data: bool) {
|
||||
self.input_mode = InputMode::ConfirmDelete(delete_local_data);
|
||||
|
||||
@ -25,4 +25,6 @@ pub struct KeybindsConfig {
|
||||
pub toggle_help: String,
|
||||
pub move_torrent: String,
|
||||
pub rename_torrent: String,
|
||||
pub filter: String,
|
||||
pub clear_filter: String,
|
||||
}
|
||||
|
||||
@ -73,6 +73,8 @@ pub async fn get_action(key_event: KeyEvent, app: &mut App) -> Result<Option<Act
|
||||
(Action::ToggleHelp, &keybinds.toggle_help),
|
||||
(Action::Move, &keybinds.move_torrent),
|
||||
(Action::Rename, &keybinds.rename_torrent),
|
||||
(Action::Filter, &keybinds.filter),
|
||||
(Action::ClearFilter, &keybinds.clear_filter),
|
||||
]
|
||||
.into_iter()
|
||||
.find_map(|(action, keybind)| matches_keybind(&key_event, keybind).then_some(action)))
|
||||
@ -100,11 +102,14 @@ pub async fn update(app: &mut App, action: Action) -> Result<()> {
|
||||
Action::StartAll => app.torrents.start_all().await?,
|
||||
Action::Move => app.prepare_move_action(),
|
||||
Action::Rename => app.prepare_rename_action(),
|
||||
Action::Filter => app.start_filter(),
|
||||
Action::ClearFilter => app.clear_filter(),
|
||||
Action::Delete(delete_local_data) => app.prepare_delete(delete_local_data),
|
||||
Action::Select => app.select(),
|
||||
Action::Submit => match app.input_mode {
|
||||
InputMode::Move => app.move_torrent().await?,
|
||||
InputMode::Rename => app.rename_torrent().await?,
|
||||
InputMode::Filter => app.apply_filter(),
|
||||
InputMode::None | InputMode::ConfirmDelete(_) => {}
|
||||
},
|
||||
Action::ConfirmYes => app.confirm_delete().await?,
|
||||
|
||||
@ -8,7 +8,7 @@ use tracing::warn;
|
||||
|
||||
pub fn render(f: &mut Frame, app: &App) {
|
||||
match app.input_mode {
|
||||
InputMode::Move | InputMode::Rename => render_text_input(f, app),
|
||||
InputMode::Move | InputMode::Rename | InputMode::Filter => render_text_input(f, app),
|
||||
InputMode::ConfirmDelete(delete_local_data) => render_confirm_delete(f, delete_local_data),
|
||||
InputMode::None => {}
|
||||
}
|
||||
@ -21,6 +21,7 @@ fn render_text_input(f: &mut Frame, app: &App) {
|
||||
let title = match app.input_mode {
|
||||
InputMode::Move => "Move to",
|
||||
InputMode::Rename => "Rename",
|
||||
InputMode::Filter => "Filter",
|
||||
_ => return,
|
||||
};
|
||||
|
||||
|
||||
@ -51,12 +51,12 @@ pub fn render(app: &mut App, frame: &mut Frame) {
|
||||
frame.render_widget(tabs, chunks[0]); // renders tab
|
||||
|
||||
app.torrents.set_fields(None);
|
||||
let torrents = &app.torrents.torrents;
|
||||
let torrents = app.filtered_torrents();
|
||||
let selected = &app.torrents.selected;
|
||||
let colors = &app.config.colors;
|
||||
let fields = app.tabs()[app.index()].fields();
|
||||
|
||||
let table = build_table(torrents, selected, colors, app.tabs()[app.index()].fields());
|
||||
|
||||
let table = build_table(&torrents, selected, colors, fields);
|
||||
frame.render_stateful_widget(table, chunks[1], &mut app.state);
|
||||
|
||||
status::render(frame, app, chunks[2]);
|
||||
|
||||
@ -23,24 +23,32 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let uploaded = FileSize::new(total_uploaded.unsigned_abs());
|
||||
|
||||
let total = app.torrents.len();
|
||||
let filtered = app.filtered_torrents().len();
|
||||
let selected_count = app.torrents.selected.len();
|
||||
|
||||
let active_filter = app.active_filter();
|
||||
let count_info = if selected_count > 0 {
|
||||
format!("{selected_count}/{total}")
|
||||
} else if !active_filter.is_empty() {
|
||||
format!("{filtered}/{total}")
|
||||
} else {
|
||||
format!("{total}")
|
||||
};
|
||||
|
||||
let mode_text = match app.input_mode {
|
||||
InputMode::Move => Some("MOVE"),
|
||||
InputMode::Rename => Some("RENAME"),
|
||||
InputMode::ConfirmDelete(_) => Some("DELETE"),
|
||||
InputMode::Move => Some("MOVE".to_string()),
|
||||
InputMode::Rename => Some("RENAME".to_string()),
|
||||
InputMode::Filter => Some(format!("Filter: {active_filter}")),
|
||||
InputMode::ConfirmDelete(_) => Some("DELETE".to_string()),
|
||||
InputMode::None if !active_filter.is_empty() => Some(format!("Filter: {active_filter}")),
|
||||
InputMode::None => None,
|
||||
};
|
||||
|
||||
let keybinds = match app.input_mode {
|
||||
InputMode::None if !app.filter_text.is_empty() => "Esc Clear filter │ ? Help",
|
||||
InputMode::None => "? Help",
|
||||
InputMode::Move | InputMode::Rename => "Enter Submit │ Esc Cancel",
|
||||
InputMode::Filter => "Enter Confirm │ Esc Cancel",
|
||||
InputMode::ConfirmDelete(_) => "y Confirm │ n Cancel",
|
||||
};
|
||||
|
||||
@ -65,7 +73,7 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect) {
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded);
|
||||
|
||||
if let Some(mode) = mode_text {
|
||||
if let Some(ref mode) = mode_text {
|
||||
block = block
|
||||
.title(format!(" {mode} "))
|
||||
.title_style(Style::default().fg(Color::Yellow).bold());
|
||||
|
||||
@ -8,12 +8,12 @@ use ratatui::{
|
||||
use std::collections::HashSet;
|
||||
use transmission_rpc::types::{Torrent, TorrentGetField, TorrentStatus};
|
||||
|
||||
pub fn build_table<'a>(
|
||||
torrents: &'a [Torrent],
|
||||
pub fn build_table(
|
||||
torrents: &[&Torrent],
|
||||
selected: &HashSet<i64>,
|
||||
colors: &ColorConfig,
|
||||
fields: &[TorrentGetField],
|
||||
) -> Table<'a> {
|
||||
) -> Table<'static> {
|
||||
let row_style = row_style(colors);
|
||||
let header_style = header_style(colors);
|
||||
let highlight_row_style = hightlighted_row_style(colors);
|
||||
@ -60,13 +60,13 @@ fn hightlighted_row_style(cfg: &ColorConfig) -> Style {
|
||||
Style::default().bg(bg).fg(fg)
|
||||
}
|
||||
|
||||
fn make_row<'a>(
|
||||
torrent: &'a Torrent,
|
||||
fn make_row(
|
||||
torrent: &Torrent,
|
||||
fields: &[TorrentGetField],
|
||||
selected: &HashSet<i64>,
|
||||
highlight: Style,
|
||||
colors: &ColorConfig,
|
||||
) -> Row<'a> {
|
||||
) -> Row<'static> {
|
||||
let status_style = status_style(torrent.status, colors);
|
||||
|
||||
let cells = fields.iter().map(|&field| {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user