1use 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
27pub fn show_properties_dialog(parent: >k::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 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 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 let notebook = gtk::Notebook::new();
80 notebook.set_vexpand(true);
81 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(¬ebook));
89
90 let basic = basic_tab(&meta);
91 notebook.append_page(&basic.page, Some(>k::Label::new(Some("Basic"))));
92 let permissions = permissions_tab(&meta, set_status.clone());
93 notebook.append_page(&permissions, Some(>k::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(>k::Label::new(Some("Image"))));
99 }
100
101 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(>k::Separator::new(gtk::Orientation::Horizontal));
113 root.append(&scroller);
114 root.append(>k::Separator::new(gtk::Orientation::Horizontal));
115 root.append(&footer);
116 window.set_child(Some(&root));
117
118 {
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 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
167pub fn show_multi_properties_dialog(parent: >k::Window, paths: &[std::path::PathBuf]) {
172 let count = paths.len();
173
174 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 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 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 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 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(>k::Separator::new(gtk::Orientation::Horizontal));
273 root.append(&grid);
274 root.append(>k::Separator::new(gtk::Orientation::Horizontal));
275 root.append(&scroller);
276 root.append(>k::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
305struct BasicTab {
307 page: gtk::Widget,
308 name_entry: gtk::Entry,
309}
310
311fn 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
363fn permissions_tab(meta: &FileMeta, set_status: Rc<dyn Fn(String)>) -> gtk::Widget {
366 let grid = grid();
367
368 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 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 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 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 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#[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 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 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
601fn 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#[derive(Debug, Clone, Copy)]
616struct PermState {
617 permissions: Permissions,
618 uid: u32,
619 gid: u32,
620 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
637fn add_row(grid: >k::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
648fn 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
659fn 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
676fn 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
701fn 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}