1use std::io::{self, stdout};
6use std::path::PathBuf;
7
8use crossterm::{
9 event::{self, Event, KeyCode, KeyEventKind},
10 terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
11 ExecutableCommand,
12};
13use ratatui::{
14 prelude::*,
15 widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
16};
17
18use crate::format_size;
19use crate::scanner::DirStat;
20
21struct DirEntry {
23 path: PathBuf,
24 name: String,
25 size: u64,
26 file_count: u64,
27 has_children: bool,
28}
29
30struct App<'a> {
32 roots: Vec<&'a DirStat>,
34 path_stack: Vec<&'a DirStat>,
36 current: Option<&'a DirStat>,
38 entries: Vec<DirEntry>,
40 list_state: ListState,
42 should_quit: bool,
44}
45
46impl<'a> App<'a> {
47 fn make_entry(stat: &DirStat) -> DirEntry {
49 DirEntry {
50 path: stat.path().to_path_buf(),
51 name: stat
52 .path()
53 .file_name()
54 .map(|n| n.to_string_lossy().to_string())
55 .unwrap_or_else(|| stat.path().display().to_string()),
56 size: stat.total_size(),
57 file_count: stat.file_count(),
58 has_children: !stat.children().is_empty(),
59 }
60 }
61
62 fn finalize_entries(&mut self) {
64 self.entries.sort_by(|a, b| b.size.cmp(&a.size));
65 if !self.entries.is_empty() {
66 self.list_state.select(Some(0));
67 } else {
68 self.list_state.select(None);
69 }
70 }
71
72 fn new(roots: Vec<&'a DirStat>) -> Self {
73 let entries: Vec<DirEntry> = roots.iter().map(|stat| Self::make_entry(stat)).collect();
74
75 let mut app = Self {
76 roots,
77 path_stack: Vec::new(),
78 current: None,
79 entries,
80 list_state: ListState::default(),
81 should_quit: false,
82 };
83
84 app.finalize_entries();
85 app
86 }
87
88 fn from_stat(stat: &'a DirStat) -> Self {
89 let mut app = Self {
90 roots: vec![stat],
91 path_stack: Vec::new(),
92 current: Some(stat),
93 entries: Vec::new(),
94 list_state: ListState::default(),
95 should_quit: false,
96 };
97
98 app.populate_entries_from_current();
99 app
100 }
101
102 fn populate_entries_from_current(&mut self) {
103 if let Some(stat) = self.current {
104 self.entries = stat.children().values().map(Self::make_entry).collect();
105 }
106 self.finalize_entries();
107 }
108
109 fn populate_entries_from_roots(&mut self) {
110 self.entries = self
111 .roots
112 .iter()
113 .map(|stat| Self::make_entry(stat))
114 .collect();
115 self.finalize_entries();
116 }
117
118 fn get_current_path(&self) -> String {
119 if let Some(stat) = self.current {
120 stat.path().display().to_string()
121 } else {
122 "Cached Roots".to_string()
123 }
124 }
125
126 fn get_current_total_size(&self) -> u64 {
127 if let Some(stat) = self.current {
128 stat.total_size()
129 } else {
130 self.entries.iter().map(|e| e.size).sum()
131 }
132 }
133
134 fn move_up(&mut self) {
135 if let Some(selected) = self.list_state.selected() {
136 if selected > 0 {
137 self.list_state.select(Some(selected - 1));
138 }
139 }
140 }
141
142 fn move_down(&mut self) {
143 if let Some(selected) = self.list_state.selected() {
144 if selected < self.entries.len().saturating_sub(1) {
145 self.list_state.select(Some(selected + 1));
146 }
147 }
148 }
149
150 fn enter_selected(&mut self) {
151 if let Some(selected) = self.list_state.selected() {
152 if selected < self.entries.len() && self.entries[selected].has_children {
153 let selected_path = &self.entries[selected].path;
154
155 let child_stat = if let Some(current) = self.current {
157 current.children().get(selected_path)
159 } else {
160 self.roots
162 .iter()
163 .find(|r| r.path() == selected_path)
164 .copied()
165 };
166
167 if let Some(stat) = child_stat {
168 if let Some(current) = self.current {
170 self.path_stack.push(current);
171 }
172 self.current = Some(stat);
173 self.populate_entries_from_current();
174 }
175 }
176 }
177 }
178
179 fn go_back(&mut self) {
180 if let Some(parent) = self.path_stack.pop() {
181 self.current = Some(parent);
182 self.populate_entries_from_current();
183 } else if self.current.is_some() {
184 self.current = None;
186 self.populate_entries_from_roots();
187 }
188 }
189
190 fn handle_key(&mut self, code: KeyCode) {
191 match code {
192 KeyCode::Char('q') | KeyCode::Esc => self.should_quit = true,
193 KeyCode::Up | KeyCode::Char('k') => self.move_up(),
194 KeyCode::Down | KeyCode::Char('j') => self.move_down(),
195 KeyCode::Enter | KeyCode::Right | KeyCode::Char('l') => self.enter_selected(),
196 KeyCode::Backspace | KeyCode::Left | KeyCode::Char('h') => self.go_back(),
197 _ => {}
198 }
199 }
200}
201
202pub fn run_tui(stat: &DirStat) -> io::Result<()> {
204 enable_raw_mode()?;
206 stdout().execute(EnterAlternateScreen)?;
207
208 let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?;
209 let mut app = App::from_stat(stat);
210
211 loop {
213 terminal.draw(|frame| render(&mut app, frame))?;
214
215 if let Event::Key(key) = event::read()? {
216 if key.kind == KeyEventKind::Press {
217 app.handle_key(key.code);
218 }
219 }
220
221 if app.should_quit {
222 break;
223 }
224 }
225
226 disable_raw_mode()?;
228 stdout().execute(LeaveAlternateScreen)?;
229
230 Ok(())
231}
232
233pub fn run_tui_with_roots(roots: Vec<&DirStat>) -> io::Result<()> {
235 if roots.is_empty() {
236 return Err(io::Error::new(
237 io::ErrorKind::NotFound,
238 "No cached directories found",
239 ));
240 }
241
242 enable_raw_mode()?;
244 stdout().execute(EnterAlternateScreen)?;
245
246 let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?;
247 let mut app = App::new(roots);
248
249 loop {
251 terminal.draw(|frame| render(&mut app, frame))?;
252
253 if let Event::Key(key) = event::read()? {
254 if key.kind == KeyEventKind::Press {
255 app.handle_key(key.code);
256 }
257 }
258
259 if app.should_quit {
260 break;
261 }
262 }
263
264 disable_raw_mode()?;
266 stdout().execute(LeaveAlternateScreen)?;
267
268 Ok(())
269}
270
271fn render(app: &mut App, frame: &mut Frame) {
272 let area = frame.area();
273
274 let chunks = Layout::default()
276 .direction(Direction::Vertical)
277 .constraints([
278 Constraint::Length(3), Constraint::Min(0), Constraint::Length(3), ])
282 .split(area);
283
284 let total_size = format_size(app.get_current_total_size(), true);
286 let header_text = format!("{} (Total: {})", app.get_current_path(), total_size);
287 let header = Paragraph::new(header_text)
288 .style(
289 Style::default()
290 .fg(Color::Cyan)
291 .add_modifier(Modifier::BOLD),
292 )
293 .block(
294 Block::default()
295 .borders(Borders::ALL)
296 .title("acme-disk-use"),
297 );
298 frame.render_widget(header, chunks[0]);
299
300 let items: Vec<ListItem> = app
302 .entries
303 .iter()
304 .map(|entry| {
305 let size_str = format_size(entry.size, true);
306 let indicator = if entry.has_children { "/" } else { "" };
307 let line = format!(
308 "{:>12} {:>6} files {}{}",
309 size_str, entry.file_count, entry.name, indicator
310 );
311 ListItem::new(line)
312 })
313 .collect();
314
315 let list = List::new(items)
316 .block(Block::default().borders(Borders::ALL).title("Directories"))
317 .highlight_style(
318 Style::default()
319 .bg(Color::DarkGray)
320 .add_modifier(Modifier::BOLD),
321 )
322 .highlight_symbol("> ");
323
324 frame.render_stateful_widget(list, chunks[1], &mut app.list_state);
325
326 let help_text = "↑/k: Up | ↓/j: Down | Enter/→/l: Open | Backspace/←/h: Back | q/Esc: Quit";
328 let footer = Paragraph::new(help_text)
329 .style(Style::default().fg(Color::DarkGray))
330 .block(Block::default().borders(Borders::ALL).title("Help"));
331 frame.render_widget(footer, chunks[2]);
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337 use std::collections::HashMap;
338 use std::time::SystemTime;
339
340 fn create_test_stat() -> DirStat {
341 DirStat {
342 path: PathBuf::from("/test"),
343 total_size: 1000,
344 file_count: 10,
345 last_scan: SystemTime::now(),
346 children: HashMap::new(),
347 }
348 }
349
350 #[test]
351 fn test_dir_entry_sorting() {
352 let stat1 = DirStat {
354 path: PathBuf::from("/test/small"),
355 total_size: 100,
356 file_count: 1,
357 last_scan: SystemTime::now(),
358 children: HashMap::new(),
359 };
360
361 let stat2 = DirStat {
362 path: PathBuf::from("/test/large"),
363 total_size: 1000,
364 file_count: 10,
365 last_scan: SystemTime::now(),
366 children: HashMap::new(),
367 };
368
369 let roots: Vec<&DirStat> = vec![&stat1, &stat2];
370 let app = App::new(roots);
371
372 assert_eq!(app.entries.len(), 2);
374 assert_eq!(app.entries[0].size, 1000); assert_eq!(app.entries[1].size, 100); }
377
378 #[test]
379 fn test_app_navigation() {
380 let stat = create_test_stat();
381 let mut app = App::from_stat(&stat);
382
383 app.move_up();
385 app.move_down();
386 app.enter_selected();
387 app.go_back();
388 }
389
390 #[test]
391 fn test_go_back_to_roots() {
392 let child = DirStat {
394 path: PathBuf::from("/test/child"),
395 total_size: 500,
396 file_count: 5,
397 last_scan: SystemTime::now(),
398 children: HashMap::new(),
399 };
400
401 let mut children = HashMap::new();
402 children.insert(PathBuf::from("/test/child"), child);
403
404 let root = DirStat {
405 path: PathBuf::from("/test"),
406 total_size: 1000,
407 file_count: 10,
408 last_scan: SystemTime::now(),
409 children,
410 };
411
412 let roots: Vec<&DirStat> = vec![&root];
413 let mut app = App::new(roots);
414
415 assert!(app.current.is_none());
417 assert_eq!(app.get_current_path(), "Cached Roots");
418
419 app.enter_selected();
421 assert!(app.current.is_some());
422 assert_eq!(app.get_current_path(), "/test");
423
424 app.go_back();
426 assert!(app.current.is_none());
427 assert_eq!(app.get_current_path(), "Cached Roots");
428 }
429}