Skip to main content

qframe/widgets/file_manager/
mod.rs

1//! A file manager: a folder shown as a tree, with the operations a person expects on it.
2
3mod 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
34/// Turns a manager's messages into the application's own, on the drawing side.
35type Wrap<Msg> = Rc<dyn Fn(FileManagerMsg) -> Msg>;
36
37/// What the application adds to the menu of the row `key`, which acts on `targets`.
38type Menu<Msg> = Rc<dyn Fn(&str, &[String]) -> Vec<ContextItem<Msg>>>;
39
40/// What a row's path becomes for the application.
41type OnPath<Msg> = Rc<dyn Fn(&Path) -> Msg>;
42
43/// What the application says about the look of the row `key`.
44type Marks = Rc<dyn Fn(&str) -> RowMark>;
45
46/// The name the field of the naming dialog is focused by.
47const NAME_ID: &str = "file-manager-name";
48
49/// Width of the naming dialog, in cells: room for a long file name without covering the screen.
50const NAMING_WIDTH: u16 = 48;
51
52/// A folder as a tree, with every file operation on it.
53///
54/// The manager is a **file view**, not an application: it reads the folder, draws it, does the
55/// file operations and says what happened. What opening a file means is always the application's:
56/// [`on_open`](Self::on_open) says a path was asked to be opened and nothing more.
57///
58/// The application owns a [`FileManagerState`], hands it every [`FileManagerMsg`] and draws it
59/// here. Folders are read on a background thread, never while drawing; a read that takes longer
60/// than about 300 ms shows a small spinner on the folder's own row, which then stays about 500 ms,
61/// so quick reads never flash one.
62///
63/// What it does: opening and closing folders, one and several selections, the keyboard's own way
64/// through the rows, dragging entries onto a folder to move them, cut and paste, a new file or
65/// folder, renaming with the name checked as it is typed, and deleting behind a question. Each
66/// operation says what it changed or why it was refused, entry by entry when there were several.
67///
68/// What it draws: the root as the top row, so the folder itself has a place for its menu; folders
69/// then files, each in name order; an entry whose name the platform does not spell as text shown
70/// lossily rather than left out; what was cut faint until it is pasted or let go.
71///
72/// See [`FileManagerState`] for a whole application, and
73/// [`FileManagerState::confined`](FileManagerState::confined) for keeping operations inside the
74/// root.
75///
76/// Keys: the tree's own (↑/↓ between rows, ←/→ and Enter to open and close a folder, Enter on a
77/// file to open it, Space and Ctrl to select several, Home and End, the menu key on the row the
78/// cursor is on).
79///
80/// What it can add: each row's icon by the kind of the entry, see [`kind_icons`](Self::kind_icons),
81/// and those icons in the colours of their families, see [`kind_tones`](Self::kind_tones).
82///
83/// Style keys: the tree's (`list-item`, `tree-chevron`, `tree-drop`, `list-detail`, `spinner`),
84/// the context menu's and the dialog's. Texts: `quvyta.file-manager.*`.
85pub 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    /// How kinds are drawn on this screen, worked out when it is shown.
99    kinds: kinds::KindLook<'a>,
100}
101
102impl<'a, Msg: Clone + 'static> FileManager<'a, Msg> {
103    /// A manager showing `state`; `wrap` turns the manager's messages into the application's.
104    ///
105    /// `wrap` is a function such as `Msg::Files`, or a closure that captures what it needs, such
106    /// as a screen's own conversion: `move |message| convert(screen::Msg::Files(message))`.
107    #[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    /// What the top row says. The name of the root folder by default; an application with a name
127    /// of its own for it, such as a project's, gives that instead.
128    #[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    /// A file was asked to be opened: a click or Enter on its row.
135    ///
136    /// The manager has no viewer, tab or window of its own; one application opens the path in a
137    /// tab, another in a window, and a dialog returns it as the answer. Without this a click on a
138    /// file only selects it.
139    #[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    /// Offers "Open a terminal here" on a folder's menu, with the folder's path.
146    ///
147    /// The wording is the framework's, so every application says it the same way; what a terminal
148    /// is stays the application's own.
149    #[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    /// The application's own items on a row's menu, in a group of their own between the manager's
156    /// editing items and its last, destructive one.
157    ///
158    /// The row's key comes first and what an action there acts on second: the whole selection when
159    /// the row is one of several selected, the row alone otherwise, as
160    /// [`FileManagerState::targets`] works it out.
161    #[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    /// What the application says about the look of a row, by key: a sign in a tone, a faint row,
168    /// or both. See [`RowMark`].
169    ///
170    /// The manager knows names and folders; what an entry means to the application it cannot know.
171    /// qcode marks an entry its backup leaves out with a warning sign and draws the row faint; a
172    /// version control panel marks what is ignored. Return [`RowMark::new()`] for a row with
173    /// nothing to say, which is every row by default.
174    ///
175    /// A mark cannot make a row louder than the manager's own states: a cut entry and a disabled
176    /// manager stay faint whatever the mark says, because they are about what can be done rather
177    /// than about what the entry is.
178    #[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    /// The shape the folder is drawn in: the tree it is without being asked, a list of rows with
185    /// their size, date and permissions, or a grid of icons.
186    ///
187    /// The tree shows folders inside folders, opened where they stand. The other two show one
188    /// folder at a time: its own row comes first, so the folder has a place for its menu and a way
189    /// back out of it, and stepping into a folder shows that folder instead. Which folder is shown
190    /// is [`FileManagerState::folder`], and the keys, the menus and every operation are the same
191    /// in all three.
192    ///
193    /// The list reads the size, the date and the permissions of a page of entries around the
194    /// cursor, never of a whole folder; the tree and the icons read none.
195    #[must_use]
196    pub fn view(mut self, view: FileView) -> Self {
197        self.view = view;
198        self
199    }
200
201    /// Draws each row's icon by the kind of its entry: the Rust logo on a Rust file, a zipper on
202    /// an archive, a folder with a branch on `.git`, the downloads folder in the home. Off, every
203    /// row is a plain `folder` or `file`.
204    ///
205    /// A person knows what a file is from its icon before reading its name. The kind comes from
206    /// the name alone, see [`file_kind`](crate::icons::file_kind), so no file is opened to draw
207    /// it; whether a file whose name says nothing may be run is the one thing read, with the
208    /// folder. Outside a Nerd Font each icon is its family's shape, so code, pictures and archives
209    /// are still told apart.
210    ///
211    /// The icons have no colour of their own, as the plain ones have none: they are drawn in the
212    /// row's quiet colour and take the selected row's colour with the rest of it. A sign an
213    /// application gives with [`row_mark`](Self::row_mark) says something the kind cannot, so it
214    /// wins over the kind. Colours by kind are a further layer, [`kind_tones`](Self::kind_tones).
215    ///
216    /// The folders of the home are found by the names the person's language gives them, read from
217    /// `user-dirs.dirs` once the home is on screen; [`user_folders`](Self::user_folders) gives them
218    /// instead.
219    #[must_use]
220    pub fn kind_icons(mut self, on: bool) -> Self {
221        self.kind_icons = on;
222        self
223    }
224
225    /// Colours the icons of [`kind_icons`](Self::kind_icons) by their family: folders take the
226    /// accent and the files the theme's series tones, see
227    /// [`KindFamily::tone`](crate::icons::KindFamily::tone). A file whose kind is not known keeps
228    /// the row's colour.
229    ///
230    /// The colour only repeats what the shape says, so it adds nothing where tones cannot be told
231    /// apart: in sixteen colours and in ASCII it is not drawn. It does nothing without
232    /// [`kind_icons`](Self::kind_icons).
233    #[must_use]
234    pub fn kind_tones(mut self, on: bool) -> Self {
235        self.kind_tones = on;
236        self
237    }
238
239    /// The home and its folders [`kind_icons`](Self::kind_icons) recognises, in place of the
240    /// person's own, [`UserFolders::current`].
241    ///
242    /// For a manager showing another person's home, or a test that means a home of its own.
243    #[must_use]
244    pub fn user_folders(mut self, folders: &'a UserFolders) -> Self {
245        self.user_folders = Some(folders);
246        self
247    }
248
249    /// Draws the rows faint and answers nothing: no click, key, drag or menu, while the
250    /// application has taken the folder away from the person.
251    #[must_use]
252    pub fn disabled(mut self, disabled: bool) -> Self {
253        self.disabled = disabled;
254        self
255    }
256
257    /// Adds the manager to `ui` and answers with its tree, to be given a size and a name.
258    ///
259    /// The dialog that asks for a name is added too while one is asked for; it is a layer and
260    /// takes no room of its own.
261    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        // The rows and the foot under them are one thing to place, so they are given a column of
275        // their own and the application sizes that.
276        let rows = self.flat_rows();
277        // The list shows details, so it asks for the page around the cursor it has none of yet;
278        // the tree and the icons show names alone and ask for nothing, which is what keeps a
279        // folder of ten thousand entries from becoming ten thousand calls to the system.
280        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    /// The row above the rows while a long operation runs: what it is doing, how far it has come
300    /// and a way to say stop.
301    ///
302    /// It sits above the tree rather than over it: the rows stay readable while a copy goes on, and
303    /// the row goes away by itself when the work ends. It takes no room at all while nothing runs.
304    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    /// The tree of the whole manager, with the root as its one top row.
322    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        // Enter or a click on a file opens it; on a folder they open the folder, which the tree
344        // does itself. Space and the modified clicks select instead.
345        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    /// The root folder itself, as the one row at the top.
355    ///
356    /// It is a row rather than nothing so the folder has a place of its own: its menu makes entries
357    /// and pastes at the top, reached by a right click or by selecting it and pressing the menu key
358    /// like any other row. A tree only as tall as its rows has no empty part below them to click,
359    /// so the row is the one way the mouse and the keyboard reach the folder alike.
360    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        // An unread folder is not an empty one, so it never says "empty" before it is known.
367        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    /// The rows below the folder `key`, as far as it has been read.
374    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                // What was cut is drawn faint until it is pasted or let go, with everything in it.
384                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                    // A folder the system refused says so on its own row. Without this it opened
391                    // to nothing, which reads as an empty folder: the person would be told a
392                    // folder they may not look into holds nothing.
393                    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    /// What the application says about the row `key`, nothing when it says nothing.
406    fn mark_of(&self, key: &str) -> RowMark {
407        self.marks.as_ref().map(|mark| mark(key)).unwrap_or_default()
408    }
409
410    /// The icon and the colour the row `key` is drawn with: the mark's sign when it has one, and
411    /// the manager's own icon for the entry called `name` otherwise, in the row's own colour
412    /// unless kinds are coloured.
413    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    /// What every row's menu holds.
428    fn menu_for(&self) -> impl Fn(&str) -> Vec<ContextItem<Msg>> + 'static {
429        let state = self.state;
430        // The menu is built long after the view, so it takes what it needs along rather than the
431        // state itself.
432        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            // The tree keeps the selection when the click is on one of its rows and makes the row
442            // the selection otherwise, so the menu acts on what the click was on.
443            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    /// The dialog that asks for a name, while one is asked for. It waits for an answer rather than
463    /// sitting beside the tree: the name is all there is to do until it is given or dropped.
464    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        // A rename selects the name without its extension, so typing gives a new name and keeps
491        // the kind of file; a new entry starts empty and has nothing to select.
492        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
515/// The path of the entry `key` under `root`.
516fn 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
520/// What the top row says about the folder at `root`: its own name, or the whole path when it has
521/// none, as the file system's own root has none.
522fn 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
526/// Puts `own`, when there is any, into a menu as a group of its own.
527fn 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
534/// What waits to be pasted, and whether pasting it will copy it.
535struct Pending {
536    keys: Vec<String>,
537    copying: bool,
538}
539
540impl Pending {
541    /// Whether anything waits to be pasted.
542    fn is_empty(&self) -> bool {
543        self.keys.is_empty()
544    }
545
546    /// What letting go of it is called: a move is cancelled, a copy is cancelled.
547    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
553/// The items for what waits to be pasted: pasting it here, and letting it go. A folder cannot take
554/// itself or a folder that holds it, so pasting there is shown but cannot be chosen.
555fn 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
569/// The last item of a row's menu: the trash when the manager has one, and deleting for good
570/// otherwise. Both are the destructive item, so both stand alone at the end in the danger colour.
571fn 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
592/// The menu of a folder, or of the root itself when `key` is [`ROOT`]: what can be made in it and,
593/// while something is cut, pasting it here.
594fn 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
626/// The menu of a file.
627fn 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
648/// The menu of a row that is one of `count` selected entries: what can be done to all of them at
649/// once. A name is given to one entry at a time, so renaming is not offered.
650fn 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}