1use std::io;
4use std::path::{Path, PathBuf};
5
6use cdt_tree::Tree;
7use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind};
8use ratatui::crossterm::execute;
9use ratatui::crossterm::terminal::{
10 EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
11};
12use ratatui::prelude::*;
13use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
14
15type Res<T> = Result<T, Box<dyn std::error::Error>>;
16
17const HELP: &str = "j/k move l expand h up / find s grep . hidden Enter cd q quit";
18
19enum Mode {
20 Tree,
21 Find(String),
22 Grep(String),
23}
24
25struct App {
26 tree: Tree,
27 hits: Vec<PathBuf>,
28 mode: Mode,
29 sel: usize,
30 msg: String,
31}
32
33impl App {
34 fn new(root: PathBuf) -> Self {
35 App {
36 tree: Tree::new(root, false),
37 hits: Vec::new(),
38 mode: Mode::Tree,
39 sel: 0,
40 msg: String::new(),
41 }
42 }
43
44 fn len(&self) -> usize {
45 match self.mode {
46 Mode::Tree => self.tree.len(),
47 _ => self.hits.len(),
48 }
49 }
50
51 fn selected(&self) -> Option<&Path> {
52 match self.mode {
53 Mode::Tree => self.tree.get(self.sel).map(|e| e.path.as_path()),
54 _ => self.hits.get(self.sel).map(PathBuf::as_path),
55 }
56 }
57
58 fn target(&self) -> PathBuf {
60 match self.selected() {
61 Some(p) if p.is_dir() => p.to_path_buf(),
62 Some(p) => p.parent().unwrap_or(&self.tree.root).to_path_buf(),
63 None => self.tree.root.clone(),
64 }
65 }
66
67 fn move_by(&mut self, d: isize) {
68 let n = self.len();
69 if n > 0 {
70 self.sel = (self.sel as isize + d).rem_euclid(n as isize) as usize;
71 }
72 }
73
74 fn back_to_tree(&mut self) {
75 self.mode = Mode::Tree;
76 self.hits.clear();
77 self.sel = 0;
78 self.msg.clear();
79 }
80
81 fn collapse_or_up(&mut self) {
83 match self.tree.get(self.sel) {
84 Some(e) if e.is_dir && e.open => self.tree.toggle(self.sel),
85 Some(_) if self.tree.parent_of(self.sel).is_some() => {
86 self.sel = self.tree.parent_of(self.sel).unwrap();
87 }
88 _ => {
89 if self.tree.up() {
90 self.sel = 0;
91 }
92 }
93 }
94 }
95
96 fn search(&mut self, q: &str) {
97 self.sel = 0;
98 match self.mode {
99 Mode::Grep(_) => match cdt_search::grep(&self.tree.root, q, self.tree.hidden) {
100 Ok(hits) => self.hits = hits,
101 Err(e) => {
102 self.hits.clear();
103 self.msg = format!("rg unavailable: {e}");
104 }
105 },
106 _ => self.hits = cdt_search::find_names(&self.tree.root, q, self.tree.hidden),
107 }
108 }
109}
110
111fn draw(f: &mut Frame, app: &App) {
112 let [body, bar] = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(f.area());
113
114 let items: Vec<ListItem> = match app.mode {
115 Mode::Tree => app
116 .tree
117 .entries
118 .iter()
119 .map(|e| {
120 let icon = if !e.is_dir {
121 " "
122 } else if e.open {
123 "▾ "
124 } else {
125 "▸ "
126 };
127 let style = if e.is_dir {
128 Style::new().fg(Color::Cyan).bold()
129 } else {
130 Style::new()
131 };
132 ListItem::new(format!("{}{icon}{}", " ".repeat(e.depth), e.name())).style(style)
133 })
134 .collect(),
135 _ => app
136 .hits
137 .iter()
138 .map(|p| ListItem::new(cdt_search::label(&app.tree.root, p)))
139 .collect(),
140 };
141
142 let mut state = ListState::default().with_selected(Some(app.sel));
143 f.render_stateful_widget(
144 List::new(items)
145 .block(
146 Block::new()
147 .borders(Borders::ALL)
148 .title(format!(" {} ", app.tree.root.display())),
149 )
150 .highlight_style(Style::new().reversed()),
151 body,
152 &mut state,
153 );
154
155 let status = match &app.mode {
156 _ if !app.msg.is_empty() => app.msg.clone(),
157 Mode::Find(q) => format!("find: {q}_ ({} hits)", app.hits.len()),
158 Mode::Grep(q) => format!("grep: {q}_ ({} files)", app.hits.len()),
159 Mode::Tree => HELP.into(),
160 };
161 f.render_widget(
162 Paragraph::new(status).style(Style::new().fg(Color::DarkGray)),
163 bar,
164 );
165}
166
167pub fn pick(root: PathBuf) -> Res<Option<PathBuf>> {
169 let mut app = App::new(root);
170 enable_raw_mode()?;
171 execute!(io::stderr(), EnterAlternateScreen)?;
172 let picked = event_loop(
173 &mut Terminal::new(CrosstermBackend::new(io::stderr()))?,
174 &mut app,
175 );
176 disable_raw_mode()?;
177 execute!(io::stderr(), LeaveAlternateScreen)?;
178 picked
179}
180
181fn event_loop(
182 term: &mut Terminal<CrosstermBackend<io::Stderr>>,
183 app: &mut App,
184) -> Res<Option<PathBuf>> {
185 loop {
186 term.draw(|f| draw(f, app))?;
187 let Event::Key(k) = event::read()? else {
188 continue;
189 };
190 if k.kind != KeyEventKind::Press {
191 continue;
192 }
193 if let Mode::Find(q) | Mode::Grep(q) = &app.mode {
194 let mut q = q.clone();
195 match k.code {
196 KeyCode::Char(c) => q.push(c),
197 KeyCode::Backspace => {
198 q.pop();
199 }
200 KeyCode::Esc => {
201 app.back_to_tree();
202 continue;
203 }
204 KeyCode::Enter => return Ok(Some(app.target())),
205 KeyCode::Down | KeyCode::Tab => {
206 app.move_by(1);
207 continue;
208 }
209 KeyCode::Up => {
210 app.move_by(-1);
211 continue;
212 }
213 _ => continue,
214 }
215 app.msg.clear();
216 app.search(&q);
217 app.mode = match app.mode {
218 Mode::Grep(_) => Mode::Grep(q),
219 _ => Mode::Find(q),
220 };
221 continue;
222 }
223 match k.code {
224 KeyCode::Char('q') | KeyCode::Esc => return Ok(None),
225 KeyCode::Enter => return Ok(Some(app.target())),
226 KeyCode::Char('j') | KeyCode::Down => app.move_by(1),
227 KeyCode::Char('k') | KeyCode::Up => app.move_by(-1),
228 KeyCode::Char('g') => app.sel = 0,
229 KeyCode::Char('G') => app.sel = app.len().saturating_sub(1),
230 KeyCode::Char('l') | KeyCode::Right | KeyCode::Tab | KeyCode::Char(' ') => {
231 app.tree.toggle(app.sel)
232 }
233 KeyCode::Char('h') | KeyCode::Left => app.collapse_or_up(),
234 KeyCode::Char('.') => {
235 app.tree.hidden = !app.tree.hidden;
236 app.tree.reload();
237 app.sel = 0;
238 }
239 KeyCode::Char('/') => app.mode = Mode::Find(String::new()),
240 KeyCode::Char('s') => app.mode = Mode::Grep(String::new()),
241 _ => {}
242 }
243 }
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249
250 #[test]
251 fn move_by_wraps_and_tolerates_an_empty_list() {
252 let mut app = App::new(PathBuf::from(env!("CARGO_MANIFEST_DIR")));
253 let n = app.len();
254 assert!(n > 0);
255 app.move_by(-1);
256 assert_eq!(app.sel, n - 1);
257 app.move_by(1);
258 assert_eq!(app.sel, 0);
259
260 app.mode = Mode::Find(String::new()); app.move_by(1);
262 assert_eq!(app.sel, 0);
263 }
264
265 #[test]
266 fn target_of_a_file_is_its_directory() {
267 let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
268 let mut app = App::new(root.clone());
269 app.mode = Mode::Find(String::new());
270 app.hits = vec![root.join("src/lib.rs")];
271 assert_eq!(app.target(), root.join("src"));
272 }
273}