Skip to main content

tree_space/ui/
props.rs

1//! The Properties dialog.
2//!
3//! Mirrors what Nautilus shows for a single path: a **Basic** tab (name, type,
4//! size/contents, location, free space and timestamps), a **Permissions** tab
5//! (owner/group and the classic read/write/execute matrix, applied as you
6//! change them) and, for images, a small **Image** tab with the pixel
7//! dimensions. Metadata comes from [`crate::fs::meta`]; this module is only the
8//! presentation and the wiring of edits back to the filesystem.
9//!
10//! Like the "Open With..." chooser, the dialog is shown as a centered overlay
11//! *layer surface*: a plain toplevel has no toplevel parent to be transient for
12//! (the panel is itself a layer surface), so Hyprland would tile it instead of
13//! floating it.
14
15use std::cell::RefCell;
16use std::path::Path;
17use std::rc::Rc;
18use std::time::{SystemTime, UNIX_EPOCH};
19
20use gtk4_layer_shell::{KeyboardMode, Layer, LayerShell};
21use relm4::gtk::{gdk, glib, pango, prelude::*};
22use relm4::prelude::*;
23
24use crate::fs::meta::{self, Access, FileMeta, Permissions};
25use crate::ui::tree::{Tree, TreeMsg, TreeOutput};
26
27/// Show the Properties dialog for `path`, applying edits live (as Nautilus
28/// does) and reporting failures in its own status line.
29pub fn show_properties_dialog(parent: &gtk::Window, path: &Path, sender: &ComponentSender<Tree>) {
30    let Ok(meta) = meta::read_meta(path) else {
31        let _ = sender.output(TreeOutput::Status(format!(
32            "Could not read properties of {}",
33            path.display()
34        )));
35        return;
36    };
37
38    let window = gtk::Window::new();
39    window.set_title(Some(&format!("Properties — {}", meta.name)));
40    window.set_default_size(500, 600);
41    window.set_transient_for(Some(parent));
42    // Keep it centered and modal-like, and let entries/combos take keys. Only
43    // possible with layer-shell support; otherwise it stays a normal transient
44    // window (the `set_transient_for` above keeps it on top of the panel).
45    if gtk4_layer_shell::is_supported() && !window.is_layer_window() {
46        window.init_layer_shell();
47        window.set_layer(Layer::Overlay);
48        window.set_keyboard_mode(KeyboardMode::Exclusive);
49    }
50
51    let status = gtk::Label::new(None);
52    status.add_css_class("props-status");
53    status.set_hexpand(true);
54    status.set_xalign(0.0);
55    status.set_ellipsize(pango::EllipsizeMode::End);
56    let set_status: Rc<dyn Fn(String)> = {
57        let status = status.clone();
58        Rc::new(move |message| status.set_text(&message))
59    };
60
61    // ── header ──────────────────────────────────────────────────────────────
62    let header = gtk::Box::new(gtk::Orientation::Horizontal, 10);
63    header.add_css_class("props-header");
64    let icon = gtk::Image::from_icon_name(icon_for(&meta));
65    icon.set_pixel_size(28);
66    let title = gtk::Label::new(Some(&meta.name));
67    title.add_css_class("props-title");
68    title.set_ellipsize(pango::EllipsizeMode::Middle);
69    title.set_xalign(0.0);
70    title.set_hexpand(true);
71    let close_button = gtk::Button::from_icon_name("window-close-symbolic");
72    close_button.add_css_class("flat");
73    header.append(&icon);
74    header.append(&title);
75    header.append(&close_button);
76
77    // ── tabs ────────────────────────────────────────────────────────────────
78    let notebook = gtk::Notebook::new();
79    notebook.set_vexpand(true);
80    // The pages must not force the window wider than we asked for: long paths
81    // and the permission controls would otherwise grow the surface.
82    let scroller = gtk::ScrolledWindow::new();
83    scroller.set_policy(gtk::PolicyType::Never, gtk::PolicyType::Automatic);
84    scroller.set_propagate_natural_width(false);
85    scroller.set_propagate_natural_height(false);
86    scroller.set_vexpand(true);
87    scroller.set_child(Some(&notebook));
88
89    let basic = basic_tab(&meta);
90    notebook.append_page(&basic.page, Some(&gtk::Label::new(Some("Basic"))));
91    let permissions = permissions_tab(&meta, set_status.clone());
92    notebook.append_page(&permissions, Some(&gtk::Label::new(Some("Permissions"))));
93    if !meta.is_dir
94        && let Some((width, height)) = meta::image_dimensions(&meta.path)
95    {
96        let image = image_tab(&meta, width, height);
97        notebook.append_page(&image, Some(&gtk::Label::new(Some("Image"))));
98    }
99
100    // ── footer ──────────────────────────────────────────────────────────────
101    let footer = gtk::Box::new(gtk::Orientation::Horizontal, 10);
102    footer.add_css_class("props-footer");
103    footer.append(&status);
104    let done = gtk::Button::with_label("Close");
105    done.add_css_class("suggested-action");
106    footer.append(&done);
107
108    let root = gtk::Box::new(gtk::Orientation::Vertical, 0);
109    root.add_css_class("props");
110    root.append(&header);
111    root.append(&gtk::Separator::new(gtk::Orientation::Horizontal));
112    root.append(&scroller);
113    root.append(&gtk::Separator::new(gtk::Orientation::Horizontal));
114    root.append(&footer);
115    window.set_child(Some(&root));
116
117    // ── actions ─────────────────────────────────────────────────────────────
118    {
119        let window = window.clone();
120        close_button.connect_clicked(move |_| window.close());
121    }
122    {
123        let window = window.clone();
124        done.connect_clicked(move |_| window.close());
125    }
126    {
127        let keys = gtk::EventControllerKey::new();
128        window.add_controller(keys.clone());
129        let window = window.clone();
130        keys.connect_key_pressed(move |_, key, _, _| {
131            if key == gdk::Key::Escape {
132                window.close();
133                glib::Propagation::Stop
134            } else {
135                glib::Propagation::Proceed
136            }
137        });
138    }
139
140    // Renaming from the editable Name field: send it to the tree (so the model,
141    // watcher and selection all update) and close on success. Invalid names are
142    // rejected in place.
143    let name_entry = basic.name_entry.clone();
144    {
145        let sender = sender.clone();
146        let set_status = set_status.clone();
147        let window = window.clone();
148        let path = meta.path.clone();
149        name_entry.connect_activate(move |entry| {
150            let name = entry.text().trim().to_string();
151            if name.is_empty() || name.contains('/') || name == "." || name == ".." {
152                set_status("A name cannot be empty, contain '/', or be '.' or '..'".to_string());
153                return;
154            }
155            if name == meta.name {
156                return;
157            }
158            sender.input(TreeMsg::RenameTo { path: path.clone(), name });
159            window.close();
160        });
161    }
162
163    window.present();
164}
165
166/// Show a summary Properties dialog for a multi-row selection: how many items,
167/// their combined size, the shared parent folder, and the list of names. Unlike
168/// the single-path dialog it is read-only — there is no sensible bulk
169/// permissions/name editor here.
170pub fn show_multi_properties_dialog(parent: &gtk::Window, paths: &[std::path::PathBuf]) {
171    let count = paths.len();
172
173    // Combined size, and whether every item shares one parent folder.
174    let mut total: u64 = 0;
175    let mut unknown_size = false;
176    for path in paths {
177        if path.is_dir() {
178            unknown_size = true;
179        } else {
180            match std::fs::metadata(path) {
181                Ok(meta) => total += meta.len(),
182                Err(_) => unknown_size = true,
183            }
184        }
185    }
186    let parents: std::collections::BTreeSet<_> =
187        paths.iter().filter_map(|p| p.parent().map(Path::to_path_buf)).collect();
188    let shared_parent = if parents.len() == 1 {
189        parents.into_iter().next().map(|p| p.display().to_string())
190    } else {
191        None
192    };
193
194    let window = gtk::Window::new();
195    window.set_title(Some(&format!("Properties — {count} items")));
196    window.set_default_size(500, 600);
197    window.set_transient_for(Some(parent));
198    if gtk4_layer_shell::is_supported() && !window.is_layer_window() {
199        window.init_layer_shell();
200        window.set_layer(Layer::Overlay);
201        window.set_keyboard_mode(KeyboardMode::Exclusive);
202    }
203
204    // ── header ──────────────────────────────────────────────────────────────
205    let header = gtk::Box::new(gtk::Orientation::Horizontal, 10);
206    header.add_css_class("props-header");
207    let icon = gtk::Image::from_icon_name("folder-symbolic");
208    icon.set_pixel_size(28);
209    let title = gtk::Label::new(Some(&format!("{count} items selected")));
210    title.add_css_class("props-title");
211    title.set_xalign(0.0);
212    title.set_hexpand(true);
213    let close_button = gtk::Button::from_icon_name("window-close-symbolic");
214    close_button.add_css_class("flat");
215    header.append(&icon);
216    header.append(&title);
217    header.append(&close_button);
218
219    // ── summary ─────────────────────────────────────────────────────────────
220    let grid = grid();
221    add_row(&grid, 0, "Items", &value_label(&count.to_string()));
222    let size_text = if unknown_size && total == 0 {
223        "—".to_string()
224    } else if unknown_size {
225        format!("{} (some folders not counted)", meta::format_size(total))
226    } else {
227        meta::format_size(total)
228    };
229    add_row(&grid, 1, "Total size", &value_label(&size_text));
230    if let Some(parent_dir) = &shared_parent {
231        add_row(&grid, 2, "Parent folder", &value_label(parent_dir));
232    }
233
234    // ── the list of names ───────────────────────────────────────────────────
235    let list = gtk::ListBox::new();
236    list.set_selection_mode(gtk::SelectionMode::None);
237    list.add_css_class("props-multi-list");
238    for path in paths {
239        let name = path
240            .file_name()
241            .map(|n| n.to_string_lossy().into_owned())
242            .unwrap_or_else(|| path.display().to_string());
243        let label = gtk::Label::new(Some(&name));
244        label.set_xalign(0.0);
245        label.set_ellipsize(pango::EllipsizeMode::Middle);
246        label.set_tooltip_text(Some(&path.to_string_lossy()));
247        let row = gtk::ListBoxRow::new();
248        row.set_child(Some(&label));
249        row.set_tooltip_text(Some(&path.to_string_lossy()));
250        list.append(&row);
251    }
252    let scroller = gtk::ScrolledWindow::new();
253    scroller.set_policy(gtk::PolicyType::Never, gtk::PolicyType::Automatic);
254    scroller.set_vexpand(true);
255    scroller.set_child(Some(&list));
256
257    // ── footer ──────────────────────────────────────────────────────────────
258    let footer = gtk::Box::new(gtk::Orientation::Horizontal, 10);
259    footer.add_css_class("props-footer");
260    let spacer = gtk::Label::new(None);
261    spacer.set_hexpand(true);
262    footer.append(&spacer);
263    let done = gtk::Button::with_label("Close");
264    done.add_css_class("suggested-action");
265    footer.append(&done);
266
267    let root = gtk::Box::new(gtk::Orientation::Vertical, 0);
268    root.add_css_class("props");
269    root.append(&header);
270    root.append(&gtk::Separator::new(gtk::Orientation::Horizontal));
271    root.append(&grid);
272    root.append(&gtk::Separator::new(gtk::Orientation::Horizontal));
273    root.append(&scroller);
274    root.append(&gtk::Separator::new(gtk::Orientation::Horizontal));
275    root.append(&footer);
276    window.set_child(Some(&root));
277
278    {
279        let window = window.clone();
280        close_button.connect_clicked(move |_| window.close());
281    }
282    {
283        let window = window.clone();
284        done.connect_clicked(move |_| window.close());
285    }
286    {
287        let keys = gtk::EventControllerKey::new();
288        window.add_controller(keys.clone());
289        let window = window.clone();
290        keys.connect_key_pressed(move |_, key, _, _| {
291            if key == gdk::Key::Escape {
292                window.close();
293                glib::Propagation::Stop
294            } else {
295                glib::Propagation::Proceed
296            }
297        });
298    }
299
300    window.present();
301}
302
303/// The Basic tab plus the widget the caller needs afterwards.
304struct BasicTab {
305    page: gtk::Widget,
306    name_entry: gtk::Entry,
307}
308
309/// Name, type, size/contents, link target, parent, free space and timestamps.
310fn basic_tab(meta: &FileMeta) -> BasicTab {
311    let grid = grid();
312
313    let name_entry = gtk::Entry::new();
314    name_entry.set_text(&meta.name);
315    name_entry.add_css_class("props-name");
316    add_row(&grid, 0, "Name", &name_entry);
317
318    let mut row = 1;
319    add_row(&grid, row, "Type", &value_label(&type_text(meta)));
320    row += 1;
321
322    if meta.is_dir {
323        if let Some(count) = meta.item_count() {
324            add_row(&grid, row, "Size", &value_label(&meta::item_count_label(count)));
325            row += 1;
326        }
327    } else {
328        add_row(&grid, row, "Size", &value_label(&meta::format_size(meta.size)));
329        row += 1;
330    }
331
332    if let Some(target) = &meta.link_target {
333        add_row(&grid, row, "Link target", &value_label(&target.display().to_string()));
334        row += 1;
335    }
336
337    if let Some(parent) = meta.path.parent() {
338        add_row(&grid, row, "Parent folder", &value_label(&parent.display().to_string()));
339        row += 1;
340    }
341
342    if let Some(free) = meta::available_space(&meta.path) {
343        add_row(&grid, row, "Free space", &value_label(&meta::format_size(free)));
344        row += 1;
345    }
346
347    for (label, time) in [
348        ("Accessed", meta.accessed),
349        ("Modified", meta.modified),
350        ("Created", meta.created),
351    ] {
352        if let Some(time) = time {
353            add_row(&grid, row, label, &value_label(&format_time(time)));
354            row += 1;
355        }
356    }
357
358    BasicTab { page: grid.upcast(), name_entry }
359}
360
361/// Owner/group plus the read/write/execute matrix and the special bits. Every
362/// control applies its change immediately and reverts on failure.
363fn permissions_tab(meta: &FileMeta, set_status: Rc<dyn Fn(String)>) -> gtk::Widget {
364    let grid = grid();
365
366    // Owner and group lists, guaranteeing the current ids are present.
367    let mut users = meta::list_users();
368    if !users.iter().any(|(id, _)| *id == meta.uid) {
369        users.push((meta.uid, meta.owner.clone()));
370        users.sort_by_key(|(id, _)| *id);
371    }
372    let mut groups = meta::list_groups();
373    if !groups.iter().any(|(id, _)| *id == meta.gid) {
374        groups.push((meta.gid, meta.group.clone()));
375        groups.sort_by_key(|(id, _)| *id);
376    }
377    let users = Rc::new(users);
378    let groups = Rc::new(groups);
379
380    let owner = dropdown(&users, meta.uid);
381    let group = dropdown(&groups, meta.gid);
382    add_row(&grid, 0, "Owner", &owner);
383    add_row(&grid, 1, "Group", &group);
384
385    // Access matrix. Checkboxes rather than dropdowns because a `GtkDropDown`
386    // refuses to shrink below its widest item, which made the whole dialog
387    // wide; the three-bit matrix is also easier to scan.
388    let owner_access = AccessRow::new(meta.permissions.owner);
389    let group_access = AccessRow::new(meta.permissions.group);
390    let other_access = AccessRow::new(meta.permissions.other);
391    let access = gtk::Grid::new();
392    access.add_css_class("props-access");
393    access.set_row_spacing(4);
394    access.set_column_spacing(20);
395    for (column, text) in ["", "Read", "Write", "Execute"].iter().enumerate() {
396        let label = gtk::Label::new(Some(text));
397        label.add_css_class("props-label");
398        label.set_xalign(0.0);
399        access.attach(&label, column as i32, 0, 1, 1);
400    }
401    for (row, (name, triad)) in [
402        ("Owner", &owner_access),
403        ("Group", &group_access),
404        ("Others", &other_access),
405    ]
406    .iter()
407    .enumerate()
408    {
409        let label = gtk::Label::new(Some(name));
410        label.add_css_class("props-label");
411        label.set_xalign(1.0);
412        access.attach(&label, 0, row as i32 + 1, 1, 1);
413        access.attach(&triad.read, 1, row as i32 + 1, 1, 1);
414        access.attach(&triad.write, 2, row as i32 + 1, 1, 1);
415        access.attach(&triad.execute, 3, row as i32 + 1, 1, 1);
416    }
417    add_row(&grid, 2, "Access", &access);
418
419    let chk_uid = gtk::CheckButton::with_label("Set user ID");
420    let chk_gid = gtk::CheckButton::with_label("Set group ID");
421    let chk_sticky = gtk::CheckButton::with_label("Sticky");
422    chk_uid.set_active(meta.permissions.set_uid);
423    chk_gid.set_active(meta.permissions.set_gid);
424    chk_sticky.set_active(meta.permissions.sticky);
425    let special = gtk::Box::new(gtk::Orientation::Vertical, 2);
426    special.add_css_class("props-special");
427    special.append(&chk_uid);
428    special.append(&chk_gid);
429    special.append(&chk_sticky);
430    add_row(&grid, 3, "Special", &special);
431
432    let path = meta.path.clone();
433    let state = Rc::new(RefCell::new(PermState {
434        permissions: meta.permissions,
435        uid: meta.uid,
436        gid: meta.gid,
437        suppress: false,
438    }));
439
440    // Put every control back to the last known-good state.
441    let revert: Rc<dyn Fn()> = {
442        let state = state.clone();
443        let owner = owner.clone();
444        let group = group.clone();
445        let owner_access = owner_access.clone();
446        let group_access = group_access.clone();
447        let other_access = other_access.clone();
448        let chk_uid = chk_uid.clone();
449        let chk_gid = chk_gid.clone();
450        let chk_sticky = chk_sticky.clone();
451        let users = users.clone();
452        let groups = groups.clone();
453        Rc::new(move || {
454            let known = *state.borrow();
455            state.borrow_mut().suppress = true;
456            owner_access.set(known.permissions.owner);
457            group_access.set(known.permissions.group);
458            other_access.set(known.permissions.other);
459            chk_uid.set_active(known.permissions.set_uid);
460            chk_gid.set_active(known.permissions.set_gid);
461            chk_sticky.set_active(known.permissions.sticky);
462            owner.set_selected(index_of(&users, known.uid));
463            group.set_selected(index_of(&groups, known.gid));
464            state.borrow_mut().suppress = false;
465        })
466    };
467
468    // Apply the access matrix plus the special bits.
469    let apply_permissions: Rc<dyn Fn()> = {
470        let state = state.clone();
471        let path = path.clone();
472        let set_status = set_status.clone();
473        let revert = revert.clone();
474        let owner_access = owner_access.clone();
475        let group_access = group_access.clone();
476        let other_access = other_access.clone();
477        let chk_uid = chk_uid.clone();
478        let chk_gid = chk_gid.clone();
479        let chk_sticky = chk_sticky.clone();
480        Rc::new(move || {
481            if state.borrow().suppress {
482                return;
483            }
484            let permissions = Permissions {
485                owner: owner_access.access(),
486                group: group_access.access(),
487                other: other_access.access(),
488                set_uid: chk_uid.is_active(),
489                set_gid: chk_gid.is_active(),
490                sticky: chk_sticky.is_active(),
491            };
492            match meta::set_permissions(&path, permissions) {
493                Ok(()) => {
494                    state.borrow_mut().permissions = permissions;
495                    set_status("Permissions changed".to_string());
496                }
497                Err(err) => {
498                    set_status(format!("Could not change permissions: {err}"));
499                    revert();
500                }
501            }
502        })
503    };
504    owner_access.connect(apply_permissions.clone());
505    group_access.connect(apply_permissions.clone());
506    other_access.connect(apply_permissions.clone());
507    for check in [&chk_uid, &chk_gid, &chk_sticky] {
508        let apply = apply_permissions.clone();
509        check.connect_toggled(move |_| apply());
510    }
511
512    // Change owner (uid only) / group (gid only).
513    for (combo, entries, is_owner) in [
514        (owner.clone(), users.clone(), true),
515        (group.clone(), groups.clone(), false),
516    ] {
517        let state = state.clone();
518        let path = path.clone();
519        let set_status = set_status.clone();
520        let revert = revert.clone();
521        combo.connect_selected_notify(move |combo| {
522            if state.borrow().suppress {
523                return;
524            }
525            let index = combo.selected() as usize;
526            let Some((id, name)) = entries.get(index) else {
527                return;
528            };
529            let result = if is_owner {
530                meta::set_owner_uid(&path, *id)
531            } else {
532                meta::set_group_gid(&path, *id)
533            };
534            match result {
535                Ok(()) => {
536                    if is_owner {
537                        state.borrow_mut().uid = *id;
538                        set_status(format!("Owner changed to {name}"));
539                    } else {
540                        state.borrow_mut().gid = *id;
541                        set_status(format!("Group changed to {name}"));
542                    }
543                }
544                Err(err) => {
545                    let what = if is_owner { "owner" } else { "group" };
546                    set_status(format!("Could not change {what}: {err}"));
547                    revert();
548                }
549            }
550        });
551    }
552
553    grid.upcast()
554}
555
556/// The three checkboxes that make up one triad's access level.
557#[derive(Clone)]
558struct AccessRow {
559    read: gtk::CheckButton,
560    write: gtk::CheckButton,
561    execute: gtk::CheckButton,
562}
563
564impl AccessRow {
565    fn new(access: Access) -> Self {
566        let row = AccessRow {
567            read: gtk::CheckButton::new(),
568            write: gtk::CheckButton::new(),
569            execute: gtk::CheckButton::new(),
570        };
571        row.set(access);
572        row
573    }
574
575    /// The triad currently ticked.
576    fn access(&self) -> Access {
577        let bits = u32::from(self.read.is_active()) << 2
578            | u32::from(self.write.is_active()) << 1
579            | u32::from(self.execute.is_active());
580        Access::from_bits(bits)
581    }
582
583    fn set(&self, access: Access) {
584        let bits = access.bits();
585        self.read.set_active(bits & 0b100 != 0);
586        self.write.set_active(bits & 0b010 != 0);
587        self.execute.set_active(bits & 0b001 != 0);
588    }
589
590    /// Run `apply` whenever any of the three boxes is toggled.
591    fn connect(&self, apply: Rc<dyn Fn()>) {
592        for check in [&self.read, &self.write, &self.execute] {
593            let apply = apply.clone();
594            check.connect_toggled(move |_| apply());
595        }
596    }
597}
598
599/// The Image tab: type and pixel dimensions.
600fn image_tab(meta: &FileMeta, width: u32, height: u32) -> gtk::Widget {
601    let grid = grid();
602    add_row(&grid, 0, "Type", &value_label(&type_text(meta)));
603    add_row(
604        &grid,
605        1,
606        "Dimensions",
607        &value_label(&format!("{width} × {height} pixels")),
608    );
609    grid.upcast()
610}
611
612/// Mutable state shared by the permission controls.
613#[derive(Debug, Clone, Copy)]
614struct PermState {
615    permissions: Permissions,
616    uid: u32,
617    gid: u32,
618    /// Set while the controls are being populated/reverted, so programmatic
619    /// changes do not fire an apply.
620    suppress: bool,
621}
622
623fn grid() -> gtk::Grid {
624    let grid = gtk::Grid::new();
625    grid.add_css_class("props-grid");
626    grid.set_row_spacing(8);
627    grid.set_column_spacing(14);
628    grid.set_margin_top(14);
629    grid.set_margin_bottom(14);
630    grid.set_margin_start(16);
631    grid.set_margin_end(16);
632    grid
633}
634
635/// Attach a right-aligned label in column 0 and `value` in column 1.
636fn add_row(grid: &gtk::Grid, row: i32, label: &str, value: &impl IsA<gtk::Widget>) {
637    let label = gtk::Label::new(Some(label));
638    label.add_css_class("props-label");
639    label.set_xalign(1.0);
640    label.set_valign(gtk::Align::Start);
641    grid.attach(&label, 0, row, 1, 1);
642    value.set_hexpand(true);
643    grid.attach(value, 1, row, 1, 1);
644}
645
646/// A read-only, selectable value label that truncates long paths in the middle.
647fn value_label(text: &str) -> gtk::Label {
648    let label = gtk::Label::new(Some(text));
649    label.add_css_class("props-value");
650    label.set_xalign(0.0);
651    label.set_selectable(true);
652    label.set_ellipsize(pango::EllipsizeMode::Middle);
653    label.set_max_width_chars(42);
654    label
655}
656
657/// A searchable dropdown over `(id, name)` entries, selecting `id`.
658fn dropdown(entries: &[(u32, String)], id: u32) -> gtk::DropDown {
659    let names: Vec<&str> = entries.iter().map(|(_, name)| name.as_str()).collect();
660    let combo = gtk::DropDown::from_strings(&names);
661    combo.set_enable_search(true);
662    combo.set_selected(index_of(entries, id));
663    combo
664}
665
666fn index_of(entries: &[(u32, String)], id: u32) -> u32 {
667    entries
668        .iter()
669        .position(|(candidate, _)| *candidate == id)
670        .map(|index| index as u32)
671        .unwrap_or(0)
672}
673
674/// "PNG image (image/png)", "Folder (inode/directory)", or the raw MIME type.
675fn type_text(meta: &FileMeta) -> String {
676    match (&meta.type_description, &meta.mime_type) {
677        (Some(description), Some(mime)) => format!("{description} ({mime})"),
678        (Some(description), None) => description.clone(),
679        (None, Some(mime)) => mime.clone(),
680        (None, None) => {
681            if meta.is_dir { "Folder".to_string() } else { "Unknown type".to_string() }
682        }
683    }
684}
685
686fn icon_for(meta: &FileMeta) -> &'static str {
687    if meta.is_dir {
688        return "folder-symbolic";
689    }
690    match meta.mime_type.as_deref() {
691        Some(mime) if mime.starts_with("image/") => "image-x-generic-symbolic",
692        Some(mime) if mime.starts_with("audio/") => "audio-x-generic-symbolic",
693        Some(mime) if mime.starts_with("video/") => "video-x-generic-symbolic",
694        Some("application/pdf") => "application-pdf-symbolic",
695        _ => "text-x-generic-symbolic",
696    }
697}
698
699/// Local, human-readable timestamp (e.g. "5 September 2026, 14:03").
700fn format_time(time: SystemTime) -> String {
701    let seconds = time
702        .duration_since(UNIX_EPOCH)
703        .map(|duration| duration.as_secs() as i64)
704        .unwrap_or(0);
705    glib::DateTime::from_unix_local(seconds)
706        .and_then(|datetime| datetime.format("%e %B %Y, %H:%M"))
707        .map(|text| text.to_string())
708        .unwrap_or_else(|_| "Unknown".to_string())
709}