1use std::path::PathBuf;
10
11use ratatui::{
12 layout::{Constraint, Direction, Layout, Rect},
13 style::Style,
14 text::{Line, Span},
15 widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
16 Frame,
17};
18
19use crate::theme::AxonmlTheme;
20
21#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum FileType {
28 Directory,
29 Model, Dataset, Config, Other,
33}
34
35impl FileType {
36 #[allow(dead_code)]
38 pub fn icon(&self) -> &'static str {
39 match self {
40 FileType::Directory => "\u{1F4C1}", FileType::Model => "\u{1F9E0}", FileType::Dataset => "\u{1F4CA}", FileType::Config => "\u{2699}", FileType::Other => "\u{1F4C4}", }
46 }
47
48 #[allow(dead_code)]
50 pub fn from_extension(ext: &str) -> Self {
51 match ext.to_lowercase().as_str() {
52 "axonml" | "onnx" | "pt" | "pth" | "safetensors" | "h5" | "keras" => FileType::Model,
53 "npz" | "npy" | "csv" | "parquet" | "arrow" | "tfrecord" => FileType::Dataset,
54 "toml" | "yaml" | "yml" | "json" => FileType::Config,
55 _ => FileType::Other,
56 }
57 }
58}
59
60#[derive(Debug, Clone)]
62pub struct FileEntry {
63 pub name: String,
64 pub path: PathBuf,
65 pub file_type: FileType,
66 pub size: Option<u64>,
67 pub is_expanded: bool,
68 pub depth: usize,
69}
70
71impl FileEntry {
72 #[allow(dead_code)]
74 pub fn new(name: String, path: PathBuf, file_type: FileType, depth: usize) -> Self {
75 Self {
76 name,
77 path,
78 file_type,
79 size: None,
80 is_expanded: false,
81 depth,
82 }
83 }
84}
85
86pub struct FilesView {
92 pub current_dir: PathBuf,
94
95 pub entries: Vec<FileEntry>,
97
98 pub selected: usize,
100
101 pub list_state: ListState,
103
104 pub show_hidden: bool,
106
107 pub filter: Option<String>,
109
110 pub preview: Option<String>,
112}
113
114impl FilesView {
115 pub fn new() -> Self {
117 let mut view = Self {
118 current_dir: PathBuf::from("/home/user/ml-projects"),
119 entries: Vec::new(),
120 selected: 0,
121 list_state: ListState::default(),
122 show_hidden: false,
123 filter: None,
124 preview: None,
125 };
126
127 view.load_demo_entries();
128 view.list_state.select(Some(0));
129 view
130 }
131
132 pub fn load_demo_entries(&mut self) {
134 self.entries = vec![
135 FileEntry {
136 name: "..".to_string(),
137 path: PathBuf::from("/home/user"),
138 file_type: FileType::Directory,
139 size: None,
140 is_expanded: false,
141 depth: 0,
142 },
143 FileEntry {
144 name: "models".to_string(),
145 path: PathBuf::from("/home/user/ml-projects/models"),
146 file_type: FileType::Directory,
147 size: None,
148 is_expanded: true,
149 depth: 0,
150 },
151 FileEntry {
152 name: "mnist_classifier.axonml".to_string(),
153 path: PathBuf::from("/home/user/ml-projects/models/mnist_classifier.axonml"),
154 file_type: FileType::Model,
155 size: Some(940_584),
156 is_expanded: false,
157 depth: 1,
158 },
159 FileEntry {
160 name: "resnet50.onnx".to_string(),
161 path: PathBuf::from("/home/user/ml-projects/models/resnet50.onnx"),
162 file_type: FileType::Model,
163 size: Some(97_800_000),
164 is_expanded: false,
165 depth: 1,
166 },
167 FileEntry {
168 name: "bert_base.safetensors".to_string(),
169 path: PathBuf::from("/home/user/ml-projects/models/bert_base.safetensors"),
170 file_type: FileType::Model,
171 size: Some(438_000_000),
172 is_expanded: false,
173 depth: 1,
174 },
175 FileEntry {
176 name: "datasets".to_string(),
177 path: PathBuf::from("/home/user/ml-projects/datasets"),
178 file_type: FileType::Directory,
179 size: None,
180 is_expanded: true,
181 depth: 0,
182 },
183 FileEntry {
184 name: "mnist.npz".to_string(),
185 path: PathBuf::from("/home/user/ml-projects/datasets/mnist.npz"),
186 file_type: FileType::Dataset,
187 size: Some(11_490_434),
188 is_expanded: false,
189 depth: 1,
190 },
191 FileEntry {
192 name: "cifar10.npz".to_string(),
193 path: PathBuf::from("/home/user/ml-projects/datasets/cifar10.npz"),
194 file_type: FileType::Dataset,
195 size: Some(170_498_071),
196 is_expanded: false,
197 depth: 1,
198 },
199 FileEntry {
200 name: "imagenet_labels.csv".to_string(),
201 path: PathBuf::from("/home/user/ml-projects/datasets/imagenet_labels.csv"),
202 file_type: FileType::Dataset,
203 size: Some(21_384),
204 is_expanded: false,
205 depth: 1,
206 },
207 FileEntry {
208 name: "configs".to_string(),
209 path: PathBuf::from("/home/user/ml-projects/configs"),
210 file_type: FileType::Directory,
211 size: None,
212 is_expanded: false,
213 depth: 0,
214 },
215 FileEntry {
216 name: "train_config.toml".to_string(),
217 path: PathBuf::from("/home/user/ml-projects/train_config.toml"),
218 file_type: FileType::Config,
219 size: Some(1_024),
220 is_expanded: false,
221 depth: 0,
222 },
223 FileEntry {
224 name: "README.md".to_string(),
225 path: PathBuf::from("/home/user/ml-projects/README.md"),
226 file_type: FileType::Other,
227 size: Some(2_048),
228 is_expanded: false,
229 depth: 0,
230 },
231 ];
232 }
233
234 pub fn select_prev(&mut self) {
236 if self.selected > 0 {
237 self.selected -= 1;
238 self.list_state.select(Some(self.selected));
239 self.update_preview();
240 }
241 }
242
243 pub fn select_next(&mut self) {
245 if self.selected < self.entries.len() - 1 {
246 self.selected += 1;
247 self.list_state.select(Some(self.selected));
248 self.update_preview();
249 }
250 }
251
252 pub fn toggle_or_open(&mut self) {
254 if let Some(entry) = self.entries.get_mut(self.selected) {
255 if entry.file_type == FileType::Directory {
256 entry.is_expanded = !entry.is_expanded;
257 }
259 }
261 }
262
263 pub fn go_parent(&mut self) {
265 if let Some(parent) = self.current_dir.parent() {
266 self.current_dir = parent.to_path_buf();
267 }
269 }
270
271 pub fn go_up(&mut self) {
273 self.go_parent();
274 }
275
276 pub fn go_home(&mut self) {
278 if let Some(home) = dirs::home_dir() {
279 self.current_dir = home;
280 }
282 }
283
284 pub fn open_selected(&mut self) -> Option<PathBuf> {
286 if let Some(entry) = self.entries.get(self.selected) {
287 if entry.file_type == FileType::Directory {
288 self.toggle_or_open();
290 } else {
291 return Some(entry.path.clone());
292 }
293 }
294 None
295 }
296
297 pub fn toggle_hidden(&mut self) {
299 self.show_hidden = !self.show_hidden;
300 }
302
303 fn update_preview(&mut self) {
305 if let Some(entry) = self.entries.get(self.selected) {
306 self.preview = Some(format!(
307 "Path: {}\nType: {:?}\nSize: {}",
308 entry.path.display(),
309 entry.file_type,
310 entry.size.map(format_size).unwrap_or_else(|| "-".to_string())
311 ));
312 }
313 }
314
315 pub fn render(&mut self, frame: &mut Frame, area: Rect) {
317 let chunks = Layout::default()
318 .direction(Direction::Horizontal)
319 .constraints([
320 Constraint::Percentage(60), Constraint::Percentage(40), ])
323 .split(area);
324
325 self.render_file_list(frame, chunks[0]);
326 self.render_preview(frame, chunks[1]);
327 }
328
329 fn render_file_list(&mut self, frame: &mut Frame, area: Rect) {
330 let chunks = Layout::default()
331 .direction(Direction::Vertical)
332 .constraints([
333 Constraint::Length(3), Constraint::Min(10), ])
336 .split(area);
337
338 let path_text = Line::from(vec![
340 Span::styled("Path: ", AxonmlTheme::muted()),
341 Span::styled(self.current_dir.display().to_string(), AxonmlTheme::accent()),
342 ]);
343
344 let path_bar = Paragraph::new(path_text)
345 .block(
346 Block::default()
347 .borders(Borders::ALL)
348 .border_style(AxonmlTheme::border())
349 .title(Span::styled(" Location ", AxonmlTheme::header())),
350 );
351
352 frame.render_widget(path_bar, chunks[0]);
353
354 let items: Vec<ListItem> = self
356 .entries
357 .iter()
358 .enumerate()
359 .map(|(i, entry)| {
360 let style = if i == self.selected {
361 AxonmlTheme::selected()
362 } else {
363 Style::default()
364 };
365
366 let indent = " ".repeat(entry.depth);
368
369 let expand_char = if entry.file_type == FileType::Directory {
371 if entry.is_expanded { "\u{25BC} " } else { "\u{25B6} " }
372 } else {
373 " "
374 };
375
376 let name_style = match entry.file_type {
378 FileType::Directory => AxonmlTheme::layer_type(),
379 FileType::Model => AxonmlTheme::success(),
380 FileType::Dataset => AxonmlTheme::info(),
381 FileType::Config => AxonmlTheme::warning(),
382 FileType::Other => AxonmlTheme::muted(),
383 };
384
385 let size_str = entry
386 .size
387 .map(|s| format!("{:>10}", format_size(s)))
388 .unwrap_or_else(|| " -".to_string());
389
390 let content = Line::from(vec![
391 Span::raw(indent),
392 Span::styled(expand_char, AxonmlTheme::muted()),
393 Span::styled(&entry.name, name_style),
394 Span::styled(format!(" {}", size_str), AxonmlTheme::muted()),
395 ]);
396
397 ListItem::new(content).style(style)
398 })
399 .collect();
400
401 let list = List::new(items)
402 .block(
403 Block::default()
404 .borders(Borders::ALL)
405 .border_style(AxonmlTheme::border_focused())
406 .title(Span::styled(" Files ", AxonmlTheme::header())),
407 )
408 .highlight_style(AxonmlTheme::selected());
409
410 frame.render_stateful_widget(list, chunks[1], &mut self.list_state);
411 }
412
413 fn render_preview(&self, frame: &mut Frame, area: Rect) {
414 let selected_entry = self.entries.get(self.selected);
415
416 let preview_text = if let Some(entry) = selected_entry {
417 let type_info = match entry.file_type {
418 FileType::Directory => "Directory - Press Enter to expand/collapse",
419 FileType::Model => "Neural Network Model\nSupported: Axonml, ONNX, SafeTensors\nPress Enter to load in Model view",
420 FileType::Dataset => "Dataset File\nSupported: NPZ, CSV, Parquet\nPress Enter to load in Data view",
421 FileType::Config => "Configuration File\nPress Enter to edit",
422 FileType::Other => "Unknown file type",
423 };
424
425 vec![
426 Line::from(vec![
427 Span::styled("Name: ", AxonmlTheme::muted()),
428 Span::styled(&entry.name, AxonmlTheme::title()),
429 ]),
430 Line::from(""),
431 Line::from(vec![
432 Span::styled("Type: ", AxonmlTheme::muted()),
433 Span::styled(format!("{:?}", entry.file_type), AxonmlTheme::accent()),
434 ]),
435 Line::from(vec![
436 Span::styled("Size: ", AxonmlTheme::muted()),
437 Span::styled(
438 entry.size.map(format_size).unwrap_or_else(|| "-".to_string()),
439 AxonmlTheme::metric_value(),
440 ),
441 ]),
442 Line::from(vec![
443 Span::styled("Path: ", AxonmlTheme::muted()),
444 Span::styled(entry.path.display().to_string(), AxonmlTheme::layer_shape()),
445 ]),
446 Line::from(""),
447 Line::from(Span::styled(type_info, AxonmlTheme::info())),
448 ]
449 } else {
450 vec![Line::from(Span::styled("No file selected", AxonmlTheme::muted()))]
451 };
452
453 let preview = Paragraph::new(preview_text)
454 .block(
455 Block::default()
456 .borders(Borders::ALL)
457 .border_style(AxonmlTheme::border())
458 .title(Span::styled(" Preview ", AxonmlTheme::header())),
459 )
460 .wrap(ratatui::widgets::Wrap { trim: true });
461
462 frame.render_widget(preview, area);
463 }
464}
465
466impl Default for FilesView {
467 fn default() -> Self {
468 Self::new()
469 }
470}
471
472fn format_size(bytes: u64) -> String {
477 const KB: u64 = 1024;
478 const MB: u64 = KB * 1024;
479 const GB: u64 = MB * 1024;
480
481 if bytes >= GB {
482 format!("{:.1} GB", bytes as f64 / GB as f64)
483 } else if bytes >= MB {
484 format!("{:.1} MB", bytes as f64 / MB as f64)
485 } else if bytes >= KB {
486 format!("{:.1} KB", bytes as f64 / KB as f64)
487 } else {
488 format!("{} B", bytes)
489 }
490}