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 open {
326 node = node.children(self.nodes(&child));
327 }
328 }
329 node
330 })
331 .collect()
332 }
333
334 fn mark_of(&self, key: &str) -> RowMark {
336 self.marks.as_ref().map(|mark| mark(key)).unwrap_or_default()
337 }
338
339 fn sign_of(&self, mark: &RowMark, own: &'static str) -> (String, Option<String>) {
342 match mark.icon() {
343 Some(icon) => (icon.to_owned(), mark.tone().map(str::to_owned)),
344 None => (own.to_owned(), None),
345 }
346 }
347
348 fn menu_for(&self) -> impl Fn(&str) -> Vec<ContextItem<Msg>> + 'static {
350 let state = self.state;
351 let wrap = Rc::clone(&self.wrap);
354 let chosen = state.chosen().to_vec();
355 let pending = Pending { keys: state.pending().to_vec(), copying: state.is_copying() };
356 let trashing = state.is_trashing();
357 let folders = state.folder_keys();
358 let extra = self.menu.clone();
359 let terminal = self.on_open_terminal.clone();
360 let root = state.root().to_path_buf();
361 move |key: &str| {
362 let targets = state::targets_of(&chosen, key);
365 let mut own = extra.as_ref().map(|items| items(key, &targets)).unwrap_or_default();
366 if let Some(message) = &terminal
367 && (key == ROOT || folders.contains(key))
368 {
369 let label = crate::t!("quvyta.file-manager.open-terminal");
370 own.push(ContextItem::new(label, message(&path_of(&root, key))));
371 }
372 let send = |message: FileManagerMsg| wrap(message);
373 if targets.len() > 1 {
374 return many_menu(key, targets.len(), &pending, trashing, own, &send);
375 }
376 if key == ROOT || folders.contains(key) {
377 return folder_menu(key, &pending, trashing, own, &send);
378 }
379 file_menu(key, &pending, trashing, own, &send)
380 }
381 }
382
383 fn naming_dialog(&self, ui: &mut View<'_, Msg>) {
386 let state = self.state;
387 let Some(naming) = state.naming() else { return };
388 let (title, confirm) = match &naming.purpose {
389 NameFor::File => (crate::t!("quvyta.file-manager.new-file-title"), crate::t!("quvyta.file-manager.create")),
390 NameFor::Folder => {
391 (crate::t!("quvyta.file-manager.new-folder-title"), crate::t!("quvyta.file-manager.create"))
392 }
393 NameFor::Rename(key) => (
394 crate::t!("quvyta.file-manager.rename-title", name = name_of(key)),
395 crate::t!("quvyta.file-manager.rename-do"),
396 ),
397 };
398 let close = (self.wrap)(FileManagerMsg::CloseNaming);
399 let submit = (self.wrap)(FileManagerMsg::Submit);
400 let dialog = Modal::new()
401 .title(title)
402 .width(NAMING_WIDTH)
403 .on_close(close.clone())
404 .action(Button::new(crate::t!("quvyta.file-manager.cancel")).on_press(close))
405 .action(Button::new(confirm).variant("primary").on_press(submit.clone()));
406 let mut errors = FormErrors::new();
407 if let Some(problem) = state.naming_problem() {
408 errors.set(NAME_ID, problem.message());
409 }
410 let value = naming.value.clone();
411 let selection = match &naming.purpose {
414 NameFor::Rename(key) => Some(stem(&value, state.is_folder(key))),
415 NameFor::File | NameFor::Folder => None,
416 };
417 let typed = Rc::clone(&self.wrap);
418 ui.add_with(dialog, |ui| {
419 Form::new().show(ui, |fields| {
420 let label = crate::t!("quvyta.file-manager.name-label");
421 fields.field(Field::new(label).error(errors.get(NAME_ID)), |ui| {
422 let mut input = TextInput::new(value)
423 .invalid(errors.has(NAME_ID))
424 .on_change(move |value| typed(FileManagerMsg::Name(value)))
425 .on_submit(move |_| submit.clone());
426 if let Some(range) = selection {
427 input = input.select_on_focus(range);
428 }
429 ui.add(input).id(NAME_ID).fill_width();
430 });
431 });
432 });
433 }
434}
435
436fn path_of(root: &Path, key: &str) -> std::path::PathBuf {
438 key.split('/').filter(|part| !part.is_empty()).fold(root.to_path_buf(), |path, part| path.join(part))
439}
440
441fn root_name(root: &Path) -> String {
444 root.file_name().map_or_else(|| root.display().to_string(), |name| name.to_string_lossy().into_owned())
445}
446
447fn add_own<Msg>(items: &mut Vec<ContextItem<Msg>>, own: Vec<ContextItem<Msg>>) {
449 if !own.is_empty() {
450 items.push(ContextItem::gap());
451 items.extend(own);
452 }
453}
454
455struct Pending {
457 keys: Vec<String>,
458 copying: bool,
459}
460
461impl Pending {
462 fn is_empty(&self) -> bool {
464 self.keys.is_empty()
465 }
466
467 fn drop_label(&self) -> String {
469 let key = if self.copying { "quvyta.file-manager.drop-copy" } else { "quvyta.file-manager.drop-cut" };
470 crate::t!(key)
471 }
472}
473
474fn paste_items<Msg: Clone + 'static>(
477 items: &mut Vec<ContextItem<Msg>>,
478 key: &str,
479 pending: &Pending,
480 send: &impl Fn(FileManagerMsg) -> Msg,
481) {
482 if pending.is_empty() {
483 return;
484 }
485 let paste = ContextItem::new(crate::t!("quvyta.file-manager.paste"), send(FileManagerMsg::Paste(key.to_owned())));
486 items.push(paste.disabled(pending.keys.iter().any(|waiting| is_within(key, waiting))));
487 items.push(ContextItem::new(pending.drop_label(), send(FileManagerMsg::DropCut)));
488}
489
490fn away_item<Msg: Clone + 'static>(
493 key: &str,
494 count: usize,
495 trashing: bool,
496 send: &impl Fn(FileManagerMsg) -> Msg,
497) -> ContextItem<Msg> {
498 let many = count > 1;
499 let label = match (trashing, many) {
500 (true, false) => crate::t!("quvyta.file-manager.trash"),
501 (true, true) => crate::t!("quvyta.file-manager.trash-many", n = count),
502 (false, false) => crate::t!("quvyta.file-manager.delete"),
503 (false, true) => crate::t!("quvyta.file-manager.delete-many", n = count),
504 };
505 let message = if trashing {
506 send(FileManagerMsg::Trash(key.to_owned()))
507 } else {
508 send(FileManagerMsg::Delete(key.to_owned()))
509 };
510 ContextItem::new(label, message).danger(true)
511}
512
513fn folder_menu<Msg: Clone + 'static>(
516 key: &str,
517 pending: &Pending,
518 trashing: bool,
519 own: Vec<ContextItem<Msg>>,
520 send: &impl Fn(FileManagerMsg) -> Msg,
521) -> Vec<ContextItem<Msg>> {
522 let mut items = vec![
523 ContextItem::new(crate::t!("quvyta.file-manager.new-file"), send(FileManagerMsg::NewFile(key.to_owned()))),
524 ContextItem::new(crate::t!("quvyta.file-manager.new-folder"), send(FileManagerMsg::NewFolder(key.to_owned()))),
525 ];
526 let root = key == ROOT;
527 if !root {
528 items.push(ContextItem::gap());
529 items.push(ContextItem::new(
530 crate::t!("quvyta.file-manager.rename"),
531 send(FileManagerMsg::Rename(key.to_owned())),
532 ));
533 items.push(ContextItem::new(crate::t!("quvyta.file-manager.cut"), send(FileManagerMsg::Cut(key.to_owned()))));
534 items.push(ContextItem::new(crate::t!("quvyta.file-manager.copy"), send(FileManagerMsg::Copy(key.to_owned()))));
535 }
536 paste_items(&mut items, key, pending, send);
537 add_own(&mut items, own);
538 items.push(ContextItem::gap());
539 if root {
540 items.push(ContextItem::new(crate::t!("quvyta.file-manager.refresh"), send(FileManagerMsg::Refresh)));
541 } else {
542 items.push(away_item(key, 1, trashing, send));
543 }
544 items
545}
546
547fn file_menu<Msg: Clone + 'static>(
549 key: &str,
550 pending: &Pending,
551 trashing: bool,
552 own: Vec<ContextItem<Msg>>,
553 send: &impl Fn(FileManagerMsg) -> Msg,
554) -> Vec<ContextItem<Msg>> {
555 let mut items = vec![
556 ContextItem::new(crate::t!("quvyta.file-manager.rename"), send(FileManagerMsg::Rename(key.to_owned()))),
557 ContextItem::new(crate::t!("quvyta.file-manager.cut"), send(FileManagerMsg::Cut(key.to_owned()))),
558 ContextItem::new(crate::t!("quvyta.file-manager.copy"), send(FileManagerMsg::Copy(key.to_owned()))),
559 ];
560 if !pending.is_empty() {
561 items.push(ContextItem::new(pending.drop_label(), send(FileManagerMsg::DropCut)));
562 }
563 add_own(&mut items, own);
564 items.push(ContextItem::gap());
565 items.push(away_item(key, 1, trashing, send));
566 items
567}
568
569fn many_menu<Msg: Clone + 'static>(
572 key: &str,
573 count: usize,
574 pending: &Pending,
575 trashing: bool,
576 own: Vec<ContextItem<Msg>>,
577 send: &impl Fn(FileManagerMsg) -> Msg,
578) -> Vec<ContextItem<Msg>> {
579 let mut items = vec![
580 ContextItem::new(
581 crate::t!("quvyta.file-manager.cut-many", n = count),
582 send(FileManagerMsg::Cut(key.to_owned())),
583 ),
584 ContextItem::new(
585 crate::t!("quvyta.file-manager.copy-many", n = count),
586 send(FileManagerMsg::Copy(key.to_owned())),
587 ),
588 ];
589 if !pending.is_empty() {
590 items.push(ContextItem::new(pending.drop_label(), send(FileManagerMsg::DropCut)));
591 }
592 add_own(&mut items, own);
593 items.push(ContextItem::gap());
594 items.push(away_item(key, count, trashing, send));
595 items
596}