add fuzzy filename searching for sending files

This commit is contained in:
Joe Ardent 2025-08-13 14:29:03 -07:00
parent 3dd7a7281b
commit 8150bfacf2
5 changed files with 188 additions and 31 deletions

27
Cargo.lock generated
View file

@ -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"

View file

@ -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"] }

View file

@ -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<usize>,
working_dir: Option<PathBuf>,
input: Input,
}
fn searcher() -> SimSearch<usize> {
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<TransferEvent>,
file_picker: FileExplorer,
file_finder: FileFinder,
text: Option<String>,
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<TransferEvent>) -> 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<String> {
@ -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<Self> {
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;

View file

@ -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<Line> = 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(&current_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(

View file

@ -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() => {}
}
}