imp_tui/views/
file_finder.rs1use std::path::Path;
2
3use ratatui::buffer::Buffer;
4use ratatui::layout::Rect;
5use ratatui::style::Style;
6use ratatui::text::{Line, Span};
7use ratatui::widgets::{Block, Borders, Clear, Widget};
8
9use crate::theme::Theme;
10
11#[derive(Debug, Clone)]
13pub struct FileFinderState {
14 pub files: Vec<String>,
15 pub filter: String,
16 pub selected: usize,
17}
18
19impl FileFinderState {
20 pub fn new(files: Vec<String>) -> Self {
21 Self {
22 files,
23 filter: String::new(),
24 selected: 0,
25 }
26 }
27
28 pub fn filtered(&self) -> Vec<&str> {
29 if self.filter.is_empty() {
30 self.files.iter().take(20).map(|s| s.as_str()).collect()
31 } else {
32 let lower = self.filter.to_lowercase();
33 self.files
34 .iter()
35 .filter(|f| fuzzy_match(&f.to_lowercase(), &lower))
36 .take(10)
37 .map(|s| s.as_str())
38 .collect()
39 }
40 }
41
42 pub fn move_up(&mut self) {
43 if self.selected > 0 {
44 self.selected -= 1;
45 }
46 }
47
48 pub fn move_down(&mut self) {
49 let count = self.filtered().len();
50 if self.selected + 1 < count {
51 self.selected += 1;
52 }
53 }
54
55 pub fn push_filter(&mut self, c: char) {
56 self.filter.push(c);
57 self.selected = 0;
58 }
59
60 pub fn pop_filter(&mut self) {
61 self.filter.pop();
62 self.selected = 0;
63 }
64
65 pub fn selected_file(&self) -> Option<String> {
66 let filtered = self.filtered();
67 filtered.get(self.selected).map(|s| s.to_string())
68 }
69}
70
71pub fn collect_project_files(root: &Path, max_files: usize) -> Vec<String> {
73 let mut files = Vec::new();
74
75 for entry in walkdir::WalkDir::new(root)
76 .follow_links(false)
77 .into_iter()
78 .filter_entry(|e| {
79 let name = e.file_name().to_string_lossy();
80 if e.file_type().is_dir() {
82 return !name.starts_with('.')
83 && name != "node_modules"
84 && name != "target"
85 && name != "__pycache__"
86 && name != ".git";
87 }
88 true
89 })
90 {
91 if files.len() >= max_files {
92 break;
93 }
94 if let Ok(entry) = entry {
95 if entry.file_type().is_file() {
96 if let Ok(rel) = entry.path().strip_prefix(root) {
97 files.push(rel.to_string_lossy().to_string());
98 }
99 }
100 }
101 }
102
103 files.sort();
104 files
105}
106
107fn fuzzy_match(haystack: &str, pattern: &str) -> bool {
109 let mut hay_chars = haystack.chars();
110 for p in pattern.chars() {
111 loop {
112 match hay_chars.next() {
113 Some(h) if h == p => break,
114 Some(_) => continue,
115 None => return false,
116 }
117 }
118 }
119 true
120}
121
122pub struct FileFinderView<'a> {
124 state: &'a FileFinderState,
125 theme: &'a Theme,
126}
127
128impl<'a> FileFinderView<'a> {
129 pub fn new(state: &'a FileFinderState, theme: &'a Theme) -> Self {
130 Self { state, theme }
131 }
132}
133
134impl Widget for FileFinderView<'_> {
135 fn render(self, area: Rect, buf: &mut Buffer) {
136 if area.height < 3 || area.width < 15 {
137 return;
138 }
139
140 Clear.render(area, buf);
141
142 let title = if self.state.filter.is_empty() {
143 " @file ".to_string()
144 } else {
145 format!(" @{} ", self.state.filter)
146 };
147
148 let block = Block::default()
149 .title(title)
150 .borders(Borders::ALL)
151 .border_style(self.theme.accent_style());
152 let inner = block.inner(area);
153 block.render(area, buf);
154
155 let filtered = self.state.filtered();
156
157 for (i, path) in filtered.iter().enumerate() {
158 if i >= inner.height as usize {
159 break;
160 }
161
162 let is_selected = i == self.state.selected;
163 let style = if is_selected {
164 self.theme.selected_style()
165 } else {
166 Style::default()
167 };
168
169 let line = Line::from(Span::styled(format!(" {path}"), style));
170 buf.set_line(inner.x, inner.y + i as u16, &line, inner.width);
171 }
172
173 if filtered.is_empty() {
174 let line = Line::from(Span::styled(
175 " No matching files",
176 self.theme.muted_style(),
177 ));
178 buf.set_line(inner.x, inner.y, &line, inner.width);
179 }
180 }
181}