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_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 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 let notebook = gtk::Notebook::new();
79 notebook.set_vexpand(true);
80 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(¬ebook));
88
89 let basic = basic_tab(&meta);
90 notebook.append_page(&basic.page, Some(>k::Label::new(Some("Basic"))));
91 let permissions = permissions_tab(&meta, set_status.clone());
92 notebook.append_page(&permissions, Some(>k::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(>k::Label::new(Some("Image"))));
98 }
99
100 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(>k::Separator::new(gtk::Orientation::Horizontal));
112 root.append(&scroller);
113 root.append(>k::Separator::new(gtk::Orientation::Horizontal));
114 root.append(&footer);
115 window.set_child(Some(&root));
116
117 {
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 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
166pub fn show_multi_properties_dialog(parent: >k::Window, paths: &[std::path::PathBuf]) {
171 let count = paths.len();
172
173 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 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 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 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 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(>k::Separator::new(gtk::Orientation::Horizontal));
271 root.append(&grid);
272 root.append(>k::Separator::new(gtk::Orientation::Horizontal));
273 root.append(&scroller);
274 root.append(>k::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
303struct BasicTab {
305 page: gtk::Widget,
306 name_entry: gtk::Entry,
307}
308
309fn 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
361fn permissions_tab(meta: &FileMeta, set_status: Rc<dyn Fn(String)>) -> gtk::Widget {
364 let grid = grid();
365
366 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 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 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 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 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#[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 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 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
599fn 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#[derive(Debug, Clone, Copy)]
614struct PermState {
615 permissions: Permissions,
616 uid: u32,
617 gid: u32,
618 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
635fn add_row(grid: >k::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
646fn 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
657fn 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
674fn 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
699fn 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}