1mod details;
4mod flat;
5mod kinds;
6mod mark;
7mod ops;
8mod state;
9mod trash;
10mod watch;
11
12#[cfg(test)]
13mod kinds_tests;
14#[cfg(test)]
15mod tests;
16
17use std::path::Path;
18use std::rc::Rc;
19
20use crate::icons::UserFolders;
21use crate::widget::{Length, NodeMut, View};
22
23use super::{Button, ContextItem, Field, Form, FormErrors, Modal, ProgressBar, Text, TextInput, Tree, TreeNode};
24
25pub use details::FileDetails;
26pub use flat::FileView;
27pub use mark::RowMark;
28pub use ops::copy_into;
29use ops::stem;
30pub use ops::{FileChange, FileError, NameProblem, is_inside, is_within, name_of, parent_key};
31use state::ROOT;
32pub use state::{FileManagerMsg, FileManagerState, FileWork, FolderEntry, NameFor, Naming, child_key};
33
34type Wrap<Msg> = Rc<dyn Fn(FileManagerMsg) -> Msg>;
36
37type Menu<Msg> = Rc<dyn Fn(&str, &[String]) -> Vec<ContextItem<Msg>>>;
39
40type OnPath<Msg> = Rc<dyn Fn(&Path) -> Msg>;
42
43type Marks = Rc<dyn Fn(&str) -> RowMark>;
45
46const NAME_ID: &str = "file-manager-name";
48
49const NAMING_WIDTH: u16 = 48;
51
52pub struct FileManager<'a, Msg> {
86 state: &'a FileManagerState,
87 wrap: Wrap<Msg>,
88 root_label: Option<String>,
89 on_open: Option<OnPath<Msg>>,
90 on_open_terminal: Option<OnPath<Msg>>,
91 menu: Option<Menu<Msg>>,
92 marks: Option<Marks>,
93 view: FileView,
94 disabled: bool,
95 kind_icons: bool,
96 kind_tones: bool,
97 user_folders: Option<&'a UserFolders>,
98 kinds: kinds::KindLook<'a>,
100}
101
102impl<'a, Msg: Clone + 'static> FileManager<'a, Msg> {
103 #[must_use]
108 pub fn new(state: &'a FileManagerState, wrap: impl Fn(FileManagerMsg) -> Msg + 'static) -> Self {
109 Self {
110 state,
111 wrap: Rc::new(wrap),
112 root_label: None,
113 on_open: None,
114 on_open_terminal: None,
115 menu: None,
116 marks: None,
117 view: FileView::Tree,
118 disabled: false,
119 kind_icons: false,
120 kind_tones: false,
121 user_folders: None,
122 kinds: kinds::KindLook::default(),
123 }
124 }
125
126 #[must_use]
129 pub fn root_label(mut self, label: impl Into<String>) -> Self {
130 self.root_label = Some(label.into());
131 self
132 }
133
134 #[must_use]
140 pub fn on_open(mut self, message: impl Fn(&Path) -> Msg + 'static) -> Self {
141 self.on_open = Some(Rc::new(message));
142 self
143 }
144
145 #[must_use]
150 pub fn on_open_terminal(mut self, message: impl Fn(&Path) -> Msg + 'static) -> Self {
151 self.on_open_terminal = Some(Rc::new(message));
152 self
153 }
154
155 #[must_use]
162 pub fn menu_items(mut self, items: impl Fn(&str, &[String]) -> Vec<ContextItem<Msg>> + 'static) -> Self {
163 self.menu = Some(Rc::new(items));
164 self
165 }
166
167 #[must_use]
179 pub fn row_mark(mut self, mark: impl Fn(&str) -> RowMark + 'static) -> Self {
180 self.marks = Some(Rc::new(mark));
181 self
182 }
183
184 #[must_use]
196 pub fn view(mut self, view: FileView) -> Self {
197 self.view = view;
198 self
199 }
200
201 #[must_use]
220 pub fn kind_icons(mut self, on: bool) -> Self {
221 self.kind_icons = on;
222 self
223 }
224
225 #[must_use]
234 pub fn kind_tones(mut self, on: bool) -> Self {
235 self.kind_tones = on;
236 self
237 }
238
239 #[must_use]
244 pub fn user_folders(mut self, folders: &'a UserFolders) -> Self {
245 self.user_folders = Some(folders);
246 self
247 }
248
249 #[must_use]
252 pub fn disabled(mut self, disabled: bool) -> Self {
253 self.disabled = disabled;
254 self
255 }
256
257 pub fn show<'v>(mut self, ui: &'v mut View<'_, Msg>) -> NodeMut<'v, Msg> {
262 let state = self.state;
263 self.kinds = self.kind_look(ui.env());
264 if let Some(problem) = state.error() {
265 ui.add(Text::new(crate::t!("quvyta.file-manager.unreadable")).role("secondary"));
266 return ui.add(Text::new(problem.to_owned()).role("faint")).selectable(true);
267 }
268 self.naming_dialog(ui);
269 self.work_row(ui);
270 if self.view == FileView::Tree {
271 let tree = self.tree();
272 return ui.add(tree);
273 }
274 let rows = self.flat_rows();
277 if self.view == FileView::List {
281 let gaps = state.detail_gaps(state.folder());
282 if !gaps.is_empty() {
283 let wrap = Rc::clone(&self.wrap);
284 ui.on_idle(std::time::Duration::ZERO, move |_| wrap(FileManagerMsg::Detail(gaps.clone())));
285 }
286 }
287 ui.column(|ui| {
288 if self.view == FileView::Icons {
289 let grid = self.grid(&rows);
290 ui.add(grid).fill();
291 } else {
292 let table = self.table(&rows);
293 ui.add(table).fill();
294 }
295 self.foot(ui, &rows);
296 })
297 }
298
299 fn work_row(&self, ui: &mut View<'_, Msg>) {
305 let Some(work) = self.state.work() else { return };
306 let label = crate::t!("quvyta.file-manager.copying", n = work.entries());
307 let stop = (self.wrap)(FileManagerMsg::Stop);
308 let note = work.note().to_owned();
309 let done = work.done();
310 ui.row(|ui| {
311 ui.add(Text::new(label).role("secondary").no_wrap());
312 ui.add(ProgressBar::new(done).percent(true)).width(Length::Fill(1));
313 if !note.is_empty() {
314 ui.add(Text::new(note).role("faint").no_wrap());
315 }
316 ui.add(Button::new(crate::t!("quvyta.file-manager.stop")).on_press(stop));
317 })
318 .fill_width();
319 }
320
321 fn tree(&self) -> Tree<Msg> {
323 let state = self.state;
324 let tree = Tree::new([self.root_node()]);
325 if self.disabled {
326 return tree;
327 }
328 let wrap = Rc::clone(&self.wrap);
329 let expand = Rc::clone(&self.wrap);
330 let choose = Rc::clone(&self.wrap);
331 let drop = Rc::clone(&self.wrap);
332 let accepts = state.folder_keys();
333 let tree = tree
334 .selected(state.selected())
335 .on_select(move |key| wrap(FileManagerMsg::Select(key.to_owned())))
336 .multi_select(state.chosen(), move |keys| choose(FileManagerMsg::Choose(keys)))
337 .droppable(
338 move |dropped| drop(FileManagerMsg::Drop(dropped)),
339 move |key| key == ROOT || accepts.contains(key),
340 )
341 .on_expand(move |key, open| expand(FileManagerMsg::Expand(key.to_owned(), open)))
342 .context_menu(self.menu_for());
343 match &self.on_open {
346 Some(open) => {
347 let (open, root) = (Rc::clone(open), state.root().to_path_buf());
348 tree.on_activate(move |key| open(&path_of(&root, key)))
349 }
350 None => tree,
351 }
352 }
353
354 fn root_node(&self) -> TreeNode {
361 let state = self.state;
362 let label = self.root_label.clone().unwrap_or_else(|| root_name(state.root()));
363 let mark = self.mark_of(ROOT);
364 let (icon, tone) = self.sign_of(&mark, ROOT, &root_name(state.root()), true, false);
365 let mut root = TreeNode::new(ROOT, label).icon(icon, tone.as_deref()).faint(self.disabled || mark.is_faint());
366 if state.shown_children(ROOT).is_some_and(|entries| entries.is_empty()) {
368 root = root.detail(crate::t!("quvyta.file-manager.empty"));
369 }
370 root.expandable(true).expanded(state.is_open(ROOT)).loading(state.is_loading(ROOT)).children(self.nodes(ROOT))
371 }
372
373 fn nodes(&self, key: &str) -> Vec<TreeNode> {
375 let state = self.state;
376 let Some(entries) = state.shown_children(key) else { return Vec::new() };
377 entries
378 .into_iter()
379 .map(|entry| {
380 let child = child_key(key, &entry.name);
381 let mark = self.mark_of(&child);
382 let (icon, tone) = self.sign_of(&mark, &child, &entry.name, entry.folder, entry.executable);
383 let faint = self.disabled || state.is_cut(&child) || mark.is_faint();
385 let mut node =
386 TreeNode::new(child.clone(), entry.name.clone()).icon(icon, tone.as_deref()).faint(faint);
387 if entry.folder {
388 let open = state.is_open(&child);
389 node = node.expandable(true).expanded(open).loading(state.is_loading(&child));
390 if state.folder_error(&child).is_some() {
394 node = node.detail(crate::t!("quvyta.file-manager.unreadable-short"));
395 }
396 if open {
397 node = node.children(self.nodes(&child));
398 }
399 }
400 node
401 })
402 .collect()
403 }
404
405 fn mark_of(&self, key: &str) -> RowMark {
407 self.marks.as_ref().map(|mark| mark(key)).unwrap_or_default()
408 }
409
410 fn sign_of(
414 &self,
415 mark: &RowMark,
416 key: &str,
417 name: &str,
418 folder: bool,
419 executable: bool,
420 ) -> (String, Option<String>) {
421 match mark.icon() {
422 Some(icon) => (icon.to_owned(), mark.tone().map(str::to_owned)),
423 None => self.own_icon(key, name, folder, executable),
424 }
425 }
426
427 fn menu_for(&self) -> impl Fn(&str) -> Vec<ContextItem<Msg>> + 'static {
429 let state = self.state;
430 let wrap = Rc::clone(&self.wrap);
433 let chosen = state.chosen().to_vec();
434 let pending = Pending { keys: state.pending().to_vec(), copying: state.is_copying() };
435 let trashing = state.is_trashing();
436 let folders = state.folder_keys();
437 let extra = self.menu.clone();
438 let terminal = self.on_open_terminal.clone();
439 let root = state.root().to_path_buf();
440 move |key: &str| {
441 let targets = state::targets_of(&chosen, key);
444 let mut own = extra.as_ref().map(|items| items(key, &targets)).unwrap_or_default();
445 if let Some(message) = &terminal
446 && (key == ROOT || folders.contains(key))
447 {
448 let label = crate::t!("quvyta.file-manager.open-terminal");
449 own.push(ContextItem::new(label, message(&path_of(&root, key))));
450 }
451 let send = |message: FileManagerMsg| wrap(message);
452 if targets.len() > 1 {
453 return many_menu(key, targets.len(), &pending, trashing, own, &send);
454 }
455 if key == ROOT || folders.contains(key) {
456 return folder_menu(key, &pending, trashing, own, &send);
457 }
458 file_menu(key, &pending, trashing, own, &send)
459 }
460 }
461
462 fn naming_dialog(&self, ui: &mut View<'_, Msg>) {
465 let state = self.state;
466 let Some(naming) = state.naming() else { return };
467 let (title, confirm) = match &naming.purpose {
468 NameFor::File => (crate::t!("quvyta.file-manager.new-file-title"), crate::t!("quvyta.file-manager.create")),
469 NameFor::Folder => {
470 (crate::t!("quvyta.file-manager.new-folder-title"), crate::t!("quvyta.file-manager.create"))
471 }
472 NameFor::Rename(key) => (
473 crate::t!("quvyta.file-manager.rename-title", name = name_of(key)),
474 crate::t!("quvyta.file-manager.rename-do"),
475 ),
476 };
477 let close = (self.wrap)(FileManagerMsg::CloseNaming);
478 let submit = (self.wrap)(FileManagerMsg::Submit);
479 let dialog = Modal::new()
480 .title(title)
481 .width(NAMING_WIDTH)
482 .on_close(close.clone())
483 .action(Button::new(crate::t!("quvyta.file-manager.cancel")).on_press(close))
484 .action(Button::new(confirm).variant("primary").on_press(submit.clone()));
485 let mut errors = FormErrors::new();
486 if let Some(problem) = state.naming_problem() {
487 errors.set(NAME_ID, problem.message());
488 }
489 let value = naming.value.clone();
490 let selection = match &naming.purpose {
493 NameFor::Rename(key) => Some(stem(&value, state.is_folder(key))),
494 NameFor::File | NameFor::Folder => None,
495 };
496 let typed = Rc::clone(&self.wrap);
497 ui.add_with(dialog, |ui| {
498 Form::new().show(ui, |fields| {
499 let label = crate::t!("quvyta.file-manager.name-label");
500 fields.field(Field::new(label).error(errors.get(NAME_ID)), |ui| {
501 let mut input = TextInput::new(value)
502 .invalid(errors.has(NAME_ID))
503 .on_change(move |value| typed(FileManagerMsg::Name(value)))
504 .on_submit(move |_| submit.clone());
505 if let Some(range) = selection {
506 input = input.select_on_focus(range);
507 }
508 ui.add(input).id(NAME_ID).fill_width();
509 });
510 });
511 });
512 }
513}
514
515fn path_of(root: &Path, key: &str) -> std::path::PathBuf {
517 key.split('/').filter(|part| !part.is_empty()).fold(root.to_path_buf(), |path, part| path.join(part))
518}
519
520fn root_name(root: &Path) -> String {
523 root.file_name().map_or_else(|| root.display().to_string(), |name| name.to_string_lossy().into_owned())
524}
525
526fn add_own<Msg>(items: &mut Vec<ContextItem<Msg>>, own: Vec<ContextItem<Msg>>) {
528 if !own.is_empty() {
529 items.push(ContextItem::gap());
530 items.extend(own);
531 }
532}
533
534struct Pending {
536 keys: Vec<String>,
537 copying: bool,
538}
539
540impl Pending {
541 fn is_empty(&self) -> bool {
543 self.keys.is_empty()
544 }
545
546 fn drop_label(&self) -> String {
548 let key = if self.copying { "quvyta.file-manager.drop-copy" } else { "quvyta.file-manager.drop-cut" };
549 crate::t!(key)
550 }
551}
552
553fn paste_items<Msg: Clone + 'static>(
556 items: &mut Vec<ContextItem<Msg>>,
557 key: &str,
558 pending: &Pending,
559 send: &impl Fn(FileManagerMsg) -> Msg,
560) {
561 if pending.is_empty() {
562 return;
563 }
564 let paste = ContextItem::new(crate::t!("quvyta.file-manager.paste"), send(FileManagerMsg::Paste(key.to_owned())));
565 items.push(paste.disabled(pending.keys.iter().any(|waiting| is_within(key, waiting))));
566 items.push(ContextItem::new(pending.drop_label(), send(FileManagerMsg::DropCut)));
567}
568
569fn away_item<Msg: Clone + 'static>(
572 key: &str,
573 count: usize,
574 trashing: bool,
575 send: &impl Fn(FileManagerMsg) -> Msg,
576) -> ContextItem<Msg> {
577 let many = count > 1;
578 let label = match (trashing, many) {
579 (true, false) => crate::t!("quvyta.file-manager.trash"),
580 (true, true) => crate::t!("quvyta.file-manager.trash-many", n = count),
581 (false, false) => crate::t!("quvyta.file-manager.delete"),
582 (false, true) => crate::t!("quvyta.file-manager.delete-many", n = count),
583 };
584 let message = if trashing {
585 send(FileManagerMsg::Trash(key.to_owned()))
586 } else {
587 send(FileManagerMsg::Delete(key.to_owned()))
588 };
589 ContextItem::new(label, message).danger(true)
590}
591
592fn folder_menu<Msg: Clone + 'static>(
595 key: &str,
596 pending: &Pending,
597 trashing: bool,
598 own: Vec<ContextItem<Msg>>,
599 send: &impl Fn(FileManagerMsg) -> Msg,
600) -> Vec<ContextItem<Msg>> {
601 let mut items = vec![
602 ContextItem::new(crate::t!("quvyta.file-manager.new-file"), send(FileManagerMsg::NewFile(key.to_owned()))),
603 ContextItem::new(crate::t!("quvyta.file-manager.new-folder"), send(FileManagerMsg::NewFolder(key.to_owned()))),
604 ];
605 let root = key == ROOT;
606 if !root {
607 items.push(ContextItem::gap());
608 items.push(ContextItem::new(
609 crate::t!("quvyta.file-manager.rename"),
610 send(FileManagerMsg::Rename(key.to_owned())),
611 ));
612 items.push(ContextItem::new(crate::t!("quvyta.file-manager.cut"), send(FileManagerMsg::Cut(key.to_owned()))));
613 items.push(ContextItem::new(crate::t!("quvyta.file-manager.copy"), send(FileManagerMsg::Copy(key.to_owned()))));
614 }
615 paste_items(&mut items, key, pending, send);
616 add_own(&mut items, own);
617 items.push(ContextItem::gap());
618 if root {
619 items.push(ContextItem::new(crate::t!("quvyta.file-manager.refresh"), send(FileManagerMsg::Refresh)));
620 } else {
621 items.push(away_item(key, 1, trashing, send));
622 }
623 items
624}
625
626fn file_menu<Msg: Clone + 'static>(
628 key: &str,
629 pending: &Pending,
630 trashing: bool,
631 own: Vec<ContextItem<Msg>>,
632 send: &impl Fn(FileManagerMsg) -> Msg,
633) -> Vec<ContextItem<Msg>> {
634 let mut items = vec![
635 ContextItem::new(crate::t!("quvyta.file-manager.rename"), send(FileManagerMsg::Rename(key.to_owned()))),
636 ContextItem::new(crate::t!("quvyta.file-manager.cut"), send(FileManagerMsg::Cut(key.to_owned()))),
637 ContextItem::new(crate::t!("quvyta.file-manager.copy"), send(FileManagerMsg::Copy(key.to_owned()))),
638 ];
639 if !pending.is_empty() {
640 items.push(ContextItem::new(pending.drop_label(), send(FileManagerMsg::DropCut)));
641 }
642 add_own(&mut items, own);
643 items.push(ContextItem::gap());
644 items.push(away_item(key, 1, trashing, send));
645 items
646}
647
648fn many_menu<Msg: Clone + 'static>(
651 key: &str,
652 count: usize,
653 pending: &Pending,
654 trashing: bool,
655 own: Vec<ContextItem<Msg>>,
656 send: &impl Fn(FileManagerMsg) -> Msg,
657) -> Vec<ContextItem<Msg>> {
658 let mut items = vec![
659 ContextItem::new(
660 crate::t!("quvyta.file-manager.cut-many", n = count),
661 send(FileManagerMsg::Cut(key.to_owned())),
662 ),
663 ContextItem::new(
664 crate::t!("quvyta.file-manager.copy-many", n = count),
665 send(FileManagerMsg::Copy(key.to_owned())),
666 ),
667 ];
668 if !pending.is_empty() {
669 items.push(ContextItem::new(pending.drop_label(), send(FileManagerMsg::DropCut)));
670 }
671 add_own(&mut items, own);
672 items.push(ContextItem::gap());
673 items.push(away_item(key, count, trashing, send));
674 items
675}