1use std::fs;
2use std::path::{Path, PathBuf};
3
4use dear_imgui_rs::Ui;
5use dear_imgui_rs::input::{Key, MouseButton};
6
7use crate::core::{
8 ClickAction, DialogMode, FileDialogError, FileFilter, LayoutStyle, Selection, SortBy,
9};
10
11#[derive(Debug)]
13pub struct FileBrowserState {
14 pub visible: bool,
16 pub mode: DialogMode,
18 pub cwd: PathBuf,
20 pub selected: Vec<String>,
22 pub save_name: String,
24 pub filters: Vec<FileFilter>,
26 pub active_filter: Option<usize>,
28 pub click_action: ClickAction,
30 pub search: String,
32 pub sort_by: SortBy,
34 pub sort_ascending: bool,
36 pub layout: LayoutStyle,
38 pub allow_multi: bool,
40 pub show_hidden: bool,
42 pub double_click: bool,
44 pub path_edit: bool,
46 pub path_edit_buffer: String,
48 pub focus_path_edit_next: bool,
50 pub focus_search_next: bool,
52 pub result: Option<Result<Selection, FileDialogError>>,
54 pub ui_error: Option<String>,
56 pub breadcrumbs_max_segments: usize,
58 pub dirs_first: bool,
60 pub empty_hint_enabled: bool,
62 pub empty_hint_color: [f32; 4],
64 pub empty_hint_static_message: Option<String>,
66}
67
68impl FileBrowserState {
69 pub fn new(mode: DialogMode) -> Self {
98 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
99 Self {
100 visible: true,
101 mode,
102 cwd,
103 selected: Vec::new(),
104 save_name: String::new(),
105 filters: Vec::new(),
106 active_filter: None,
107 click_action: ClickAction::Select,
108 search: String::new(),
109 sort_by: SortBy::Name,
110 sort_ascending: true,
111 layout: LayoutStyle::Standard,
112 allow_multi: matches!(mode, DialogMode::OpenFiles),
113 show_hidden: false,
114 double_click: true,
115 path_edit: false,
116 path_edit_buffer: String::new(),
117 focus_path_edit_next: false,
118 focus_search_next: false,
119 result: None,
120 ui_error: None,
121 breadcrumbs_max_segments: 6,
122 dirs_first: true,
123 empty_hint_enabled: true,
124 empty_hint_color: [0.7, 0.7, 0.7, 1.0],
125 empty_hint_static_message: None,
126 }
127 }
128
129 pub fn set_filters<I, F>(&mut self, filters: I)
131 where
132 I: IntoIterator<Item = F>,
133 F: Into<FileFilter>,
134 {
135 self.filters = filters.into_iter().map(Into::into).collect();
136 }
137}
138
139pub struct FileBrowser<'ui> {
141 pub ui: &'ui Ui,
142}
143
144pub trait FileDialogExt {
146 fn file_browser(&self) -> FileBrowser<'_>;
148}
149
150impl FileDialogExt for Ui {
151 fn file_browser(&self) -> FileBrowser<'_> {
152 FileBrowser { ui: self }
153 }
154}
155
156impl<'ui> FileBrowser<'ui> {
157 pub fn show(&self, state: &mut FileBrowserState) -> Option<Result<Selection, FileDialogError>> {
160 if !state.visible {
161 return None;
162 }
163 let title = match state.mode {
164 DialogMode::OpenFile | DialogMode::OpenFiles => "Open",
165 DialogMode::PickFolder => "Select Folder",
166 DialogMode::SaveFile => "Save",
167 };
168 self.ui
169 .window(title)
170 .size([760.0, 520.0], dear_imgui_rs::Condition::FirstUseEver)
171 .build(|| {
172 if self.ui.button("Up") {
174 let _ = up_dir(&mut state.cwd);
175 state.selected.clear();
176 }
177 self.ui.same_line();
178 if self.ui.button("Refresh") { }
179 self.ui.same_line();
180 let mut show_hidden = state.show_hidden;
181 if self.ui.checkbox("Hidden", &mut show_hidden) {
182 state.show_hidden = show_hidden;
183 }
184 self.ui.same_line();
185 if state.path_edit {
187 if state.focus_path_edit_next {
188 self.ui.set_keyboard_focus_here();
189 state.focus_path_edit_next = false;
190 }
191 self.ui
192 .input_text("##path_edit", &mut state.path_edit_buffer)
193 .build();
194 self.ui.same_line();
195 if self.ui.button("Go") {
196 let input = state.path_edit_buffer.trim();
197 let raw_p = PathBuf::from(input);
198 let p = std::fs::canonicalize(&raw_p).unwrap_or(raw_p.clone());
200 match std::fs::metadata(&p) {
201 Ok(md) => {
202 if md.is_dir() {
203 state.cwd = p;
204 state.selected.clear();
205 state.path_edit = false;
206 state.ui_error = None;
207 } else {
208 state.ui_error =
209 Some("Path exists but is not a directory".into());
210 }
211 }
212 Err(e) => {
213 use std::io::ErrorKind::*;
214 let msg = match e.kind() {
215 NotFound => format!("No such directory: {}", input),
216 PermissionDenied => format!("Permission denied: {}", input),
217 _ => format!("Invalid directory '{}': {}", input, e),
218 };
219 state.ui_error = Some(msg);
220 }
221 }
222 }
223 self.ui.same_line();
224 if self.ui.button("Cancel") {
225 state.path_edit = false;
226 }
227 } else {
228 draw_breadcrumbs(self.ui, &mut state.cwd, state.breadcrumbs_max_segments);
229 }
230 self.ui.same_line();
232 if state.focus_search_next {
233 self.ui.set_keyboard_focus_here();
234 state.focus_search_next = false;
235 }
236 self.ui.input_text("Search", &mut state.search).build();
237
238 self.ui.separator();
239
240 let avail = self.ui.content_region_avail();
242 match state.layout {
243 LayoutStyle::Standard => {
244 let left_w = 180.0f32;
245 self.ui
246 .child_window("quick_locations")
247 .size([left_w, avail[1] - 80.0])
248 .build(self.ui, || {
249 draw_quick_locations(self.ui, &mut state.cwd);
250 });
251 self.ui.same_line();
252 self.ui
253 .child_window("file_list")
254 .size([avail[0] - left_w - 8.0, avail[1] - 80.0])
255 .build(self.ui, || {
256 draw_file_table(
257 self.ui,
258 state,
259 [avail[0] - left_w - 8.0, avail[1] - 110.0],
260 );
261 });
262 }
263 LayoutStyle::Minimal => {
264 self.ui
265 .child_window("file_list_min")
266 .size([avail[0], avail[1] - 80.0])
267 .build(self.ui, || {
268 draw_file_table(self.ui, state, [avail[0], avail[1] - 110.0]);
269 });
270 }
271 }
272
273 self.ui.separator();
274 if matches!(state.mode, DialogMode::SaveFile) {
276 self.ui.text("File name:");
277 self.ui.same_line();
278 self.ui
279 .input_text("##save_name", &mut state.save_name)
280 .build();
281 self.ui.same_line();
282 }
283 if !state.filters.is_empty() && !matches!(state.mode, DialogMode::PickFolder) {
285 self.ui.same_line();
286 let preview = state
287 .active_filter
288 .and_then(|i| state.filters.get(i))
289 .map(|f| f.name.as_str())
290 .unwrap_or("All files");
291 if let Some(_c) = self.ui.begin_combo("Filter", preview) {
292 if self
293 .ui
294 .selectable_config("All files")
295 .selected(state.active_filter.is_none())
296 .build()
297 {
298 state.active_filter = None;
299 }
300 for (i, f) in state.filters.iter().enumerate() {
301 if self
302 .ui
303 .selectable_config(&f.name)
304 .selected(state.active_filter == Some(i))
305 .build()
306 {
307 state.active_filter = Some(i);
308 }
309 }
310 }
311 }
312
313 let confirm_label = match state.mode {
314 DialogMode::OpenFile | DialogMode::OpenFiles => "Open",
315 DialogMode::PickFolder => "Select",
316 DialogMode::SaveFile => "Save",
317 };
318 let confirm = self.ui.button(confirm_label);
319 self.ui.same_line();
320 let cancel = self.ui.button("Cancel");
321 self.ui.same_line();
322 let mut nav_on_click = matches!(state.click_action, ClickAction::Navigate);
324 if self.ui.checkbox("Navigate on click", &mut nav_on_click) {
325 state.click_action = if nav_on_click {
326 ClickAction::Navigate
327 } else {
328 ClickAction::Select
329 };
330 }
331 self.ui.same_line();
332 let mut dbl = state.double_click;
333 if self.ui.checkbox("DblClick confirm", &mut dbl) {
334 state.double_click = dbl;
335 }
336
337 if cancel {
338 state.result = Some(Err(FileDialogError::Cancelled));
339 state.visible = false;
340 } else if confirm {
341 if matches!(state.mode, DialogMode::OpenFile | DialogMode::OpenFiles)
343 && state.selected.len() == 1
344 {
345 let sel = state.selected[0].clone();
346 let is_dir = state.cwd.join(&sel).is_dir();
347 if is_dir {
348 state.cwd.push(sel);
349 state.selected.clear();
350 } else {
351 match finalize_selection(state) {
352 Ok(sel) => {
353 state.result = Some(Ok(sel));
354 state.visible = false;
355 }
356 Err(e) => state.ui_error = Some(e.to_string()),
357 }
358 }
359 } else {
360 match finalize_selection(state) {
361 Ok(sel) => {
362 state.result = Some(Ok(sel));
363 state.visible = false;
364 }
365 Err(e) => state.ui_error = Some(e.to_string()),
366 }
367 }
368 }
369
370 if let Some(err) = &state.ui_error {
371 self.ui.separator();
372 self.ui
373 .text_colored([1.0, 0.3, 0.3, 1.0], format!("Error: {err}"));
374 }
375 });
376
377 let ctrl = self.ui.is_key_down(Key::LeftCtrl) || self.ui.is_key_down(Key::RightCtrl);
379 if ctrl && self.ui.is_key_pressed(Key::L) {
380 state.path_edit = true;
381 state.path_edit_buffer = state.cwd.display().to_string();
382 state.focus_path_edit_next = true;
383 }
384 if ctrl && self.ui.is_key_pressed(Key::F) {
385 state.focus_search_next = true;
386 }
387 if !self.ui.io().want_capture_keyboard() && self.ui.is_key_pressed(Key::Backspace) {
388 let _ = up_dir(&mut state.cwd);
389 state.selected.clear();
390 }
391 if !state.path_edit && self.ui.is_key_pressed(Key::Enter) {
392 if matches!(state.mode, DialogMode::OpenFile | DialogMode::OpenFiles)
393 && state.selected.len() == 1
394 {
395 let sel = state.selected[0].clone();
396 let is_dir = state.cwd.join(&sel).is_dir();
397 if is_dir {
398 state.cwd.push(sel);
399 state.selected.clear();
400 } else {
401 match finalize_selection(state) {
402 Ok(sel) => {
403 state.result = Some(Ok(sel));
404 state.visible = false;
405 }
406 Err(e) => state.ui_error = Some(e.to_string()),
407 }
408 }
409 } else {
410 match finalize_selection(state) {
411 Ok(sel) => {
412 state.result = Some(Ok(sel));
413 state.visible = false;
414 }
415 Err(e) => state.ui_error = Some(e.to_string()),
416 }
417 }
418 }
419 state.result.take()
420 }
421}
422
423fn sort_label(name: &str, active: bool, asc: bool) -> String {
424 if active {
425 format!("{} {}", name, if asc { "▲" } else { "▼" })
426 } else {
427 name.to_string()
428 }
429}
430
431fn toggle_sort(sort_by: &mut SortBy, asc: &mut bool, new_key: SortBy) {
432 if *sort_by == new_key {
433 *asc = !*asc;
434 } else {
435 *sort_by = new_key;
436 *asc = true;
437 }
438}
439
440fn draw_breadcrumbs(ui: &Ui, cwd: &mut PathBuf, max_segments: usize) {
441 let mut crumbs: Vec<(String, PathBuf)> = Vec::new();
443 let mut acc = PathBuf::new();
444 for comp in cwd.components() {
445 use std::path::Component;
446 match comp {
447 Component::Prefix(p) => {
448 acc.push(p.as_os_str());
449 crumbs.push((p.as_os_str().to_string_lossy().to_string(), acc.clone()));
450 }
451 Component::RootDir => {
452 acc.push(std::path::MAIN_SEPARATOR.to_string());
453 crumbs.push((String::from(std::path::MAIN_SEPARATOR), acc.clone()));
454 }
455 Component::Normal(seg) => {
456 acc.push(seg);
457 crumbs.push((seg.to_string_lossy().to_string(), acc.clone()));
458 }
459 _ => {}
460 }
461 }
462 let mut new_cwd: Option<PathBuf> = None;
463 let n = crumbs.len();
464 let compress = max_segments > 0 && n > max_segments && max_segments >= 3;
465 if !compress {
466 for (i, (label, path)) in crumbs.iter().enumerate() {
467 if ui.button(label) {
468 new_cwd = Some(path.clone());
469 }
470 ui.same_line();
471 if i + 1 < n {
472 ui.text(">");
473 ui.same_line();
474 }
475 }
476 } else {
477 if let Some((label, path)) = crumbs.first() {
479 if ui.button(label) {
480 new_cwd = Some(path.clone());
481 }
482 ui.same_line();
483 ui.text(">");
484 ui.same_line();
485 }
486 ui.text("...");
488 ui.same_line();
489 ui.text(">");
490 ui.same_line();
491 let tail = max_segments - 2;
493 let start_tail = n.saturating_sub(tail);
494 for (i, (label, path)) in crumbs.iter().enumerate().skip(start_tail) {
495 if ui.button(label) {
496 new_cwd = Some(path.clone());
497 }
498 ui.same_line();
499 if i + 1 < n {
500 ui.text(">");
501 ui.same_line();
502 }
503 }
504 }
505 ui.new_line();
506 if let Some(p) = new_cwd {
507 *cwd = p;
508 }
509}
510
511fn draw_quick_locations(ui: &Ui, cwd: &mut PathBuf) {
512 if ui.button("Home") {
514 if let Some(home) = home_dir() {
515 *cwd = home;
516 }
517 }
518 if ui.button("Root") {
520 *cwd = PathBuf::from(std::path::MAIN_SEPARATOR.to_string());
521 }
522 #[cfg(target_os = "windows")]
524 {
525 ui.separator();
526 ui.text("Drives");
527 for d in windows_drives() {
528 if ui.button(&d) {
529 *cwd = PathBuf::from(d);
530 }
531 }
532 }
533}
534
535fn read_entries(dir: &Path, show_hidden: bool) -> Vec<DirEntry> {
536 let mut out = Vec::new();
537 if let Ok(rd) = fs::read_dir(dir) {
538 for e in rd.flatten() {
539 if let Ok(ft) = e.file_type() {
540 let name = e.file_name().to_string_lossy().to_string();
541 if !show_hidden && name.starts_with('.') {
542 continue;
543 }
544 let meta = e.metadata().ok();
545 let modified = meta.as_ref().and_then(|m| m.modified().ok());
546 let size = if ft.is_file() {
547 meta.as_ref().map(|m| m.len())
548 } else {
549 None
550 };
551 out.push(DirEntry {
552 name,
553 is_dir: ft.is_dir(),
554 size,
555 modified,
556 });
557 }
558 }
559 }
560 out
561}
562
563fn up_dir(path: &mut PathBuf) -> bool {
564 path.pop()
565}
566
567fn toggle_select(list: &mut Vec<String>, name: &str) {
568 if let Some(i) = list.iter().position(|s| s == name) {
569 list.remove(i);
570 } else {
571 list.push(name.to_string());
572 }
573}
574
575fn matches_filters(name: &str, filters: &[FileFilter]) -> bool {
576 if filters.is_empty() {
577 return true;
578 }
579 let ext = Path::new(name)
580 .extension()
581 .and_then(|s| s.to_str())
582 .map(|s| s.to_lowercase());
583 match ext {
584 Some(e) => filters.iter().any(|f| f.extensions.iter().any(|x| x == &e)),
585 None => false,
586 }
587}
588
589fn finalize_selection(state: &mut FileBrowserState) -> Result<Selection, FileDialogError> {
590 let mut sel = Selection { paths: Vec::new() };
591 let eff_filters = effective_filters(state);
592 match state.mode {
593 DialogMode::PickFolder => {
594 sel.paths.push(state.cwd.clone());
595 }
596 DialogMode::OpenFile | DialogMode::OpenFiles => {
597 let names = std::mem::take(&mut state.selected);
598 if names.is_empty() {
599 return Err(FileDialogError::InvalidPath("no selection".into()));
600 }
601 for n in names {
602 if !matches_filters(&n, &eff_filters) {
603 continue;
604 }
605 sel.paths.push(state.cwd.join(n));
606 }
607 if sel.paths.is_empty() {
608 return Err(FileDialogError::InvalidPath(
609 "no file matched filters".into(),
610 ));
611 }
612 }
613 DialogMode::SaveFile => {
614 let name = if state.save_name.trim().is_empty() {
615 return Err(FileDialogError::InvalidPath("empty file name".into()));
616 } else {
617 state.save_name.trim().to_string()
618 };
619 sel.paths.push(state.cwd.join(name));
620 }
621 }
622 Ok(sel)
623}
624
625#[derive(Clone, Debug)]
626struct DirEntry {
627 name: String,
628 is_dir: bool,
629 size: Option<u64>,
630 modified: Option<std::time::SystemTime>,
631}
632impl DirEntry {
633 fn display_name(&self) -> String {
634 if self.is_dir {
635 format!("[{}]", self.name)
636 } else {
637 self.name.clone()
638 }
639 }
640}
641
642fn effective_filters(state: &FileBrowserState) -> Vec<FileFilter> {
643 match state.active_filter {
644 Some(i) => state.filters.get(i).cloned().into_iter().collect(),
645 None => Vec::new(),
646 }
647}
648
649fn draw_file_table(ui: &Ui, state: &mut FileBrowserState, size: [f32; 2]) {
650 let mut entries: Vec<DirEntry> = read_entries(&state.cwd, state.show_hidden);
652 let display_filters: Vec<FileFilter> = effective_filters(state);
653 entries.retain(|e| {
654 let pass_kind = if matches!(state.mode, DialogMode::PickFolder) {
655 e.is_dir
656 } else {
657 e.is_dir || matches_filters(&e.name, &display_filters)
658 };
659 let pass_search = if state.search.is_empty() {
660 true
661 } else {
662 e.name.to_lowercase().contains(&state.search.to_lowercase())
663 };
664 pass_kind && pass_search
665 });
666 entries.sort_by(|a, b| {
668 let ord = match state.sort_by {
669 SortBy::Name => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
670 SortBy::Size => a.size.unwrap_or(0).cmp(&b.size.unwrap_or(0)),
671 SortBy::Modified => a.modified.cmp(&b.modified),
672 };
673 if state.sort_ascending {
674 ord
675 } else {
676 ord.reverse()
677 }
678 });
679
680 use dear_imgui_rs::{SortDirection, TableColumnFlags, TableFlags};
682 let flags = TableFlags::RESIZABLE
683 | TableFlags::ROW_BG
684 | TableFlags::BORDERS_V
685 | TableFlags::BORDERS_OUTER
686 | TableFlags::SCROLL_Y
687 | TableFlags::SIZING_STRETCH_PROP
688 | TableFlags::SORTABLE; ui.table("file_table")
690 .flags(flags)
691 .outer_size(size)
692 .column("Name")
693 .flags(TableColumnFlags::PREFER_SORT_ASCENDING)
694 .user_id(0)
695 .weight(0.6)
696 .done()
697 .column("Size")
698 .flags(TableColumnFlags::PREFER_SORT_DESCENDING)
699 .user_id(1)
700 .weight(0.2)
701 .done()
702 .column("Modified")
703 .flags(TableColumnFlags::PREFER_SORT_DESCENDING)
704 .user_id(2)
705 .weight(0.2)
706 .done()
707 .headers(true)
708 .build(|ui| {
709 if let Some(mut specs) = ui.table_get_sort_specs() {
711 if specs.is_dirty() {
712 if let Some(s) = specs.iter().next() {
713 let (by, asc) = match (s.column_index, s.sort_direction) {
714 (0, SortDirection::Ascending) => (SortBy::Name, true),
715 (0, SortDirection::Descending) => (SortBy::Name, false),
716 (1, SortDirection::Ascending) => (SortBy::Size, true),
717 (1, SortDirection::Descending) => (SortBy::Size, false),
718 (2, SortDirection::Ascending) => (SortBy::Modified, true),
719 (2, SortDirection::Descending) => (SortBy::Modified, false),
720 _ => (state.sort_by, state.sort_ascending),
721 };
722 state.sort_by = by;
723 state.sort_ascending = asc;
724 }
725 specs.clear_dirty();
726 }
727 }
728
729 entries.sort_by(|a, b| {
731 if state.dirs_first && a.is_dir != b.is_dir {
733 return if a.is_dir {
734 std::cmp::Ordering::Less
735 } else {
736 std::cmp::Ordering::Greater
737 };
738 }
739 let ord = match state.sort_by {
740 SortBy::Name => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
741 SortBy::Size => a.size.unwrap_or(0).cmp(&b.size.unwrap_or(0)),
742 SortBy::Modified => a.modified.cmp(&b.modified),
743 };
744 if state.sort_ascending {
745 ord
746 } else {
747 ord.reverse()
748 }
749 });
750
751 if entries.is_empty() {
753 if state.empty_hint_enabled {
754 ui.table_next_row();
755 ui.table_next_column();
756 let msg = if let Some(custom) = &state.empty_hint_static_message {
757 custom.clone()
758 } else {
759 let filter_label = state
760 .active_filter
761 .and_then(|i| state.filters.get(i))
762 .map(|f| f.name.as_str())
763 .unwrap_or("All files");
764 let hidden_label = if state.show_hidden { "on" } else { "off" };
765 if state.search.is_empty() {
766 format!(
767 "No matching entries. Filter: {}, Hidden: {}",
768 filter_label, hidden_label
769 )
770 } else {
771 format!(
772 "No matching entries. Filter: {}, Search: '{}', Hidden: {}",
773 filter_label, state.search, hidden_label
774 )
775 }
776 };
777 ui.text_colored(state.empty_hint_color, msg);
778 }
779 } else {
780 for e in &entries {
781 ui.table_next_row();
782 ui.table_next_column();
784 let selected = state.selected.iter().any(|s| s == &e.name);
785 let label = e.display_name();
786 if ui
787 .selectable_config(label)
788 .selected(selected)
789 .span_all_columns(false)
790 .build()
791 {
792 if e.is_dir {
793 match state.click_action {
794 ClickAction::Select => {
795 state.selected.clear();
796 state.selected.push(e.name.clone());
797 }
798 ClickAction::Navigate => {
799 state.cwd.push(&e.name);
800 state.selected.clear();
801 }
802 }
803 } else {
804 if !state.allow_multi {
805 state.selected.clear();
806 }
807 toggle_select(&mut state.selected, &e.name);
808 }
809 }
810 if state.double_click
812 && ui.is_item_hovered()
813 && ui.is_mouse_double_clicked(MouseButton::Left)
814 {
815 if e.is_dir {
816 state.cwd.push(&e.name);
818 state.selected.clear();
819 } else if matches!(state.mode, DialogMode::OpenFile | DialogMode::OpenFiles)
820 {
821 state.selected.clear();
823 state.selected.push(e.name.clone());
824 match finalize_selection(state) {
825 Ok(sel) => {
826 state.result = Some(Ok(sel));
827 state.visible = false;
828 }
829 Err(err) => {
830 state.ui_error = Some(err.to_string());
831 }
832 }
833 }
834 }
835 ui.table_next_column();
837 ui.text(match e.size {
838 Some(s) => format_size(s),
839 None => String::new(),
840 });
841 ui.table_next_column();
843 let modified_str = format_modified_ago(e.modified);
844 ui.text(&modified_str);
845 if ui.is_item_hovered() {
846 if let Some(m) = e.modified {
847 use chrono::{DateTime, Local, TimeZone};
848 let dt: DateTime<Local> = DateTime::<Local>::from(m);
849 ui.tooltip_text(dt.format("%Y-%m-%d %H:%M:%S").to_string());
850 }
851 }
852 }
853 }
854 });
855}
856
857fn format_size(size: u64) -> String {
858 const KB: f64 = 1024.0;
859 const MB: f64 = KB * 1024.0;
860 const GB: f64 = MB * 1024.0;
861 let s = size as f64;
862 if s >= GB {
863 format!("{:.2} GB", s / GB)
864 } else if s >= MB {
865 format!("{:.2} MB", s / MB)
866 } else if s >= KB {
867 format!("{:.0} KB", s / KB)
868 } else {
869 format!("{} B", size)
870 }
871}
872
873fn format_modified_ago(modified: Option<std::time::SystemTime>) -> String {
874 use std::time::{Duration, SystemTime};
875 let m = match modified {
876 Some(t) => t,
877 None => return String::new(),
878 };
879 let now = SystemTime::now();
880 let delta = match now.duration_since(m) {
881 Ok(d) => d,
882 Err(e) => e.duration(),
883 };
884 const DAY: u64 = 24 * 60 * 60;
886 const WEEK: u64 = 7 * DAY;
887 if delta.as_secs() >= WEEK {
888 use chrono::{DateTime, Local};
889 let dt: DateTime<Local> = DateTime::<Local>::from(m);
890 return dt.format("%Y-%m-%d").to_string();
891 }
892 humanize_duration(delta)
893}
894
895fn humanize_duration(d: std::time::Duration) -> String {
896 let secs = d.as_secs();
897 const MIN: u64 = 60;
898 const HOUR: u64 = 60 * MIN;
899 const DAY: u64 = 24 * HOUR;
900 const WEEK: u64 = 7 * DAY;
901 if secs < 10 {
902 return "just now".into();
903 }
904 if secs < MIN {
905 return format!("{}s ago", secs);
906 }
907 if secs < HOUR {
908 return format!("{}m ago", secs / MIN);
909 }
910 if secs < DAY {
911 return format!("{}h ago", secs / HOUR);
912 }
913 if secs < WEEK {
914 return format!("{}d ago", secs / DAY);
915 }
916 let days = secs / DAY;
917 format!("{}d ago", days)
918}
919
920fn home_dir() -> Option<PathBuf> {
921 std::env::var_os("HOME")
922 .map(PathBuf::from)
923 .or_else(|| std::env::var_os("USERPROFILE").map(PathBuf::from))
924}
925
926#[cfg(target_os = "windows")]
927fn windows_drives() -> Vec<String> {
928 let mut v = Vec::new();
929 for c in b'A'..=b'Z' {
930 let s = format!("{}:\\", c as char);
931 if Path::new(&s).exists() {
932 v.push(s);
933 }
934 }
935 v
936}