dear_file_browser/ui/
mod.rs

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/// State for in-UI file browser
12#[derive(Debug)]
13pub struct FileBrowserState {
14    /// Whether to draw the browser (show/hide)
15    pub visible: bool,
16    /// Mode
17    pub mode: DialogMode,
18    /// Current working directory
19    pub cwd: PathBuf,
20    /// Selected entry names (relative to cwd)
21    pub selected: Vec<String>,
22    /// Optional filename input for SaveFile
23    pub save_name: String,
24    /// Filters (lower-case extensions)
25    pub filters: Vec<FileFilter>,
26    /// Active filter index (None = All)
27    pub active_filter: Option<usize>,
28    /// Click behavior for directories: select or navigate
29    pub click_action: ClickAction,
30    /// Search query to filter entries by substring (case-insensitive)
31    pub search: String,
32    /// Current sort column
33    pub sort_by: SortBy,
34    /// Sort order flag (true = ascending)
35    pub sort_ascending: bool,
36    /// Layout style for the browser UI
37    pub layout: LayoutStyle,
38    /// Allow selecting multiple files
39    pub allow_multi: bool,
40    /// Show dotfiles (simple heuristic)
41    pub show_hidden: bool,
42    /// Double-click navigates/confirm (directories/files)
43    pub double_click: bool,
44    /// Path edit mode (Ctrl+L)
45    pub path_edit: bool,
46    /// Path edit buffer
47    pub path_edit_buffer: String,
48    /// Focus path edit on next frame
49    pub focus_path_edit_next: bool,
50    /// Focus search on next frame (Ctrl+F)
51    pub focus_search_next: bool,
52    /// Result emitted when the user confirms or cancels
53    pub result: Option<Result<Selection, FileDialogError>>,
54    /// Error string to display in UI (non-fatal)
55    pub ui_error: Option<String>,
56    /// Max breadcrumb segments to display (compress with ellipsis when exceeded)
57    pub breadcrumbs_max_segments: usize,
58    /// Put directories before files when sorting
59    pub dirs_first: bool,
60    /// Show a hint row when no entries match filters/search
61    pub empty_hint_enabled: bool,
62    /// RGBA color of the empty hint text
63    pub empty_hint_color: [f32; 4],
64    /// Custom static hint message when entries list is empty; if None, a default message is built
65    pub empty_hint_static_message: Option<String>,
66}
67
68impl FileBrowserState {
69    /// Create a new state for a mode.
70    ///
71    /// Examples
72    /// ```no_run
73    /// use dear_file_browser::{DialogMode, FileBrowserState, FileDialogExt, FileFilter};
74    /// # use dear_imgui_rs::*;
75    /// # let mut ctx = Context::create();
76    /// # let ui = ctx.frame();
77    /// let mut state = FileBrowserState::new(DialogMode::OpenFiles);
78    /// // Optional configuration
79    /// state.dirs_first = true;
80    /// state.double_click = true;            // dbl-click file = confirm; dbl-click dir = enter
81    /// state.click_action = dear_file_browser::ClickAction::Select; // or Navigate
82    /// state.breadcrumbs_max_segments = 6;   // compress deep paths
83    /// // Filters are case-insensitive and extension names shouldn't include dots
84    /// state.set_filters(vec![FileFilter::from(("Images", &["png", "jpg", "jpeg"]))]);
85    ///
86    /// ui.window("File Browser").build(|| {
87    ///     if let Some(res) = ui.file_browser().show(&mut state) {
88    ///         match res {
89    ///             Ok(sel) => {
90    ///                 for p in sel.paths { eprintln!("{:?}", p); }
91    ///             }
92    ///             Err(e) => eprintln!("dialog cancelled or error: {e}"),
93    ///         }
94    ///     }
95    /// });
96    /// ```
97    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    /// Configure filters
130    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
139/// UI handle for file browser
140pub struct FileBrowser<'ui> {
141    pub ui: &'ui Ui,
142}
143
144/// Extend Ui with a file browser entry point
145pub trait FileDialogExt {
146    /// Entry point for showing the file browser widget
147    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    /// Draw the file browser and update the state.
158    /// Returns Some(result) once the user confirms/cancels; None otherwise.
159    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                // Top toolbar: Up, Refresh, Hidden toggle, Breadcrumbs, Filter, Search
173                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") { /* rescan happens each frame */ }
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                // Breadcrumbs or Path Edit
186                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                        // Try to canonicalize for nicer navigation; fall back to raw path on error
199                        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                // Search box (aligned to the right)
231                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                // Content region
241                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                // Footer: file name (Save) + buttons
275                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                // Filter selector (moved to footer like ImGuiFileDialog)
284                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                // Click behavior toggle
323                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                    // Special-case: if a single directory selected in file-open modes, navigate into it instead of confirming
342                    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        // Keyboard shortcuts
378        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    // Build crumbs first to avoid borrowing cwd while mutating it
442    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        // First segment
478        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        // Ellipsis
487        ui.text("...");
488        ui.same_line();
489        ui.text(">");
490        ui.same_line();
491        // Tail segments
492        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    // Home
513    if ui.button("Home") {
514        if let Some(home) = home_dir() {
515            *cwd = home;
516        }
517    }
518    // Root
519    if ui.button("Root") {
520        *cwd = PathBuf::from(std::path::MAIN_SEPARATOR.to_string());
521    }
522    // Drives (Windows)
523    #[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    // Gather entries
651    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    // Sort
667    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    // Table
681    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; // enable built-in header sorting
689    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            // Apply ImGui sort specs (single primary sort)
710            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            // Sort entries for display
730            entries.sort_by(|a, b| {
731                // Directories-first precedence (independent of asc/desc)
732                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            // Rows
752            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                    // Name
783                    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                    // Optional: Double-click behavior (navigate into dir or confirm selection)
811                    if state.double_click
812                        && ui.is_item_hovered()
813                        && ui.is_mouse_double_clicked(MouseButton::Left)
814                    {
815                        if e.is_dir {
816                            // Double-click directory: always navigate into it
817                            state.cwd.push(&e.name);
818                            state.selected.clear();
819                        } else if matches!(state.mode, DialogMode::OpenFile | DialogMode::OpenFiles)
820                        {
821                            // Double-click file: confirm immediately
822                            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                    // Size
836                    ui.table_next_column();
837                    ui.text(match e.size {
838                        Some(s) => format_size(s),
839                        None => String::new(),
840                    });
841                    // Modified
842                    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    // For older than a week, show short absolute date inline; full datetime remains in tooltip
885    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}