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",
|
"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]]
|
[[package]]
|
||||||
name = "generic-array"
|
name = "generic-array"
|
||||||
version = "0.14.7"
|
version = "0.14.7"
|
||||||
@ -2513,6 +2522,7 @@ dependencies = [
|
|||||||
"crossterm",
|
"crossterm",
|
||||||
"derive_more",
|
"derive_more",
|
||||||
"dirs",
|
"dirs",
|
||||||
|
"fuzzy-matcher",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
"serde",
|
"serde",
|
||||||
"thiserror 2.0.12",
|
"thiserror 2.0.12",
|
||||||
|
|||||||
@ -10,6 +10,7 @@ color-eyre = "0.6"
|
|||||||
crossterm = "0.29"
|
crossterm = "0.29"
|
||||||
derive_more = { version = "2.1", features = ["display"] }
|
derive_more = { version = "2.1", features = ["display"] }
|
||||||
dirs = "6.0"
|
dirs = "6.0"
|
||||||
|
fuzzy-matcher = "0.3"
|
||||||
ratatui = "0.30"
|
ratatui = "0.30"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
thiserror = "2.0"
|
thiserror = "2.0"
|
||||||
|
|||||||
@ -35,6 +35,10 @@ rename_torrent = "r"
|
|||||||
delete = "d"
|
delete = "d"
|
||||||
delete_force = "D"
|
delete_force = "D"
|
||||||
|
|
||||||
|
# Search/filter
|
||||||
|
filter = "/"
|
||||||
|
clear_filter = "escape"
|
||||||
|
|
||||||
# General
|
# General
|
||||||
toggle_help = "?"
|
toggle_help = "?"
|
||||||
quit = "q"
|
quit = "q"
|
||||||
|
|||||||
@ -30,6 +30,10 @@ pub enum Action {
|
|||||||
Delete(bool),
|
Delete(bool),
|
||||||
#[display("Rename Torrent")]
|
#[display("Rename Torrent")]
|
||||||
Rename,
|
Rename,
|
||||||
|
#[display("Filter")]
|
||||||
|
Filter,
|
||||||
|
#[display("Clear Filter")]
|
||||||
|
ClearFilter,
|
||||||
#[display("Select")]
|
#[display("Select")]
|
||||||
Select,
|
Select,
|
||||||
#[display("Submit")]
|
#[display("Submit")]
|
||||||
|
|||||||
@ -9,6 +9,7 @@ pub mod utils;
|
|||||||
|
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::{app::input::InputHandler, config::Config};
|
use crate::{app::input::InputHandler, config::Config};
|
||||||
|
use fuzzy_matcher::{FuzzyMatcher, skim::SkimMatcherV2};
|
||||||
use ratatui::widgets::TableState;
|
use ratatui::widgets::TableState;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use types::Selected;
|
use types::Selected;
|
||||||
@ -21,6 +22,7 @@ pub enum InputMode {
|
|||||||
None,
|
None,
|
||||||
Move,
|
Move,
|
||||||
Rename,
|
Rename,
|
||||||
|
Filter,
|
||||||
/// Confirm delete dialog. Bool indicates whether to delete local data.
|
/// Confirm delete dialog. Bool indicates whether to delete local data.
|
||||||
ConfirmDelete(bool),
|
ConfirmDelete(bool),
|
||||||
}
|
}
|
||||||
@ -37,6 +39,7 @@ pub struct App {
|
|||||||
pub config: Config,
|
pub config: Config,
|
||||||
pub input_handler: InputHandler,
|
pub input_handler: InputHandler,
|
||||||
pub input_mode: InputMode,
|
pub input_mode: InputMode,
|
||||||
|
pub filter_text: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
@ -58,6 +61,7 @@ impl App {
|
|||||||
config,
|
config,
|
||||||
input_handler: InputHandler::new(),
|
input_handler: InputHandler::new(),
|
||||||
input_mode: InputMode::None,
|
input_mode: InputMode::None,
|
||||||
|
filter_text: String::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -121,12 +125,15 @@ impl App {
|
|||||||
#[inline]
|
#[inline]
|
||||||
pub fn prev_tab(&mut self) {
|
pub fn prev_tab(&mut self) {
|
||||||
self.close_help();
|
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.
|
/// Switches to the tab whose index is `idx` if it exists.
|
||||||
#[inline]
|
#[inline]
|
||||||
pub fn switch_tab(&mut self, idx: usize) {
|
pub const fn switch_tab(&mut self, idx: usize) {
|
||||||
if idx < self.tabs.len() {
|
if idx < self.tabs.len() {
|
||||||
self.close_help();
|
self.close_help();
|
||||||
self.index = idx;
|
self.index = idx;
|
||||||
@ -227,6 +234,61 @@ impl App {
|
|||||||
self.close_help();
|
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.
|
/// Prepare delete confirmation dialog.
|
||||||
pub const fn prepare_delete(&mut self, delete_local_data: bool) {
|
pub const fn prepare_delete(&mut self, delete_local_data: bool) {
|
||||||
self.input_mode = InputMode::ConfirmDelete(delete_local_data);
|
self.input_mode = InputMode::ConfirmDelete(delete_local_data);
|
||||||
|
|||||||
@ -25,4 +25,6 @@ pub struct KeybindsConfig {
|
|||||||
pub toggle_help: String,
|
pub toggle_help: String,
|
||||||
pub move_torrent: String,
|
pub move_torrent: String,
|
||||||
pub rename_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::ToggleHelp, &keybinds.toggle_help),
|
||||||
(Action::Move, &keybinds.move_torrent),
|
(Action::Move, &keybinds.move_torrent),
|
||||||
(Action::Rename, &keybinds.rename_torrent),
|
(Action::Rename, &keybinds.rename_torrent),
|
||||||
|
(Action::Filter, &keybinds.filter),
|
||||||
|
(Action::ClearFilter, &keybinds.clear_filter),
|
||||||
]
|
]
|
||||||
.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)))
|
||||||
@ -100,11 +102,14 @@ pub async fn update(app: &mut App, action: Action) -> Result<()> {
|
|||||||
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::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::Delete(delete_local_data) => app.prepare_delete(delete_local_data),
|
||||||
Action::Select => app.select(),
|
Action::Select => app.select(),
|
||||||
Action::Submit => match app.input_mode {
|
Action::Submit => match app.input_mode {
|
||||||
InputMode::Move => app.move_torrent().await?,
|
InputMode::Move => app.move_torrent().await?,
|
||||||
InputMode::Rename => app.rename_torrent().await?,
|
InputMode::Rename => app.rename_torrent().await?,
|
||||||
|
InputMode::Filter => app.apply_filter(),
|
||||||
InputMode::None | InputMode::ConfirmDelete(_) => {}
|
InputMode::None | InputMode::ConfirmDelete(_) => {}
|
||||||
},
|
},
|
||||||
Action::ConfirmYes => app.confirm_delete().await?,
|
Action::ConfirmYes => app.confirm_delete().await?,
|
||||||
|
|||||||
@ -8,7 +8,7 @@ use tracing::warn;
|
|||||||
|
|
||||||
pub fn render(f: &mut Frame, app: &App) {
|
pub fn render(f: &mut Frame, app: &App) {
|
||||||
match app.input_mode {
|
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::ConfirmDelete(delete_local_data) => render_confirm_delete(f, delete_local_data),
|
||||||
InputMode::None => {}
|
InputMode::None => {}
|
||||||
}
|
}
|
||||||
@ -21,6 +21,7 @@ fn render_text_input(f: &mut Frame, app: &App) {
|
|||||||
let title = match app.input_mode {
|
let title = match app.input_mode {
|
||||||
InputMode::Move => "Move to",
|
InputMode::Move => "Move to",
|
||||||
InputMode::Rename => "Rename",
|
InputMode::Rename => "Rename",
|
||||||
|
InputMode::Filter => "Filter",
|
||||||
_ => return,
|
_ => return,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -51,12 +51,12 @@ pub fn render(app: &mut App, frame: &mut Frame) {
|
|||||||
frame.render_widget(tabs, chunks[0]); // renders tab
|
frame.render_widget(tabs, chunks[0]); // renders tab
|
||||||
|
|
||||||
app.torrents.set_fields(None);
|
app.torrents.set_fields(None);
|
||||||
let torrents = &app.torrents.torrents;
|
let torrents = app.filtered_torrents();
|
||||||
let selected = &app.torrents.selected;
|
let selected = &app.torrents.selected;
|
||||||
let colors = &app.config.colors;
|
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);
|
frame.render_stateful_widget(table, chunks[1], &mut app.state);
|
||||||
|
|
||||||
status::render(frame, app, chunks[2]);
|
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 uploaded = FileSize::new(total_uploaded.unsigned_abs());
|
||||||
|
|
||||||
let total = app.torrents.len();
|
let total = app.torrents.len();
|
||||||
|
let filtered = app.filtered_torrents().len();
|
||||||
let selected_count = app.torrents.selected.len();
|
let selected_count = app.torrents.selected.len();
|
||||||
|
|
||||||
|
let active_filter = app.active_filter();
|
||||||
let count_info = if selected_count > 0 {
|
let count_info = if selected_count > 0 {
|
||||||
format!("{selected_count}/{total}")
|
format!("{selected_count}/{total}")
|
||||||
|
} else if !active_filter.is_empty() {
|
||||||
|
format!("{filtered}/{total}")
|
||||||
} else {
|
} else {
|
||||||
format!("{total}")
|
format!("{total}")
|
||||||
};
|
};
|
||||||
|
|
||||||
let mode_text = match app.input_mode {
|
let mode_text = match app.input_mode {
|
||||||
InputMode::Move => Some("MOVE"),
|
InputMode::Move => Some("MOVE".to_string()),
|
||||||
InputMode::Rename => Some("RENAME"),
|
InputMode::Rename => Some("RENAME".to_string()),
|
||||||
InputMode::ConfirmDelete(_) => Some("DELETE"),
|
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,
|
InputMode::None => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let keybinds = match app.input_mode {
|
let keybinds = match app.input_mode {
|
||||||
|
InputMode::None if !app.filter_text.is_empty() => "Esc Clear filter │ ? Help",
|
||||||
InputMode::None => "? Help",
|
InputMode::None => "? Help",
|
||||||
InputMode::Move | InputMode::Rename => "Enter Submit │ Esc Cancel",
|
InputMode::Move | InputMode::Rename => "Enter Submit │ Esc Cancel",
|
||||||
|
InputMode::Filter => "Enter Confirm │ Esc Cancel",
|
||||||
InputMode::ConfirmDelete(_) => "y Confirm │ n Cancel",
|
InputMode::ConfirmDelete(_) => "y Confirm │ n Cancel",
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -65,7 +73,7 @@ pub fn render(frame: &mut Frame, app: &App, area: Rect) {
|
|||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.border_type(BorderType::Rounded);
|
.border_type(BorderType::Rounded);
|
||||||
|
|
||||||
if let Some(mode) = mode_text {
|
if let Some(ref mode) = mode_text {
|
||||||
block = block
|
block = block
|
||||||
.title(format!(" {mode} "))
|
.title(format!(" {mode} "))
|
||||||
.title_style(Style::default().fg(Color::Yellow).bold());
|
.title_style(Style::default().fg(Color::Yellow).bold());
|
||||||
|
|||||||
@ -8,12 +8,12 @@ use ratatui::{
|
|||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use transmission_rpc::types::{Torrent, TorrentGetField, TorrentStatus};
|
use transmission_rpc::types::{Torrent, TorrentGetField, TorrentStatus};
|
||||||
|
|
||||||
pub fn build_table<'a>(
|
pub fn build_table(
|
||||||
torrents: &'a [Torrent],
|
torrents: &[&Torrent],
|
||||||
selected: &HashSet<i64>,
|
selected: &HashSet<i64>,
|
||||||
colors: &ColorConfig,
|
colors: &ColorConfig,
|
||||||
fields: &[TorrentGetField],
|
fields: &[TorrentGetField],
|
||||||
) -> Table<'a> {
|
) -> Table<'static> {
|
||||||
let row_style = row_style(colors);
|
let row_style = row_style(colors);
|
||||||
let header_style = header_style(colors);
|
let header_style = header_style(colors);
|
||||||
let highlight_row_style = hightlighted_row_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)
|
Style::default().bg(bg).fg(fg)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn make_row<'a>(
|
fn make_row(
|
||||||
torrent: &'a Torrent,
|
torrent: &Torrent,
|
||||||
fields: &[TorrentGetField],
|
fields: &[TorrentGetField],
|
||||||
selected: &HashSet<i64>,
|
selected: &HashSet<i64>,
|
||||||
highlight: Style,
|
highlight: Style,
|
||||||
colors: &ColorConfig,
|
colors: &ColorConfig,
|
||||||
) -> Row<'a> {
|
) -> Row<'static> {
|
||||||
let status_style = status_style(torrent.status, colors);
|
let status_style = status_style(torrent.status, colors);
|
||||||
|
|
||||||
let cells = fields.iter().map(|&field| {
|
let cells = fields.iter().map(|&field| {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user