1use crate::key::{self, Binding};
16use rusty_bubbletea::key::KeyPressMsg;
17use rusty_bubbletea::model::{Cmd, Msg};
18use rusty_lipgloss::{self, Style, RIGHT};
19use std::path::{Path, PathBuf};
20use std::sync::atomic::{AtomicI64, Ordering};
21
22static LAST_ID: AtomicI64 = AtomicI64::new(0);
23
24fn next_id() -> i32 {
25 (LAST_ID.fetch_add(1, Ordering::SeqCst)) as i32
26}
27
28pub fn new() -> Model {
30 Model {
31 id: next_id(),
32 current_directory: ".".to_string(),
33 cursor: ">".to_string(),
34 allowed_types: vec![],
35 selected: 0,
36 show_permissions: true,
37 show_size: true,
38 show_hidden: false,
39 dir_allowed: false,
40 file_allowed: true,
41 auto_height: true,
42 height: 0,
43 max_idx: 0,
44 min_idx: 0,
45 selected_stack: Stack::new(),
46 min_stack: Stack::new(),
47 max_stack: Stack::new(),
48 key_map: default_key_map(),
49 styles: default_styles(),
50 path: String::new(),
51 files: vec![],
52 file_selected: String::new(),
53 }
54}
55
56#[derive(Debug)]
58pub struct ErrorMsg(pub String);
59
60#[derive(Debug)]
62pub struct ReadDirMsg {
63 id: i32,
64 pub entries: Vec<DirEntry>,
66}
67
68#[derive(Debug, Clone)]
70pub struct DirEntry {
71 pub name: String,
73 pub is_dir: bool,
75 pub size: u64,
77 pub mode_string: String,
79 pub is_symlink: bool,
81}
82
83const MARGIN_BOTTOM: usize = 5;
84const FILE_SIZE_WIDTH: usize = 7;
85const PADDING_LEFT: usize = 2;
86
87#[derive(Debug, Clone)]
89pub struct KeyMap {
90 pub go_to_top: Binding,
92 pub go_to_last: Binding,
94 pub down: Binding,
96 pub up: Binding,
98 pub page_up: Binding,
100 pub page_down: Binding,
102 pub back: Binding,
104 pub open: Binding,
106 pub select: Binding,
108}
109
110pub fn default_key_map() -> KeyMap {
112 KeyMap {
113 go_to_top: key::new_binding(vec![key::with_keys(&["g"]), key::with_help("g", "first")]),
114 go_to_last: key::new_binding(vec![key::with_keys(&["G"]), key::with_help("G", "last")]),
115 down: key::new_binding(vec![
116 key::with_keys(&["j", "down", "ctrl+n"]),
117 key::with_help("j", "down"),
118 ]),
119 up: key::new_binding(vec![
120 key::with_keys(&["k", "up", "ctrl+p"]),
121 key::with_help("k", "up"),
122 ]),
123 page_up: key::new_binding(vec![
124 key::with_keys(&["K", "pgup"]),
125 key::with_help("pgup", "page up"),
126 ]),
127 page_down: key::new_binding(vec![
128 key::with_keys(&["J", "pgdown"]),
129 key::with_help("pgdown", "page down"),
130 ]),
131 back: key::new_binding(vec![
132 key::with_keys(&["h", "backspace", "left", "esc"]),
133 key::with_help("h", "back"),
134 ]),
135 open: key::new_binding(vec![
136 key::with_keys(&["l", "right", "enter"]),
137 key::with_help("l", "open"),
138 ]),
139 select: key::new_binding(vec![
140 key::with_keys(&["enter"]),
141 key::with_help("enter", "select"),
142 ]),
143 }
144}
145
146#[derive(Debug, Clone)]
148pub struct Styles {
149 pub disabled_cursor: Style,
151 pub cursor: Style,
153 pub symlink: Style,
155 pub directory: Style,
157 pub file: Style,
159 pub disabled_file: Style,
161 pub permission: Style,
163 pub selected: Style,
165 pub disabled_selected: Style,
167 pub file_size: Style,
169 pub empty_directory: Style,
171}
172
173pub fn default_styles() -> Styles {
175 Styles {
176 disabled_cursor: rusty_lipgloss::new_style().foreground("247"),
177 cursor: rusty_lipgloss::new_style().foreground("212"),
178 symlink: rusty_lipgloss::new_style().foreground("36"),
179 directory: rusty_lipgloss::new_style().foreground("99"),
180 file: rusty_lipgloss::new_style(),
181 disabled_file: rusty_lipgloss::new_style().foreground("243"),
182 disabled_selected: rusty_lipgloss::new_style().foreground("247"),
183 permission: rusty_lipgloss::new_style().foreground("244"),
184 selected: rusty_lipgloss::new_style().foreground("212").bold(true),
185 file_size: rusty_lipgloss::new_style()
186 .foreground("240")
187 .width(FILE_SIZE_WIDTH)
188 .align(&[RIGHT]),
189 empty_directory: rusty_lipgloss::new_style()
190 .foreground("240")
191 .padding_left(PADDING_LEFT)
192 .set_string(&["Bummer. No Files Found."]),
193 }
194}
195
196#[derive(Debug)]
198pub struct Model {
199 id: i32,
200
201 pub path: String,
203
204 pub current_directory: String,
206
207 pub allowed_types: Vec<String>,
210
211 pub key_map: KeyMap,
213 files: Vec<DirEntry>,
214 pub show_permissions: bool,
216 pub show_size: bool,
218 pub show_hidden: bool,
220 pub dir_allowed: bool,
222 pub file_allowed: bool,
224
225 pub file_selected: String,
227 selected: usize,
228 selected_stack: Stack,
229
230 min_idx: usize,
231 max_idx: usize,
232 max_stack: Stack,
233 min_stack: Stack,
234
235 height: usize,
236 pub auto_height: bool,
238
239 pub cursor: String,
241 pub styles: Styles,
243}
244
245#[derive(Debug, Default)]
248pub struct Stack {
249 slice: Vec<usize>,
250}
251
252impl Stack {
253 fn new() -> Stack {
254 Stack { slice: vec![] }
255 }
256
257 fn push(&mut self, i: usize) {
258 self.slice.push(i);
259 }
260
261 fn pop(&mut self) -> usize {
262 let res = self.slice[self.slice.len() - 1];
263 self.slice.pop();
264 res
265 }
266
267 fn length(&self) -> usize {
268 self.slice.len()
269 }
270}
271
272impl Model {
273 fn push_view(&mut self, selected: usize, minimum: usize, maximum: usize) {
274 self.selected_stack.push(selected);
275 self.min_stack.push(minimum);
276 self.max_stack.push(maximum);
277 }
278
279 fn pop_view(&mut self) -> (usize, usize, usize) {
280 (
281 self.selected_stack.pop(),
282 self.min_stack.pop(),
283 self.max_stack.pop(),
284 )
285 }
286
287 fn read_dir_cmd(&self, path: &str, show_hidden: bool) -> Cmd {
288 let path = path.to_string();
289 let id = self.id;
290 Some(Box::new(move || {
291 let mut entries: Vec<DirEntry> = vec![];
292 if let Ok(rd) = std::fs::read_dir(&path) {
293 for entry in rd.flatten() {
294 let name = entry.file_name().to_string_lossy().to_string();
295 let metadata = entry.metadata();
296 let (is_dir, size, mode_string, is_symlink) = match metadata {
297 Ok(md) => {
298 let is_symlink = md.file_type().is_symlink();
299 (md.is_dir(), md.len(), file_mode_string(&md), is_symlink)
300 }
301 Err(_) => (false, 0, "----------".to_string(), false),
302 };
303 entries.push(DirEntry {
304 name,
305 is_dir,
306 size,
307 mode_string,
308 is_symlink,
309 });
310 }
311 }
312 entries.sort_by(|a, b| {
313 if a.is_dir == b.is_dir {
314 a.name.cmp(&b.name)
315 } else {
316 b.is_dir.cmp(&a.is_dir)
317 }
318 });
319
320 if show_hidden {
321 return Some(Box::new(ReadDirMsg { id, entries }));
322 }
323
324 let mut sanitized: Vec<DirEntry> = vec![];
325 for dir_entry in entries {
326 let is_hidden = is_hidden(&dir_entry.name);
327 if is_hidden {
328 continue;
329 }
330 sanitized.push(dir_entry);
331 }
332 Some(Box::new(ReadDirMsg {
333 id,
334 entries: sanitized,
335 }))
336 }))
337 }
338
339 pub fn set_height(&mut self, h: usize) {
341 self.height = h;
342 if self.max_idx > self.height.saturating_sub(1) {
343 self.max_idx = self.min_idx + self.height - 1;
344 }
345 }
346
347 pub fn height(&self) -> usize {
349 self.height
350 }
351
352 pub fn init(&self) -> Cmd {
354 self.read_dir_cmd(&self.current_directory.clone(), self.show_hidden)
355 }
356
357 pub fn update(&mut self, msg: &dyn Msg) -> Cmd {
359 if let Some(m) = msg.as_any().downcast_ref::<ReadDirMsg>() {
360 if m.id != self.id {
361 return None;
362 }
363 self.files = m.entries.clone();
364 self.max_idx = self.max_idx.max(self.height().saturating_sub(1));
365 return None;
366 }
367
368 if msg.as_any().downcast_ref::<ErrorMsg>().is_some() {
369 return None;
370 }
371
372 if let Some(m) = msg
373 .as_any()
374 .downcast_ref::<rusty_bubbletea::screen::WindowSizeMsg>()
375 {
376 if self.auto_height {
377 self.set_height(m.height - MARGIN_BOTTOM);
378 }
379 self.max_idx = self.height() - 1;
380 return None;
381 }
382
383 if let Some(m) = msg.as_any().downcast_ref::<KeyPressMsg>() {
384 let k = &m.0;
385 if key::matches(k, std::slice::from_ref(&self.key_map.go_to_top)) {
386 self.selected = 0;
387 self.min_idx = 0;
388 self.max_idx = self.height() - 1;
389 } else if key::matches(k, std::slice::from_ref(&self.key_map.go_to_last)) {
390 self.selected = self.files.len().saturating_sub(1);
391 self.min_idx = self.files.len() - self.height();
392 self.max_idx = self.files.len() - 1;
393 } else if key::matches(k, std::slice::from_ref(&self.key_map.down)) {
394 self.selected += 1;
395 if self.selected >= self.files.len() {
396 self.selected = self.files.len().saturating_sub(1);
397 }
398 if self.selected > self.max_idx {
399 self.min_idx += 1;
400 self.max_idx += 1;
401 }
402 } else if key::matches(k, std::slice::from_ref(&self.key_map.up)) {
403 self.selected = self.selected.saturating_sub(1);
404 if self.selected < self.min_idx {
405 self.min_idx = self.min_idx.saturating_sub(1);
406 self.max_idx = self.max_idx.saturating_sub(1);
407 }
408 } else if key::matches(k, std::slice::from_ref(&self.key_map.page_down)) {
409 self.selected += self.height();
410 if self.selected >= self.files.len() {
411 self.selected = self.files.len().saturating_sub(1);
412 }
413 self.min_idx += self.height();
414 self.max_idx += self.height();
415
416 if self.max_idx >= self.files.len() {
417 self.max_idx = self.files.len() - 1;
418 self.min_idx = self.max_idx - self.height();
419 }
420 } else if key::matches(k, std::slice::from_ref(&self.key_map.page_up)) {
421 self.selected = self.selected.saturating_sub(self.height());
422 self.min_idx = self.min_idx.saturating_sub(self.height());
423 self.max_idx = self.max_idx.saturating_sub(self.height());
424
425 if self.min_idx == 0 {
426 self.max_idx = self.min_idx + self.height();
428 }
429 } else if key::matches(k, std::slice::from_ref(&self.key_map.back)) {
430 self.current_directory = Path::new(&self.current_directory)
431 .parent()
432 .map(|p| p.to_string_lossy().to_string())
433 .unwrap_or_else(|| "/".to_string());
434 if self.selected_stack.length() > 0 {
435 let (s, mn, mx) = self.pop_view();
436 self.selected = s;
437 self.min_idx = mn;
438 self.max_idx = mx;
439 } else {
440 self.selected = 0;
441 self.min_idx = 0;
442 self.max_idx = self.height() - 1;
443 }
444 return self.read_dir_cmd(&self.current_directory.clone(), self.show_hidden);
445 } else if key::matches(k, std::slice::from_ref(&self.key_map.open)) {
446 if self.files.is_empty() {
447 return None;
448 }
449
450 let f = self.files[self.selected].clone();
451 let is_symlink = f.is_symlink;
452 let mut is_dir = f.is_dir;
453
454 if is_symlink {
455 let symlink_path = PathBuf::from(&self.current_directory)
456 .join(&f.name)
457 .canonicalize()
458 .unwrap_or_default();
459 if symlink_path.is_dir() {
460 is_dir = true;
461 }
462 }
463
464 if ((!is_dir && self.file_allowed) || (is_dir && self.dir_allowed))
465 && key::matches(k, std::slice::from_ref(&self.key_map.select))
466 {
467 self.path = PathBuf::from(&self.current_directory)
469 .join(&f.name)
470 .to_string_lossy()
471 .to_string();
472 }
473
474 if !is_dir {
475 return None;
476 }
477
478 self.current_directory = PathBuf::from(&self.current_directory)
479 .join(&f.name)
480 .to_string_lossy()
481 .to_string();
482 self.push_view(self.selected, self.min_idx, self.max_idx);
483 self.selected = 0;
484 self.min_idx = 0;
485 self.max_idx = self.height() - 1;
486 return self.read_dir_cmd(&self.current_directory.clone(), self.show_hidden);
487 }
488 }
489 None
490 }
491
492 pub fn view(&self) -> String {
494 if self.files.is_empty() {
495 let v = self
496 .styles
497 .empty_directory
498 .clone()
499 .set_string(&["Bummer. No Files Found."])
500 .height(self.height())
501 .max_height(self.height())
502 .render("");
503 return v;
504 }
505 let mut s = String::new();
506
507 for (i, f) in self.files.iter().enumerate() {
508 if i < self.min_idx || i > self.max_idx {
509 continue;
510 }
511
512 let mut symlink_path = String::new();
513 let is_symlink = f.is_symlink;
514 let size = humanize_bytes(f.size);
515 let name = &f.name;
516
517 if is_symlink {
518 symlink_path = PathBuf::from(&self.current_directory)
519 .join(name)
520 .canonicalize()
521 .map(|p| p.to_string_lossy().to_string())
522 .unwrap_or_default();
523 }
524
525 let disabled = !self.can_select(name) && !f.is_dir;
526
527 if self.selected == i {
528 let mut selected = String::new();
529 if self.show_permissions {
530 selected += " ";
531 selected += &f.mode_string;
532 }
533 if self.show_size {
534 selected += &format!(
535 "{:>width$}",
536 size,
537 width = self.styles.file_size.get_width()
538 );
539 }
540 selected += " ";
541 selected += name;
542 if is_symlink {
543 selected += " → ";
544 selected += &symlink_path;
545 }
546 if disabled {
547 s += &self.styles.disabled_cursor.clone().render(&self.cursor);
548 s += &self.styles.disabled_selected.clone().render(&selected);
549 } else {
550 s += &self.styles.cursor.clone().render(&self.cursor);
551 s += &self.styles.selected.clone().render(&selected);
552 }
553 s += "\n";
554 continue;
555 }
556
557 let mut style = self.styles.file.clone();
558 if f.is_dir {
559 style = self.styles.directory.clone();
560 } else if is_symlink {
561 style = self.styles.symlink.clone();
562 } else if disabled {
563 style = self.styles.disabled_file.clone();
564 }
565
566 let mut file_name = style.render(name);
567 s += &self.styles.cursor.clone().render(" ");
568 if is_symlink {
569 file_name += " → ";
570 file_name += &symlink_path;
571 }
572 if self.show_permissions {
573 s += " ";
574 s += &self.styles.permission.clone().render(&f.mode_string);
575 }
576 if self.show_size {
577 s += &self.styles.file_size.clone().render(&size);
578 }
579 s += " ";
580 s += &file_name;
581 s += "\n";
582 }
583
584 for _ in rusty_lipgloss::size::height(&s)..=self.height() {
585 s += "\n";
586 }
587
588 s
589 }
590
591 pub fn did_select_file(&self, msg: &dyn Msg) -> (bool, String) {
594 let (did_select, path) = self.did_select_file_inner(msg);
595 if did_select && self.can_select(&path) {
596 return (true, path);
597 }
598 (false, String::new())
599 }
600
601 pub fn did_select_disabled_file(&self, msg: &dyn Msg) -> (bool, String) {
604 let (did_select, path) = self.did_select_file_inner(msg);
605 if did_select && !self.can_select(&path) {
606 return (true, path);
607 }
608 (false, String::new())
609 }
610
611 fn did_select_file_inner(&self, msg: &dyn Msg) -> (bool, String) {
612 if self.files.is_empty() {
613 return (false, String::new());
614 }
615 let m = msg.as_any().downcast_ref::<KeyPressMsg>();
616 match m {
617 Some(m) => {
618 if !key::matches(&m.0, std::slice::from_ref(&self.key_map.select)) {
621 return (false, String::new());
622 }
623
624 let f = &self.files[self.selected];
628 let is_symlink = f.is_symlink;
629 let mut is_dir = f.is_dir;
630
631 if is_symlink {
632 let symlink_path = PathBuf::from(&self.current_directory)
633 .join(&f.name)
634 .canonicalize()
635 .unwrap_or_default();
636 if symlink_path.is_dir() {
637 is_dir = true;
638 }
639 }
640
641 if ((!is_dir && self.file_allowed) || (is_dir && self.dir_allowed))
642 && !self.path.is_empty()
643 {
644 return (true, self.path.clone());
645 }
646
647 (false, String::new())
648 }
649 None => (false, String::new()),
650 }
651 }
652
653 fn can_select(&self, file: &str) -> bool {
654 if self.allowed_types.is_empty() {
655 return true;
656 }
657
658 for ext in &self.allowed_types {
659 if file.ends_with(ext.as_str()) {
660 return true;
661 }
662 }
663 false
664 }
665
666 pub fn highlighted_path(&self) -> String {
669 if self.files.is_empty() || self.selected >= self.files.len() {
670 return String::new();
671 }
672 PathBuf::from(&self.current_directory)
673 .join(&self.files[self.selected].name)
674 .to_string_lossy()
675 .to_string()
676 }
677}
678
679#[cfg(not(windows))]
685fn is_hidden(name: &str) -> bool {
686 name.starts_with('.')
687}
688
689#[cfg(windows)]
692fn is_hidden(name: &str) -> bool {
693 use std::os::windows::ffi::OsStrExt;
694
695 #[link(name = "kernel32")]
696 extern "system" {
697 fn GetFileAttributesW(lp_file_name: *const u16) -> u32;
698 }
699
700 const FILE_ATTRIBUTE_HIDDEN: u32 = 0x2;
701 const INVALID_FILE_ATTRIBUTES: u32 = u32::MAX;
702
703 let wide: Vec<u16> = std::ffi::OsStr::new(name)
704 .encode_wide()
705 .chain(std::iter::once(0))
706 .collect();
707 unsafe {
708 let attributes = GetFileAttributesW(wide.as_ptr());
709 attributes != INVALID_FILE_ATTRIBUTES && (attributes & FILE_ATTRIBUTE_HIDDEN) != 0
710 }
711}
712
713fn humanize_bytes(size: u64) -> String {
716 if size < 10 {
717 return format!("{} B", size);
718 }
719 let base = 1000.0f64;
720 let sizes = ["B", "kB", "MB", "GB", "TB", "PB", "EB"];
721 let n = (size as f64).log(base).floor() as usize;
722 let suffix = sizes[n.min(sizes.len() - 1)];
723 let val = (size as f64 / base.powi(n as i32) * 10.0 + 0.5).floor() / 10.0;
724 if val < 10.0 {
725 return format!("{:.1} {}", val, suffix).replace(' ', "");
726 }
727 format!("{:.0} {}", val, suffix).replace(' ', "")
728}
729
730#[cfg(unix)]
733fn file_mode_string(md: &std::fs::Metadata) -> String {
734 use std::os::unix::fs::{FileTypeExt, MetadataExt};
735
736 let mut s = String::with_capacity(10);
737 let mode = md.mode();
738
739 let kind = md.file_type();
741 if kind.is_dir() {
742 s.push('d');
743 } else if kind.is_symlink() {
744 s.push('L');
745 } else if kind.is_char_device() {
746 s.push('c');
747 } else if kind.is_block_device() {
748 s.push('b');
749 } else if kind.is_fifo() {
750 s.push('p');
751 } else if kind.is_socket() {
752 s.push('S');
753 } else {
754 s.push('-');
755 }
756
757 let perm = mode & 0o7777;
759 let chars = ['r', 'w', 'x'];
760 for i in 0..9 {
761 if perm & (0o400 >> i) != 0 {
762 s.push(chars[i % 3]);
763 } else {
764 s.push('-');
765 }
766 if i == 2 {
767 if perm & 0o4000 != 0 {
768 let c = s.pop().unwrap();
769 s.push(if c == 'x' { 's' } else { 'S' });
770 }
771 } else if i == 5 {
772 if perm & 0o2000 != 0 {
773 let c = s.pop().unwrap();
774 s.push(if c == 'x' { 's' } else { 'S' });
775 }
776 } else if i == 8 && perm & 0o1000 != 0 {
777 let c = s.pop().unwrap();
778 s.push(if c == 'x' { 't' } else { 'T' });
779 }
780 }
781 s
782}
783
784#[cfg(not(unix))]
785fn file_mode_string(_md: &std::fs::Metadata) -> String {
786 "-rw-r--r--".to_string()
787}