Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/tui-searcher/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ ratatui = "0.29.0"
frizbee = { git = "https://github.com/saghen/frizbee.git", branch = "main" }
anyhow = "1.0.100"
tui-textarea = "0.7.0"
walkdir = "2.5.0"
23 changes: 15 additions & 8 deletions crates/tui-searcher/README.md
Original file line number Diff line number Diff line change
@@ -1,30 +1,37 @@
# tui-searcher

A small, configurable TUI searcher (fzf-like).
A small, configurable TUI fuzzy finder inspired by `fzf`.

Features
## Features
- Interactive TUI built on `ratatui`.
- Uses `frizbee` fuzzy matching for typo-tolerant search.
- Builder-style API to configure prompts, column headers and widths.
- Ready-to-use filesystem scanner (`Searcher::filesystem`) that walks directories recursively.
- Rich outcome information including which entry was selected and the final query string.

Quick example
## Quick example

```rust
use tui_searcher::{SearchMode, Searcher, UiConfig};
use tui_searcher::{SearchData, SearchMode, Searcher, UiConfig};

let data = SearchData::from_filesystem(".")?;
let outcome = Searcher::new(data)
.with_ui_config(UiConfig::tags_and_files())
.with_input_title("Search repo")
.with_headers_for(SearchMode::Facets, vec!["Tag", "Count", "Score"])
.with_start_mode(SearchMode::Files)
.run()?;

if let Some(file) = outcome.selected_file() {
println!("Selected file: {}", file.path);
}
```

Run the example
## Run the examples

```bash
cargo run -p tui_searcher --example demo
cargo run -p tui_searcher --example filesystem -- /path/to/project
```

Notes
## Notes
- This crate is meant to be a reusable component. You can integrate it into your own CLIs and customize headers/column widths using the builder API.
- The underlying matching behavior is provided by `frizbee`.
40 changes: 20 additions & 20 deletions crates/tui-searcher/examples/demo.rs
Original file line number Diff line number Diff line change
@@ -1,32 +1,32 @@
use tui_searcher::{FacetRow, FileRow, SearchData, Searcher};
use tui_searcher::{
FacetRow, FileRow, SearchData, SearchMode, SearchSelection, Searcher, UiConfig,
};

fn main() -> anyhow::Result<()> {
// Build sample data
let facets = vec![
FacetRow {
name: "frontend".into(),
count: 3,
},
FacetRow {
name: "backend".into(),
count: 2,
},
];
let facets = vec![FacetRow::new("frontend", 3), FacetRow::new("backend", 2)];
let files = vec![
FileRow::new("src/main.rs".into(), vec!["frontend".into()]),
FileRow::new("src/lib.rs".into(), vec!["backend".into()]),
FileRow::new("src/main.rs", ["frontend"]),
FileRow::new("src/lib.rs", ["backend"]),
];

let data = SearchData {
repo_display: "example/repo".into(),
user_filter: "".into(),
facets,
files,
};
let data = SearchData::new()
.with_context("example/repo")
.with_initial_query("")
.with_facets(facets)
.with_files(files);

// Minimal searcher configuration with prompt
let searcher = Searcher::new(data).with_input_title("workspace-prototype");
let searcher = Searcher::new(data)
.with_ui_config(UiConfig::tags_and_files())
.with_input_title("workspace-prototype")
.with_start_mode(SearchMode::Facets);
let outcome = searcher.run()?;
println!("Accepted? {}", outcome.accepted);
match outcome.selection {
Some(SearchSelection::File(file)) => println!("Selected file: {}", file.path),
Some(SearchSelection::Facet(facet)) => println!("Selected facet: {}", facet.name),
None => println!("No selection"),
}
Ok(())
}
33 changes: 33 additions & 0 deletions crates/tui-searcher/examples/filesystem.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
use std::env;
use std::path::PathBuf;

use tui_searcher::{SearchSelection, Searcher};

fn main() -> anyhow::Result<()> {
let root = env::args()
.nth(1)
.map(PathBuf::from)
.unwrap_or_else(|| env::current_dir().expect("failed to resolve current dir"));

let title = root
.file_name()
.and_then(|name| name.to_str().map(|s| s.to_string()))
.unwrap_or_else(|| root.to_string_lossy().into_owned());

let searcher = Searcher::filesystem(&root)?.with_input_title(title);

let outcome = searcher.run()?;

if !outcome.accepted {
println!("Search cancelled (query: '{}')", outcome.query);
return Ok(());
}

match outcome.selection {
Some(SearchSelection::File(file)) => println!("{}", file.path),
Some(SearchSelection::Facet(facet)) => println!("Facet: {}", facet.name),
None => println!("No selection"),
}

Ok(())
}
51 changes: 46 additions & 5 deletions crates/tui-searcher/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use crate::input::SearchInput;
use crate::tables;
use crate::tabs;
use crate::theme::Theme;
use crate::types::{SearchData, SearchMode, SearchOutcome, UiConfig};
use crate::types::{SearchData, SearchMode, SearchOutcome, SearchSelection, UiConfig};
use frizbee::{Config, match_list};

