Skip to main content

imp_tui/views/
file_finder.rs

1use 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/// State for the @file fuzzy finder overlay.
12#[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
71/// Collect project files respecting .gitignore (via walkdir, skipping hidden dirs).
72pub 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            // Skip hidden directories and common non-source dirs
81            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
107/// Simple fuzzy match: all characters in the pattern appear in order in the haystack.
108fn 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
122/// File finder overlay widget.
123pub 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}