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