const PREFILTER_ENABLE_THRESHOLD: usize = 1_000;
Expand Down Expand Up @@ -49,17 +49,19 @@ impl<'a> App<'a> {
prefilter: false,
..Config::default()
};
let initial_query = data.initial_query.clone();
let context_label = data.context_label.clone();
let mut app = Self {
data,
mode: SearchMode::Facets,
search_input: SearchInput::default(),
search_input: SearchInput::new(initial_query),
table_state,
filtered_facets: Vec::new(),
filtered_files: Vec::new(),
facet_scores: Vec::new(),
file_scores: Vec::new(),
matcher_config,
input_title: None,
input_title: context_label,
facet_headers: None,
file_headers: None,
facet_widths: None,
Expand All @@ -76,6 +78,14 @@ impl<'a> App<'a> {
self.theme = theme;
}

pub fn set_mode(&mut self, mode: SearchMode) {
if self.mode != mode {
self.mode = mode;
self.table_state.select(Some(0));
self.refresh();
}
}

/// Run the interactive application. This is a method so callers can
/// customize `App` fields before launching (used by the `Searcher`
/// builder in the crate root).
Expand Down Expand Up @@ -196,10 +206,19 @@ impl<'a> App<'a> {
fn handle_key(&mut self, key: KeyEvent) -> Result<Option<SearchOutcome>> {
match key.code {
KeyCode::Esc => {
return Ok(Some(SearchOutcome { accepted: false }));
return Ok(Some(SearchOutcome {
accepted: false,
selection: None,
query: self.search_input.text().to_string(),
}));
}
KeyCode::Enter => {
return Ok(Some(SearchOutcome { accepted: true }));
let selection = self.current_selection();
return Ok(Some(SearchOutcome {
accepted: true,
selection,
query: self.search_input.text().to_string(),
}));
}
KeyCode::Tab => {
self.switch_mode();
Expand Down Expand Up @@ -246,6 +265,28 @@ impl<'a> App<'a> {
}
}

fn current_selection(&self) -> Option<SearchSelection> {
let selected = self.table_state.selected()?;
match self.mode {
SearchMode::Facets => {
let index = *self.filtered_facets.get(selected)?;
self.data
.facets
.get(index)
.cloned()
.map(SearchSelection::Facet)
}
SearchMode::Files => {
let index = *self.filtered_files.get(selected)?;
self.data
.files
.get(index)
.cloned()
.map(SearchSelection::File)
}
}
}

fn filtered_len(&self) -> usize {
match self.mode {
SearchMode::Facets => self.filtered_facets.len(),
Expand Down
21 changes: 20 additions & 1 deletion crates/tui-searcher/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ pub mod utils;
pub use app::run;
pub use input::SearchInput;
pub use theme::{LIGHT, SLATE, SOLARIZED, Theme};
pub use types::{FacetRow, FileRow, PaneUiConfig, SearchData, SearchMode, UiConfig};
pub use types::{
FacetRow, FileRow, PaneUiConfig, SearchData, SearchMode, SearchOutcome, SearchSelection,
UiConfig,
};

use ratatui::layout::Constraint;

Expand All @@ -25,6 +28,7 @@ pub struct Searcher {
file_widths: Option<Vec<Constraint>>,
ui_config: Option<UiConfig>,
theme: Option<Theme>,
start_mode: Option<SearchMode>,
}

impl Searcher {
Expand All @@ -39,9 +43,16 @@ impl Searcher {
file_widths: None,
ui_config: None,
theme: None,
start_mode: None,
}
}

/// Create a searcher pre-populated with files from the filesystem rooted at `path`.
pub fn filesystem(path: impl AsRef<std::path::Path>) -> anyhow::Result<Self> {
let data = SearchData::from_filesystem(path)?;
Ok(Self::new(data).with_start_mode(SearchMode::Files))
}

pub fn with_input_title(mut self, title: impl Into<String>) -> Self {
self.input_title = Some(title.into());
self
Expand Down Expand Up @@ -81,6 +92,11 @@ impl Searcher {
self
}

pub fn with_start_mode(mut self, mode: SearchMode) -> Self {
self.start_mode = Some(mode);
self
}

/// Run the interactive searcher with the configured options.
pub fn run(self) -> anyhow::Result<crate::types::SearchOutcome> {
// Build an App and apply optional customizations, then run it.
Expand All @@ -106,6 +122,9 @@ impl Searcher {
if let Some(theme) = self.theme {
app.set_theme(theme);
}
if let Some(mode) = self.start_mode {
app.set_mode(mode);
}

app.run()
}
Expand Down
Loading
Loading