From 8150bfacf24622cbbc501d2674554891ec601122 Mon Sep 17 00:00:00 2001 From: Joe Ardent Date: Wed, 13 Aug 2025 14:29:03 -0700 Subject: [PATCH] add fuzzy filename searching for sending files --- Cargo.lock | 27 +++++++- Cargo.toml | 1 + src/app/mod.rs | 164 +++++++++++++++++++++++++++++++++++++++------ src/app/widgets.rs | 22 ++++-- src/main.rs | 5 +- 5 files changed, 188 insertions(+), 31 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index da8f976..3686068 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -414,7 +414,7 @@ dependencies = [ "anstream", "anstyle", "clap_lex", - "strsim", + "strsim 0.11.1", ] [[package]] @@ -545,7 +545,7 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "strsim", + "strsim 0.11.1", "syn 2.0.104", ] @@ -1348,6 +1348,7 @@ dependencies = [ "serde", "serde_json", "sha256", + "simsearch", "thiserror", "tokio", "tokio-rustls", @@ -2276,6 +2277,16 @@ dependencies = [ "libc", ] +[[package]] +name = "simsearch" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c869b25830e4824ef7279015cfc298a0674aca6a54eeff2efce8d12bf3701fe" +dependencies = [ + "strsim 0.10.0", + "triple_accel", +] + [[package]] name = "slab" version = "0.4.10" @@ -2310,6 +2321,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + [[package]] name = "strsim" version = "0.11.1" @@ -2678,6 +2695,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "triple_accel" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622b09ce2fe2df4618636fb92176d205662f59803f39e70d1c333393082de96c" + [[package]] name = "try-lock" version = "0.2.5" diff --git a/Cargo.toml b/Cargo.toml index cfdbe1e..b63452a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ rustix = { version = "1", default-features = false, features = ["system"] } serde = { version = "1", features = ["derive"] } serde_json = "1" sha256 = "1.6" +simsearch = "0.2" thiserror = "2" tokio = { version = "1", default-features = false, features = ["time", "macros", "rt-multi-thread"] } tokio-rustls = { version = "0.26", default-features = false, features = ["tls12", "logging"] } diff --git a/src/app/mod.rs b/src/app/mod.rs index 2cdd270..50ee51c 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,4 +1,9 @@ -use std::{collections::BTreeMap, net::SocketAddr, time::Duration}; +use std::{ + collections::BTreeMap, + net::SocketAddr, + path::{Path, PathBuf}, + time::Duration, +}; use crossterm::event::{Event, EventStream, KeyCode, KeyEvent, KeyEventKind}; use futures::{FutureExt, StreamExt}; @@ -7,9 +12,10 @@ use julid::Julid; use log::{LevelFilter, debug, error, warn}; use ratatui::{ Frame, - widgets::{ListState, TableState}, + widgets::{ListState, TableState, WidgetRef}, }; use ratatui_explorer::FileExplorer; +use simsearch::{SearchOptions, SimSearch}; use tokio::sync::mpsc::UnboundedReceiver; use tui_input::{Input, backend::crossterm::EventHandler}; @@ -22,6 +28,22 @@ pub struct Peer { pub addr: SocketAddr, } +#[derive(Clone)] +struct FileFinder { + explorer: FileExplorer, + fuzzy: SimSearch, + working_dir: Option, + input: Input, +} + +fn searcher() -> SimSearch { + SimSearch::new_with( + SearchOptions::new() + .stop_words(vec![std::path::MAIN_SEPARATOR_STR.to_string()]) + .stop_whitespace(false), + ) +} + pub struct App { pub service: JocalService, pub events: EventStream, @@ -33,7 +55,7 @@ pub struct App { // for getting messages back from the web server or web client about things we've done; the // other end is held by the service event_listener: UnboundedReceiver, - file_picker: FileExplorer, + file_finder: FileFinder, text: Option, input: Input, } @@ -49,18 +71,24 @@ pub enum CurrentScreen { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SendingScreen { - Files, + Files(FileMode), Peers, Text, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FileMode { + Picking, + Fuzzy, +} + impl App { pub fn new(service: JocalService, event_listener: UnboundedReceiver) -> Self { App { service, event_listener, screen: vec![CurrentScreen::Main], - file_picker: FileExplorer::new().expect("could not create file explorer"), + file_finder: FileFinder::new().expect("could not create file explorer"), text: None, events: Default::default(), peers: Default::default(), @@ -78,7 +106,7 @@ impl App { match evt { Event::Key(key) if key.kind == KeyEventKind::Press - => self.handle_key_event(key, evt).await, + => self.handle_key_event(key, evt).await, Event::Mouse(_) => {} Event::Resize(_, _) => {} _ => {} @@ -109,7 +137,7 @@ impl App { } pub fn files(&mut self) -> &mut FileExplorer { - &mut self.file_picker + &mut self.file_finder.explorer } pub fn text(&mut self) -> &mut Option { @@ -130,18 +158,18 @@ impl App { CurrentScreen::Main | CurrentScreen::Logging | CurrentScreen::Receiving - | CurrentScreen::Sending(SendingScreen::Files) + | CurrentScreen::Sending(SendingScreen::Files(FileMode::Picking)) | CurrentScreen::Sending(SendingScreen::Peers) => match code { - KeyCode::Esc => self.pop(), KeyCode::Char('q') => self.exit().await, KeyCode::Char('s') => self.send(), KeyCode::Char('r') => self.recv(), KeyCode::Char('l') => self.logs(), KeyCode::Char('m') => self.main(), + KeyCode::Esc => self.pop(), _ => match mode { CurrentScreen::Main => { - if code == KeyCode::Char('d') { - self.service.refresh_peers().await; + if let KeyCode::Char('d') = code { + self.service.refresh_peers().await } } CurrentScreen::Logging => match code { @@ -157,14 +185,20 @@ impl App { _ => {} }, CurrentScreen::Sending(sending_screen) => match sending_screen { - SendingScreen::Files => match code { + // we can only be in picking mode + SendingScreen::Files(fmode) => match code { KeyCode::Char('t') => *sending_screen = SendingScreen::Text, KeyCode::Tab => *sending_screen = SendingScreen::Peers, KeyCode::Enter => self.chdir_or_send_file().await, - _ => self.file_picker.handle(&event).unwrap_or_default(), + KeyCode::Char('/') => { + *fmode = FileMode::Fuzzy; + } + _ => self.file_finder.handle(&event).unwrap_or_default(), }, SendingScreen::Peers => match code { - KeyCode::Tab => *sending_screen = SendingScreen::Files, + KeyCode::Tab => { + *sending_screen = SendingScreen::Files(FileMode::Picking) + } KeyCode::Char('t') => *sending_screen = SendingScreen::Text, KeyCode::Enter => self.send_content().await, KeyCode::Up => self.peer_state.select_previous(), @@ -176,7 +210,7 @@ impl App { CurrentScreen::Stopping => unreachable!(), }, }, - // we only need to deal with sending text now + // we only need to deal with sending text now or doing fuzzy matching CurrentScreen::Sending(sending_screen) => match sending_screen { SendingScreen::Text => match code { KeyCode::Tab => *sending_screen = SendingScreen::Peers, @@ -184,7 +218,7 @@ impl App { KeyCode::Esc => { self.text = None; self.input.reset(); - *sending_screen = SendingScreen::Files; + *sending_screen = SendingScreen::Files(FileMode::Picking); } _ => { if let Some(changed) = self.input.handle_event(&event) @@ -198,8 +232,39 @@ impl App { } } }, - // we've already handled the other sending modes - SendingScreen::Files | SendingScreen::Peers => unreachable!(), + SendingScreen::Files(fmode) => { + if *fmode == FileMode::Fuzzy { + match code { + KeyCode::Tab => *sending_screen = SendingScreen::Peers, + KeyCode::Enter => self.chdir_or_send_file().await, + KeyCode::Esc => { + self.file_finder.reset_fuzzy(); + *fmode = FileMode::Picking; + } + KeyCode::Up | KeyCode::Down => { + if let Err(e) = self.file_finder.handle(&event) { + log::error!("error selecting file: {e:?}"); + } + } + _ => { + self.file_finder.index(); + if let Some(changed) = self.file_finder.input.handle_event(&event) + && changed.value + { + let id = self + .file_finder + .fuzzy + .search(self.file_finder.input.value()) + .first() + .copied() + .unwrap_or(0); + self.file_finder.explorer.set_selected_idx(id); + } + } + } + } + } + SendingScreen::Peers => unreachable!(), }, CurrentScreen::Stopping => {} } @@ -220,7 +285,9 @@ impl App { Some(CurrentScreen::Sending(_)) => {} _ => self .screen - .push(CurrentScreen::Sending(SendingScreen::Files)), + .push(CurrentScreen::Sending(SendingScreen::Files( + FileMode::Picking, + ))), } } @@ -295,12 +362,14 @@ impl App { } async fn chdir_or_send_file(&mut self) { - let file = self.file_picker.current().path().clone(); + let file = self.file_finder.explorer.current().path().clone(); if file.is_dir() - && let Err(e) = self.file_picker.set_cwd(&file) + && let Err(e) = self.file_finder.set_cwd(&file) { error!("could not list directory {file:?}: {e}"); return; + } else if file.is_dir() { + return; } let Some(peer_idx) = self.peer_state.selected() else { @@ -352,6 +421,59 @@ impl App { } } +impl FileFinder { + pub fn new() -> Result { + Ok(Self { + explorer: FileExplorer::new()?, + fuzzy: searcher(), + working_dir: None, + input: Default::default(), + }) + } + + pub fn handle(&mut self, event: &Event) -> Result<()> { + self.index(); + Ok(self.explorer.handle(event)?) + } + + pub fn cwd(&self) -> &Path { + self.explorer.cwd() + } + + pub fn set_cwd(&mut self, cwd: &Path) -> Result<()> { + self.explorer.set_cwd(cwd)?; + self.index(); + Ok(()) + } + + pub fn widget(&self) -> impl WidgetRef { + self.explorer.widget() + } + + pub fn reset_fuzzy(&mut self) { + self.clear_fuzzy(); + self.input.reset(); + } + + fn clear_fuzzy(&mut self) { + self.fuzzy = searcher(); + } + + fn index(&mut self) { + if let Some(owd) = self.working_dir.as_ref() + && owd == self.cwd() + { + return; + } + self.working_dir = Some(self.cwd().to_path_buf()); + self.clear_fuzzy(); + + for (i, f) in self.explorer.files().iter().enumerate() { + self.fuzzy.insert(i, f.name()); + } + } +} + fn change_log_level(delta: isize) { let level = log::max_level() as isize; let max = log::LevelFilter::max() as isize; diff --git a/src/app/widgets.rs b/src/app/widgets.rs index 3b1b43b..f457839 100644 --- a/src/app/widgets.rs +++ b/src/app/widgets.rs @@ -15,7 +15,7 @@ use ratatui::{ }; use tui_logger::{TuiLoggerLevelOutput, TuiLoggerWidget, TuiWidgetState}; -use super::{App, CurrentScreen, Peer, SendingScreen}; +use super::{App, CurrentScreen, FileMode, Peer, SendingScreen}; static MAIN_MENU: LazyLock = LazyLock::new(|| { Line::from(vec![ @@ -175,7 +175,7 @@ impl Widget for &mut App { } CurrentScreen::Sending(s) => { match s { - SendingScreen::Files => { + SendingScreen::Files(_) => { outer_frame(¤t_screen, &CONTENT_SEND_FILE_MENU, area, buf) } SendingScreen::Peers => { @@ -186,9 +186,21 @@ impl Widget for &mut App { } } - self.file_picker - .widget() - .render(header_left.inner(header_margin), buf); + let file_area = header_left.inner(header_margin); + + match s { + SendingScreen::Files(FileMode::Picking) => { + self.file_finder.widget().render(file_area, buf); + } + SendingScreen::Files(FileMode::Fuzzy) => { + let layout = Layout::vertical([Constraint::Max(6), Constraint::Min(5)]); + let [input, files] = layout.areas(file_area); + text_popup(self.file_finder.input.value(), "fuzzy search", input, buf); + self.file_finder.widget().render(files, buf); + } + _ => {} + } + logger(header_right.inner(header_margin), buf); peers( diff --git a/src/main.rs b/src/main.rs index 72b3ea9..a40114e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -69,13 +69,12 @@ async fn start_and_run(terminal: &mut DefaultTerminal, config: Config) -> Result } if app.screen() == CurrentScreen::Stopping { + log::info!("shutting down"); tokio::select! { _ = shutdown.as_mut() => { break; } - _ = tick.tick() => { - log::info!("shutting down"); - } + _ = tick.tick() => {} } }