diff --git a/Cargo.lock b/Cargo.lock index 45d5405..9ceeb2f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2330,6 +2330,7 @@ dependencies = [ "frizbee", "ratatui", "tui-textarea", + "walkdir", ] [[package]] diff --git a/crates/tui-searcher/Cargo.toml b/crates/tui-searcher/Cargo.toml index c79bb57..90072b5 100644 --- a/crates/tui-searcher/Cargo.toml +++ b/crates/tui-searcher/Cargo.toml @@ -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" diff --git a/crates/tui-searcher/README.md b/crates/tui-searcher/README.md index 55a7931..ad6212a 100644 --- a/crates/tui-searcher/README.md +++ b/crates/tui-searcher/README.md @@ -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`. diff --git a/crates/tui-searcher/examples/demo.rs b/crates/tui-searcher/examples/demo.rs index 9f3319d..7318ba7 100644 --- a/crates/tui-searcher/examples/demo.rs +++ b/crates/tui-searcher/examples/demo.rs @@ -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(()) } diff --git a/crates/tui-searcher/examples/filesystem.rs b/crates/tui-searcher/examples/filesystem.rs new file mode 100644 index 0000000..c706a7b --- /dev/null +++ b/crates/tui-searcher/examples/filesystem.rs @@ -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(()) +} diff --git a/crates/tui-searcher/src/app.rs b/crates/tui-searcher/src/app.rs index de5184c..16758b3 100644 --- a/crates/tui-searcher/src/app.rs +++ b/crates/tui-searcher/src/app.rs @@ -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; @@ -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, @@ -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). @@ -196,10 +206,19 @@ impl<'a> App<'a> { fn handle_key(&mut self, key: KeyEvent) -> Result> { 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(); @@ -246,6 +265,28 @@ impl<'a> App<'a> { } } + fn current_selection(&self) -> Option { + 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(), diff --git a/crates/tui-searcher/src/lib.rs b/crates/tui-searcher/src/lib.rs index b53572e..65a77e1 100644 --- a/crates/tui-searcher/src/lib.rs +++ b/crates/tui-searcher/src/lib.rs @@ -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; @@ -25,6 +28,7 @@ pub struct Searcher { file_widths: Option>, ui_config: Option, theme: Option, + start_mode: Option, } impl Searcher { @@ -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) -> anyhow::Result { + 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) -> Self { self.input_title = Some(title.into()); self @@ -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 { // Build an App and apply optional customizations, then run it. @@ -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() } diff --git a/crates/tui-searcher/src/types.rs b/crates/tui-searcher/src/types.rs index 80ea931..0fb88e5 100644 --- a/crates/tui-searcher/src/types.rs +++ b/crates/tui-searcher/src/types.rs @@ -1,7 +1,11 @@ +use anyhow::{Context, Result}; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span, Text}; use ratatui::widgets::Cell; +use std::collections::{BTreeMap, BTreeSet}; use std::mem; +use std::path::{Component, Path}; +use walkdir::WalkDir; #[derive(Debug, Clone)] pub struct FacetRow { @@ -9,6 +13,16 @@ pub struct FacetRow { pub count: usize, } +impl FacetRow { + #[must_use] + pub fn new(name: impl Into, count: usize) -> Self { + Self { + name: name.into(), + count, + } + } +} + #[derive(Debug, Clone)] pub struct FileRow { pub path: String, @@ -19,8 +33,13 @@ pub struct FileRow { impl FileRow { #[must_use] - pub fn new(path: String, tags: Vec) -> Self { - let mut tags_sorted = tags; + pub fn new(path: impl Into, tags: I) -> Self + where + I: IntoIterator, + S: Into, + { + let path = path.into(); + let mut tags_sorted: Vec = tags.into_iter().map(Into::into).collect(); tags_sorted.sort(); let display_tags = tags_sorted.join(", "); let search_text = if display_tags.is_empty() { @@ -155,14 +174,139 @@ impl SearchMode { } pub struct SearchData { - pub repo_display: String, - pub user_filter: String, + pub context_label: Option, + pub initial_query: String, pub facets: Vec, pub files: Vec, } +impl Default for SearchData { + fn default() -> Self { + Self { + context_label: None, + initial_query: String::new(), + facets: Vec::new(), + files: Vec::new(), + } + } +} + +impl SearchData { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + #[must_use] + pub fn with_context(mut self, label: impl Into) -> Self { + self.context_label = Some(label.into()); + self + } + + #[must_use] + pub fn with_initial_query(mut self, query: impl Into) -> Self { + self.initial_query = query.into(); + self + } + + #[must_use] + pub fn with_facets(mut self, facets: Vec) -> Self { + self.facets = facets; + self + } + + #[must_use] + pub fn with_files(mut self, files: Vec) -> Self { + self.files = files; + self + } + + pub fn from_filesystem(root: impl AsRef) -> Result { + let root = root.as_ref(); + let mut files = Vec::new(); + let mut facet_counts: BTreeMap = BTreeMap::new(); + + for entry in WalkDir::new(root).into_iter() { + let entry = entry.with_context(|| format!("failed to walk {}", root.display()))?; + if !entry.file_type().is_file() { + continue; + } + + let path = entry.path(); + let relative = path.strip_prefix(root).unwrap_or(path); + let mut tags: BTreeSet = BTreeSet::new(); + + if let Some(parent) = relative.parent() { + for component in parent.components() { + if let Component::Normal(part) = component { + let value = part.to_string_lossy().to_string(); + if !value.is_empty() { + tags.insert(value); + } + } + } + } + + if let Some(ext) = relative.extension().and_then(|ext| ext.to_str()) { + if !ext.is_empty() { + tags.insert(format!("*.{ext}")); + } + } + + let tags_vec: Vec = tags.into_iter().collect(); + for tag in &tags_vec { + *facet_counts.entry(tag.clone()).or_default() += 1; + } + + let relative_display = relative.to_string_lossy().replace("\\", "/"); + files.push(FileRow::new(relative_display, tags_vec)); + } + + files.sort_by(|a, b| a.path.cmp(&b.path)); + + let facets = facet_counts + .into_iter() + .map(|(name, count)| FacetRow::new(name, count)) + .collect(); + + Ok(Self { + context_label: Some(root.display().to_string()), + initial_query: String::new(), + facets, + files, + }) + } +} + +#[derive(Debug, Clone)] pub struct SearchOutcome { pub accepted: bool, + pub selection: Option, + pub query: String, +} + +#[derive(Debug, Clone)] +pub enum SearchSelection { + Facet(FacetRow), + File(FileRow), +} + +impl SearchOutcome { + #[must_use] + pub fn selected_file(&self) -> Option<&FileRow> { + match self.selection { + Some(SearchSelection::File(ref file)) => Some(file), + _ => None, + } + } + + #[must_use] + pub fn selected_facet(&self) -> Option<&FacetRow> { + match self.selection { + Some(SearchSelection::Facet(ref facet)) => Some(facet), + _ => None, + } + } } pub(crate) fn highlight_cell(text: &str, indices: Option>) -> Cell<'_> { diff --git a/examples/demo.rs b/examples/demo.rs index ea4d472..f658ce3 100644 --- a/examples/demo.rs +++ b/examples/demo.rs @@ -1,147 +1,78 @@ mod common; use clap::Parser; use common::{Opts, apply_theme}; -use git_sparta::tui::{self, FacetRow, FileRow, SearchData}; +use git_sparta::tui::{self, FacetRow, FileRow, SearchData, SearchSelection}; fn main() -> anyhow::Result<()> { let opts = Opts::parse(); - let facets = vec![ - FacetRow { - name: "app/core".into(), - count: 12, - }, - FacetRow { - name: "app/ui".into(), - count: 9, - }, - FacetRow { - name: "docs".into(), - count: 4, - }, - FacetRow { - name: "ops".into(), - count: 6, - }, - FacetRow { - name: "tooling".into(), - count: 8, - }, - FacetRow { - name: "infra".into(), - count: 5, - }, - FacetRow { - name: "ci".into(), - count: 3, - }, - FacetRow { - name: "tests".into(), - count: 7, - }, - FacetRow { - name: "examples".into(), - count: 2, - }, - FacetRow { - name: "legacy".into(), - count: 1, - }, - FacetRow { - name: "frontend".into(), - count: 10, - }, - FacetRow { - name: "backend".into(), - count: 11, - }, - FacetRow { - name: "api".into(), - count: 6, - }, - FacetRow { - name: "db".into(), - count: 4, - }, - FacetRow { - name: "scripts".into(), - count: 3, - }, - ]; - let files = vec![ - FileRow::new( - "src/main.rs".into(), - vec!["app/core".into(), "app/ui".into()], - ), - FileRow::new( - "src/ui/search.rs".into(), - vec!["app/ui".into(), "tooling".into()], - ), - FileRow::new("docs/overview.md".into(), vec!["docs".into()]), - FileRow::new( - "ops/terraform/main.tf".into(), - vec!["ops".into(), "tooling".into()], - ), - FileRow::new("tooling/dev-env.nix".into(), vec!["tooling".into()]), - FileRow::new("infra/docker-compose.yml".into(), vec!["infra".into()]), - FileRow::new("ci/build.yml".into(), vec!["ci".into()]), - FileRow::new( - "tests/test_main.rs".into(), - vec!["tests".into(), "app/core".into()], - ), - FileRow::new( - "examples/demo.rs".into(), - vec!["examples".into(), "app/ui".into()], - ), - FileRow::new("legacy/old_code.rs".into(), vec!["legacy".into()]), - FileRow::new( - "frontend/app.jsx".into(), - vec!["frontend".into(), "app/ui".into()], - ), - FileRow::new( - "backend/service.rs".into(), - vec!["backend".into(), "app/core".into()], - ), - FileRow::new("api/routes.rs".into(), vec!["api".into(), "backend".into()]), - FileRow::new("db/schema.sql".into(), vec!["db".into()]), - FileRow::new( - "scripts/deploy.sh".into(), - vec!["scripts".into(), "infra".into()], - ), - FileRow::new( - "src/utils/helpers.rs".into(), - vec!["app/core".into(), "tooling".into()], - ), - FileRow::new( - "src/ui/components/button.rs".into(), - vec!["app/ui".into(), "frontend".into()], - ), - FileRow::new( - "ops/ansible/playbook.yml".into(), - vec!["ops".into(), "infra".into()], - ), - FileRow::new( - "tooling/lint.nix".into(), - vec!["tooling".into(), "ci".into()], - ), - FileRow::new("docs/api.md".into(), vec!["docs".into(), "api".into()]), + let facets: Vec = [ + ("app/core", 12), + ("app/ui", 9), + ("docs", 4), + ("ops", 6), + ("tooling", 8), + ("infra", 5), + ("ci", 3), + ("tests", 7), + ("examples", 2), + ("legacy", 1), + ("frontend", 10), + ("backend", 11), + ("api", 6), + ("db", 4), + ("scripts", 3), + ] + .into_iter() + .map(|(name, count)| FacetRow::new(name, count)) + .collect(); + + let files: Vec = vec![ + FileRow::new("src/main.rs", ["app/core", "app/ui"]), + FileRow::new("src/ui/search.rs", ["app/ui", "tooling"]), + FileRow::new("docs/overview.md", ["docs"]), + FileRow::new("ops/terraform/main.tf", ["ops", "tooling"]), + FileRow::new("tooling/dev-env.nix", ["tooling"]), + FileRow::new("infra/docker-compose.yml", ["infra"]), + FileRow::new("ci/build.yml", ["ci"]), + FileRow::new("tests/test_main.rs", ["tests", "app/core"]), + FileRow::new("examples/demo.rs", ["examples", "app/ui"]), + FileRow::new("legacy/old_code.rs", ["legacy"]), + FileRow::new("frontend/app.jsx", ["frontend", "app/ui"]), + FileRow::new("backend/service.rs", ["backend", "app/core"]), + FileRow::new("api/routes.rs", ["api", "backend"]), + FileRow::new("db/schema.sql", ["db"]), + FileRow::new("scripts/deploy.sh", ["scripts", "infra"]), + FileRow::new("src/utils/helpers.rs", ["app/core", "tooling"]), + FileRow::new("src/ui/components/button.rs", ["app/ui", "frontend"]), + FileRow::new("ops/ansible/playbook.yml", ["ops", "infra"]), + FileRow::new("tooling/lint.nix", ["tooling", "ci"]), + FileRow::new("docs/api.md", ["docs", "api"]), ]; - let data = SearchData { - repo_display: "demo-repo".into(), - user_filter: "demo".into(), - facets, - files, - }; + let data = SearchData::new() + .with_context("demo-repo") + .with_initial_query("demo") + .with_facets(facets) + .with_files(files); - let searcher = tui::Searcher::new(data).with_input_title("demo"); + let searcher = tui::Searcher::new(data) + .with_ui_config(tui::UiConfig::tags_and_files()) + .with_input_title("demo"); let searcher = apply_theme(searcher, &opts); let outcome = searcher.run()?; - if outcome.accepted { - println!("Demo accepted – imagine emitting sparse patterns here"); - } else { - println!("Demo aborted"); + if !outcome.accepted { + println!("Demo aborted (query: {})", outcome.query); + return Ok(()); + } + + match outcome.selection { + Some(SearchSelection::Facet(facet)) => { + println!("Selected facet: {}", facet.name) + } + Some(SearchSelection::File(file)) => println!("Selected file: {}", file.path), + None => println!("Demo accepted – imagine emitting sparse patterns here"), } Ok(()) diff --git a/examples/workspaces.rs b/examples/workspaces.rs index ac4e389..57c49bd 100644 --- a/examples/workspaces.rs +++ b/examples/workspaces.rs @@ -1,143 +1,80 @@ mod common; use clap::Parser; use common::{Opts, apply_theme}; -use git_sparta::tui::{self, FacetRow, FileRow, SearchData}; +use git_sparta::tui::{self, FacetRow, FileRow, SearchData, SearchSelection}; fn main() -> anyhow::Result<()> { let opts = Opts::parse(); - let facets = vec![ - FacetRow { - name: "backend".into(), - count: 7, - }, - FacetRow { - name: "frontend".into(), - count: 5, - }, - FacetRow { - name: "integration".into(), - count: 3, - }, - FacetRow { - name: "mobile".into(), - count: 4, - }, - FacetRow { - name: "qa".into(), - count: 2, - }, - FacetRow { - name: "devops".into(), - count: 6, - }, - FacetRow { - name: "docs".into(), - count: 3, - }, - FacetRow { - name: "security".into(), - count: 2, - }, - FacetRow { - name: "infra".into(), - count: 4, - }, - FacetRow { - name: "legacy".into(), - count: 1, - }, - FacetRow { - name: "api".into(), - count: 5, - }, - FacetRow { - name: "db".into(), - count: 3, - }, - FacetRow { - name: "tests".into(), - count: 4, - }, - FacetRow { - name: "scripts".into(), - count: 2, - }, - ]; - let files = vec![ - FileRow::new( - "services/catalog/lib.rs".into(), - vec!["backend".into(), "integration".into()], - ), - FileRow::new("services/payments/api.rs".into(), vec!["backend".into()]), - FileRow::new("clients/web/src/app.tsx".into(), vec!["frontend".into()]), - FileRow::new( - "clients/mobile/app/lib/main.dart".into(), - vec!["mobile".into(), "integration".into()], - ), - FileRow::new( - "qa/scenarios/payment.feature".into(), - vec!["qa".into(), "backend".into()], - ), - FileRow::new("devops/ci.yml".into(), vec!["devops".into()]), - FileRow::new("docs/architecture.md".into(), vec!["docs".into()]), - FileRow::new( - "security/audit.log".into(), - vec!["security".into(), "infra".into()], - ), - FileRow::new( - "infra/terraform/main.tf".into(), - vec!["infra".into(), "devops".into()], - ), - FileRow::new("legacy/old_service.rs".into(), vec!["legacy".into()]), - FileRow::new("api/routes.rs".into(), vec!["api".into(), "backend".into()]), - FileRow::new("db/schema.sql".into(), vec!["db".into()]), - FileRow::new( - "tests/test_api.rs".into(), - vec!["tests".into(), "api".into()], - ), - FileRow::new( - "scripts/deploy.sh".into(), - vec!["scripts".into(), "devops".into()], - ), - FileRow::new( - "clients/web/src/components/button.tsx".into(), - vec!["frontend".into()], - ), - FileRow::new( - "clients/mobile/app/lib/utils.dart".into(), - vec!["mobile".into()], - ), - FileRow::new( - "qa/scenarios/login.feature".into(), - vec!["qa".into(), "frontend".into()], - ), - FileRow::new( - "infra/ansible/playbook.yml".into(), - vec!["infra".into(), "devops".into()], - ), - FileRow::new("docs/api.md".into(), vec!["docs".into(), "api".into()]), + let facets: Vec = [ + ("backend", 7), + ("frontend", 5), + ("integration", 3), + ("mobile", 4), + ("qa", 2), + ("devops", 6), + ("docs", 3), + ("security", 2), + ("infra", 4), + ("legacy", 1), + ("api", 5), + ("db", 3), + ("tests", 4), + ("scripts", 2), + ] + .into_iter() + .map(|(name, count)| FacetRow::new(name, count)) + .collect(); + + let files: Vec = vec![ + FileRow::new("services/catalog/lib.rs", ["backend", "integration"]), + FileRow::new("services/payments/api.rs", ["backend"]), + FileRow::new("clients/web/src/app.tsx", ["frontend"]), FileRow::new( - "db/migrations/001_init.sql".into(), - vec!["db".into(), "backend".into()], + "clients/mobile/app/lib/main.dart", + ["mobile", "integration"], ), + FileRow::new("qa/scenarios/payment.feature", ["qa", "backend"]), + FileRow::new("devops/ci.yml", ["devops"]), + FileRow::new("docs/architecture.md", ["docs"]), + FileRow::new("security/audit.log", ["security", "infra"]), + FileRow::new("infra/terraform/main.tf", ["infra", "devops"]), + FileRow::new("legacy/old_service.rs", ["legacy"]), + FileRow::new("api/routes.rs", ["api", "backend"]), + FileRow::new("db/schema.sql", ["db"]), + FileRow::new("tests/test_api.rs", ["tests", "api"]), + FileRow::new("scripts/deploy.sh", ["scripts", "devops"]), + FileRow::new("clients/web/src/components/button.tsx", ["frontend"]), + FileRow::new("clients/mobile/app/lib/utils.dart", ["mobile"]), + FileRow::new("qa/scenarios/login.feature", ["qa", "frontend"]), + FileRow::new("infra/ansible/playbook.yml", ["infra", "devops"]), + FileRow::new("docs/api.md", ["docs", "api"]), + FileRow::new("db/migrations/001_init.sql", ["db", "backend"]), ]; - let data = SearchData { - repo_display: "workspace-prototype".into(), - user_filter: "workspace".into(), - facets, - files, - }; + let data = SearchData::new() + .with_context("workspace-prototype") + .with_initial_query("workspace") + .with_facets(facets) + .with_files(files); - let searcher = tui::Searcher::new(data).with_input_title("workspace"); + let searcher = tui::Searcher::new(data) + .with_ui_config(tui::UiConfig::tags_and_files()) + .with_input_title("workspace"); let searcher = apply_theme(searcher, &opts); let outcome = searcher.run()?; - if outcome.accepted { - println!("Workspace walkthrough accepted"); - } else { - println!("Workspace walkthrough cancelled"); + if !outcome.accepted { + println!("Workspace walkthrough cancelled (query: {})", outcome.query); + return Ok(()); + } + + match outcome.selection { + Some(SearchSelection::Facet(facet)) => { + println!("Selected workspace facet: {}", facet.name) + } + Some(SearchSelection::File(file)) => println!("Selected workspace file: {}", file.path), + None => println!("Workspace walkthrough accepted"), } Ok(()) diff --git a/src/commands/generate.rs b/src/commands/generate.rs index ccfe4b9..3c5d5b1 100644 --- a/src/commands/generate.rs +++ b/src/commands/generate.rs @@ -90,21 +90,21 @@ pub fn run( let patterns: Vec = unique_patterns.into_iter().collect(); let facets = tag_counts .into_iter() - .map(|(name, count)| tui::FacetRow { name, count }) + .map(|(name, count)| tui::FacetRow::new(name, count)) .collect(); let files = file_map .into_iter() - .map(|(path, tags)| tui::FileRow::new(path, tags.into_iter().collect())) + .map(|(path, tags)| tui::FileRow::new(path, tags.into_iter())) .collect(); - let mut searcher_builder = tui::Searcher::new(tui::SearchData { - repo_display: root.display().to_string(), - user_filter: tag.to_string(), - facets, - files, - }); + let data = tui::SearchData::new() + .with_context(root.display().to_string()) + .with_initial_query(tag) + .with_facets(facets) + .with_files(files); - searcher_builder = searcher_builder.with_ui_config(tui::UiConfig::tags_and_files()); + let mut searcher_builder = + tui::Searcher::new(data).with_ui_config(tui::UiConfig::tags_and_files()); if let Some(name) = theme { // Use centralized theme lookup from the tui crate; with_theme_name is