1mod details;
4mod flat;
5mod mark;
6mod ops;
7mod state;
8mod trash;
9mod watch;
10
11#[cfg(test)]
12mod tests;
13
14use std::path::Path;
15use std::rc::Rc;
16
17use crate::widget::{Length, NodeMut, View};
18
19use super::{Button, ContextItem, Field, Form, FormErrors, Modal, ProgressBar, Text, TextInput, Tree, TreeNode};
20
21pub use details::FileDetails;
22pub use flat::FileView;
23pub use mark::RowMark;
24pub use ops::copy_into;
25use ops::stem;
26pub use ops::{FileChange, FileError, NameProblem, is_inside, is_within, name_of, parent_key};
27use state::ROOT;
28pub use state::{FileManagerMsg, FileManagerState, FileWork, FolderEntry, NameFor, Naming, child_key};
29
30type Wrap<Msg> = Rc<dyn Fn(FileManagerMsg) -> Msg>;
32
33type Menu<Msg> = Rc<dyn Fn(&str, &[String]) -> Vec<ContextItem<Msg>>>;
35
36type OnPath<Msg> = Rc<dyn Fn(&Path) -> Msg>;
38
39type Marks = Rc<dyn Fn(&str) -> RowMark>;
41
42const NAME_ID: &str = "file-manager-name";
44
45const NAMING_WIDTH: u16 = 48;
47
48pub struct FileManager<'a, Msg> {
79 state: &'a FileManagerState,
80 wrap: Wrap<Msg>,
81 root_label: Option<String>,
82 on_open: Option<OnPath<Msg>>,
83 on_open_terminal: Option<OnPath<Msg>>,
84 menu: Option<Menu<Msg>>,
85 marks: Option<Marks>,
86 view: FileView,
87 disabled: bool,
88}
89
90impl<'a, Msg: Clone + 'static> FileManager<'a, Msg> {
91 #[must_use]
96 pub fn new(state: &'a FileManagerState, wrap: impl Fn(FileManagerMsg) -> Msg + 'static) -> Self {
97 Self {
98 state,
99 wrap: Rc::new(wrap),
100 root_label: None,
101 on_open: None,
102 on_open_terminal: None,
103 menu: None,
104 marks: None,
105 view: FileView::Tree,
106 disabled: false,
107 }
108 }
109
110 #[must_use]
113 pub fn root_label(mut self, label: impl Into<String>) -> Self {
114 self.root_label = Some(label.into());
115 self
116 }
117
118 #[must_use]
124 pub fn on_open(mut self, message: impl Fn(&Path) -> Msg + 'static) -> Self {
125 self.on_open = Some(Rc::new(message));
126 self
127 }
128
129 #[must_use]
134 pub fn on_open_terminal(mut self, message: impl Fn(&Path) -> Msg + 'static) -> Self {
135 self.on_open_terminal = Some(Rc::new(message));
136 self
137 }
138
139 #[must_use]
146 pub fn menu_items(mut self, items: impl Fn(&str, &[String]) -> Vec<ContextItem<Msg>> + 'static) -> Self {
147 self.menu = Some(Rc::new(items));
148 self
149 }
150
151 #[must_use]
163 pub fn row_mark(mut self, mark: impl Fn(&str) -> RowMark + 'static) -> Self {
164 self.marks = Some(Rc::new(mark));
165 self
166 }
167
168 #[must_use]
180 pub fn view(mut self, view: FileView) -> Self {
181 self.view = view;
182 self
183 }
184
185 #[must_use]
188 pub fn disabled(mut self, disabled: bool) -> Self {
189 self.disabled = disabled;
190 self
191 }
192
193 pub fn show<'v>(self, ui: &'v mut View<'_, Msg>) -> NodeMut<'v, Msg> {
198 let state = self.state;
199 if let Some(problem) = state.error() {
200 ui.add(Text::new(crate::t!("quvyta.file-manager.unreadable")).role("secondary"));
201 return ui.add(Text::new(problem.to_owned()).role("faint")).selectable(true);
202 }
203 self.naming_dialog(ui);
204 self.work_row(ui);
205 if self.view == FileView::Tree {
206 let tree = self.tree();
207 return ui.add(tree);
208 }
209 let rows = self.flat_rows();
212 if self.view == FileView::List {
216 let gaps = state.detail_gaps(state.folder());
217 if !gaps.is_empty() {
218 let wrap = Rc::clone(&self.wrap);
219 ui.on_idle(std::time::Duration::ZERO, move |_| wrap(FileManagerMsg::Detail(gaps.clone())));
220 }
221 }
222 ui.column(|ui| {
223 if self.view == FileView::Icons {
224 let grid = self.grid(&rows);
225 ui.add(grid).fill();
226 } else {
227 let table = self.table(&rows);
228 ui.add(table).fill();
229 }
230 self.foot(ui, &rows);
231 })
232 }
233
234 fn work_row(&self, ui: &mut View<'_, Msg>) {
240 let Some(work) = self.state.work() else { return };
241 let label = crate::t!("quvyta.file-manager.copying", n = work.entries());
242 let stop = (self.wrap)(FileManagerMsg::Stop);
243 let note = work.note().to_owned();
244 let done = work.done();
245 ui.row(|ui| {
246 ui.add(Text::new(label).role("secondary").no_wrap());
247 ui.add(ProgressBar::new(done).percent(true)).width(Length::Fill(1));
248 if !note.is_empty() {
249 ui.add(Text::new(note).role("faint").no_wrap());
250 }
251 ui.add(Button::new(crate::t!("quvyta.file-manager.stop")).on_press(stop));
252 })
253 .fill_width();
254 }
255
256 fn tree(&self) -> Tree<Msg> {
258 let state = self.state;
259 let tree = Tree::new([self.root_node()]);
260 if self.disabled {
261 return tree;
262 }
263 let wrap = Rc::clone(&self.wrap);
264 let expand = Rc::clone(&self.wrap);
265 let choose = Rc::clone(&self.wrap);
266 let drop = Rc::clone(&self.wrap);
267 let accepts = state.folder_keys();
268 let tree = tree
269 .selected(state.selected())
270 .on_select(move |key| wrap(FileManagerMsg::Select(key.to_owned())))
271 .multi_select(state.chosen(), move |keys| choose(FileManagerMsg::Choose(keys)))
272 .droppable(
273 move |dropped| drop(FileManagerMsg::Drop(dropped)),
274 move |key| key == ROOT || accepts.contains(key),
275 )
276 .on_expand(move |key, open| expand(FileManagerMsg::Expand(key.to_owned(), open)))
277 .context_menu(self.menu_for());
278 match &self.on_open {
281 Some(open) => {
282 let (open, root) = (Rc::clone(open), state.root().to_path_buf());
283 tree.on_activate(move |key| open(&path_of(&root, key)))
284 }
285 None => tree,
286 }
287 }
288
289 fn root_node(&self) -> TreeNode {
296 let state = self.state;
297 let label = self.root_label.clone().unwrap_or_else(|| root_name(state.root()));
298 let mark = self.mark_of(ROOT);
299 let (icon, tone) = self.sign_of(&mark, "folder");
300 let mut root = TreeNode::new(ROOT, label).icon(icon, tone.as_deref()).faint(self.disabled || mark.is_faint());
301 if state.shown_children(ROOT).is_some_and(|entries| entries.is_empty()) {
303 root = root.detail(crate::t!("quvyta.file-manager.empty"));
304 }
305 root.expandable(true).expanded(state.is_open(ROOT)).loading(state.is_loading(ROOT)).children(self.nodes(ROOT))
306 }
307
308 fn nodes(&self, key: &str) -> Vec<TreeNode> {
310 let state = self.state;
311 let Some(entries) = state.shown_children(key) else { return Vec::new() };
312 entries
313 .into_iter()
314 .map(|entry| {
315 let child = child_key(key, &entry.name);
316 let mark = self.mark_of(&child);
317 let (icon, tone) = self.sign_of(&mark, if entry.folder { "folder" } else { "file" });
318 let faint = self.disabled || state.is_cut(&child) || mark.is_faint();
320 let mut node =
321 TreeNode::new(child.clone(), entry.name.clone()).icon(icon, tone.as_deref()).faint(faint);
322 if entry.folder {
323 let open = state.is_open(&child);
324 node = node.expandable(true).expanded(open).loading(state.is_loading(&child));
325 if state.folder_error(&child).is_some() {
329 node = node.detail(crate::t!("quvyta.file-manager.unreadable-short"));
330 }
331 if open {
332 node = node.children(self.nodes(&child));
333 }
334 }
335 node
336 })
337 .collect()
338 }
339
340 fn mark_of(&self, key: &str) -> RowMark {
342 self.marks.as_ref().map(|mark| mark(key)).unwrap_or_default()
343 }
344
345 fn sign_of(&self, mark: &RowMark, own: &'static str) -> (String, Option<String>) {
348 match mark.icon() {
349 Some(icon) => (icon.to_owned(), mark.tone().map(str::to_owned)),
350 None => (own.to_owned(), None),
351 }
352 }
353
354 fn menu_for(&self) -> impl Fn(&str) -> Vec<ContextItem<Msg>> + 'static {
356 let state = self.state;
357 let wrap = Rc::clone(&self.wrap);
360 let chosen = state.chosen().to_vec();
361 let pending = Pending { keys: state.pending().to_vec(), copying: state.is_copying() };
362 let trashing = state.is_trashing();
363 let folders = state.folder_keys();
364 let extra = self.menu.clone();
365 let terminal = self.on_open_terminal.clone();
366 let root = state.root().to_path_buf();
367 move |key: &str| {
368 let targets = state::targets_of(&chosen, key);
371 let mut own = extra.as_ref().map(|items| items(key, &targets)).unwrap_or_default();
372 if let Some(message) = &terminal
373 && (key == ROOT || folders.contains(key))
374 {
375 let label = crate::t!("quvyta.file-manager.open-terminal");
376 own.push(ContextItem::new(label, message(&path_of(&root, key))));
377 }
378 let send = |message: FileManagerMsg| wrap(message);
379 if targets.len() > 1 {
380 return many_menu(key, targets.len(), &pending, trashing, own, &send);
381 }
382 if key == ROOT || folders.contains(key) {
383 return folder_menu(key, &pending, trashing, own, &send);
384 }
385 file_menu(key, &pending, trashing, own, &send)
386 }
387 }
388
389 fn naming_dialog(&self, ui: &mut View<'_, Msg>) {
392 let state = self.state;
393 let Some(naming) = state.naming() else { return };
394 let (title, confirm) = match &naming.purpose {
395 NameFor::File => (crate::t!("quvyta.file-manager.new-file-title"), crate::t!("quvyta.file-manager.create")),
396 NameFor::Folder => {
397 (crate::t!("quvyta.file-manager.new-folder-title"), crate::t!("quvyta.file-manager.create"))
398 }
399 NameFor::Rename(key) => (
400 crate::t!("quvyta.file-manager.rename-title", name = name_of(key)),
401 crate::t!("quvyta.file-manager.rename-do"),
402 ),
403 };
404 let close = (self.wrap)(FileManagerMsg::CloseNaming);
405 let submit = (self.wrap)(FileManagerMsg::Submit);
406 let dialog = Modal::new()
407 .title(title)
408 .width(NAMING_WIDTH)
409 .on_close(close.clone())
410 .action(Button::new(crate::t!("quvyta.file-manager.cancel")).on_press(close))
411 .action(Button::new(confirm).variant("primary").on_press(submit.clone()));
412 let mut errors = FormErrors::new();
413 if let Some(problem) = state.naming_problem() {
414 errors.set(NAME_ID, problem.message());
415 }
416 let value = naming.value.clone();
417 let selection = match &naming.purpose {
420 NameFor::Rename(key) => Some(stem(&value, state.is_folder(key))),
421 NameFor::File | NameFor::Folder => None,
422 };
423 let typed = Rc::clone(&self.wrap);
424 ui.add_with(dialog, |ui| {
425 Form::new().show(ui, |fields| {
426 let label = crate::t!("quvyta.file-manager.name-label");
427 fields.field(Field::new(label).error(errors.get(NAME_ID)), |ui| {
428 let mut input = TextInput::new(value)
429 .invalid(errors.has(NAME_ID))
430 .on_change(move |value| typed(FileManagerMsg::Name(value)))
431 .on_submit(move |_| submit.clone());
432 if let Some(range) = selection {
433 input = input.select_on_focus(range);
434 }
435 ui.add(input).id(NAME_ID).fill_width();
436 });
437 });
438 });
439 }
440}
441
442fn path_of(root: &Path, key: &str) -> std::path::PathBuf {
444 key.split('/').filter(|part| !part.is_empty()).fold(root.to_path_buf(), |path, part| path.join(part))
445}
446
447fn root_name(root: &Path) -> String {
450 root.file_name().map_or_else(|| root.display().to_string(), |name| name.to_string_lossy().into_owned())
451}
452
453fn add_own<Msg>(items: &mut Vec<ContextItem<Msg>>, own: Vec<ContextItem<Msg>>) {
455 if !own.is_empty() {
456 items.push(ContextItem::gap());
457 items.extend(own);
458 }
459}
460
461struct Pending {
463 keys: Vec<String>,
464 copying: bool,
465}
466
467impl Pending {
468 fn is_empty(&self) -> bool {
470 self.keys.is_empty()
471 }
472
473 fn drop_label(&self) -> String {
475 let key = if self.copying { "quvyta.file-manager.drop-copy" } else { "quvyta.file-manager.drop-cut" };
476 crate::t!(key)
477 }
478}
479
480fn paste_items<Msg: Clone + 'static>(
483 items: &mut Vec<ContextItem<Msg>>,
484 key: &str,
485 pending: &Pending,
486 send: &impl Fn(FileManagerMsg) -> Msg,
487) {
488 if pending.is_empty() {
489 return;
490 }
491 let paste = ContextItem::new(crate::t!("quvyta.file-manager.paste"), send(FileManagerMsg::Paste(key.to_owned())));
492 items.push(paste.disabled(pending.keys.iter().any(|waiting| is_within(key, waiting))));
493 items.push(ContextItem::new(pending.drop_label(), send(FileManagerMsg::DropCut)));
494}
495
496fn away_item<Msg: Clone + 'static>(
499 key: &str,
500 count: usize,
501 trashing: bool,
502 send: &impl Fn(FileManagerMsg) -> Msg,
503) -> ContextItem<Msg> {
504 let many = count > 1;
505 let label = match (trashing, many) {
506 (true, false) => crate::t!("quvyta.file-manager.trash"),
507 (true, true) => crate::t!("quvyta.file-manager.trash-many", n = count),
508 (false, false) => crate::t!("quvyta.file-manager.delete"),
509 (false, true) => crate::t!("quvyta.file-manager.delete-many", n = count),
510 };
511 let message = if trashing {
512 send(FileManagerMsg::Trash(key.to_owned()))
513 } else {
514 send(FileManagerMsg::Delete(key.to_owned()))
515 };
516 ContextItem::new(label, message).danger(true)
517}
518
519fn folder_menu<Msg: Clone + 'static>(
522 key: &str,
523 pending: &Pending,
524 trashing: bool,
525 own: Vec<ContextItem<Msg>>,
526 send: &impl Fn(FileManagerMsg) -> Msg,
527) -> Vec<ContextItem<Msg>> {
528 let mut items = vec![
529 ContextItem::new(crate::t!("quvyta.file-manager.new-file"), send(FileManagerMsg::NewFile(key.to_owned()))),
530 ContextItem::new(crate::t!("quvyta.file-manager.new-folder"), send(FileManagerMsg::NewFolder(key.to_owned()))),
531 ];
532 let root = key == ROOT;
533 if !root {
534 items.push(ContextItem::gap());
535 items.push(ContextItem::new(
536 crate::t!("quvyta.file-manager.rename"),
537 send(FileManagerMsg::Rename(key.to_owned())),
538 ));
539 items.push(ContextItem::new(crate::t!("quvyta.file-manager.cut"), send(FileManagerMsg::Cut(key.to_owned()))));
540 items.push(ContextItem::new(crate::t!("quvyta.file-manager.copy"), send(FileManagerMsg::Copy(key.to_owned()))));
541 }
542 paste_items(&mut items, key, pending, send);
543 add_own(&mut items, own);
544 items.push(ContextItem::gap());
545 if root {
546 items.push(ContextItem::new(crate::t!("quvyta.file-manager.refresh"), send(FileManagerMsg::Refresh)));
547 } else {
548 items.push(away_item(key, 1, trashing, send));
549 }
550 items
551}
552
553fn file_menu<Msg: Clone + 'static>(
555 key: &str,
556 pending: &Pending,
557 trashing: bool,
558 own: Vec<ContextItem<Msg>>,
559 send: &impl Fn(FileManagerMsg) -> Msg,
560) -> Vec<ContextItem<Msg>> {
561 let mut items = vec![
562 ContextItem::new(crate::t!("quvyta.file-manager.rename"), send(FileManagerMsg::Rename(key.to_owned()))),
563 ContextItem::new(crate::t!("quvyta.file-manager.cut"), send(FileManagerMsg::Cut(key.to_owned()))),
564 ContextItem::new(crate::t!("quvyta.file-manager.copy"), send(FileManagerMsg::Copy(key.to_owned()))),
565 ];
566 if !pending.is_empty() {
567 items.push(ContextItem::new(pending.drop_label(), send(FileManagerMsg::DropCut)));
568 }
569 add_own(&mut items, own);
570 items.push(ContextItem::gap());
571 items.push(away_item(key, 1, trashing, send));
572 items
573}
574
575fn many_menu<Msg: Clone + 'static>(
578 key: &str,
579 count: usize,
580 pending: &Pending,
581 trashing: bool,
582 own: Vec<ContextItem<Msg>>,
583 send: &impl Fn(FileManagerMsg) -> Msg,
584) -> Vec<ContextItem<Msg>> {
585 let mut items = vec![
586 ContextItem::new(
587 crate::t!("quvyta.file-manager.cut-many", n = count),
588 send(FileManagerMsg::Cut(key.to_owned())),
589 ),
590 ContextItem::new(
591 crate::t!("quvyta.file-manager.copy-many", n = count),
592 send(FileManagerMsg::Copy(key.to_owned())),
593 ),
594 ];
595 if !pending.is_empty() {
596 items.push(ContextItem::new(pending.drop_label(), send(FileManagerMsg::DropCut)));
597 }
598 add_own(&mut items, own);
599 items.push(ContextItem::gap());
600 items.push(away_item(key, count, trashing, send));
601 items
602}