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 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
30/// Turns a manager's messages into the application's own, on the drawing side.
31type Wrap<Msg> = Rc<dyn Fn(FileManagerMsg) -> Msg>;
32
33/// What the application adds to the menu of the row `key`, which acts on `targets`.
34type Menu<Msg> = Rc<dyn Fn(&str, &[String]) -> Vec<ContextItem<Msg>>>;
35
36/// What a row's path becomes for the application.
37type OnPath<Msg> = Rc<dyn Fn(&Path) -> Msg>;
38
39/// What the application says about the look of the row `key`.
40type Marks = Rc<dyn Fn(&str) -> RowMark>;
41
42/// The name the field of the naming dialog is focused by.
43const NAME_ID: &str = "file-manager-name";
44
45/// Width of the naming dialog, in cells: room for a long file name without covering the screen.
46const NAMING_WIDTH: u16 = 48;
47
48/// A folder as a tree, with every file operation on it.
49///
50/// The manager is a **file view**, not an application: it reads the folder, draws it, does the
51/// file operations and says what happened. What opening a file means is always the application's:
52/// [`on_open`](Self::on_open) says a path was asked to be opened and nothing more.
53///
54/// The application owns a [`FileManagerState`], hands it every [`FileManagerMsg`] and draws it
55/// here. Folders are read on a background thread, never while drawing; a read that takes longer
56/// than about 300 ms shows a small spinner on the folder's own row, which then stays about 500 ms,
57/// so quick reads never flash one.
58///
59/// What it does: opening and closing folders, one and several selections, the keyboard's own way
60/// through the rows, dragging entries onto a folder to move them, cut and paste, a new file or
61/// folder, renaming with the name checked as it is typed, and deleting behind a question. Each
62/// operation says what it changed or why it was refused, entry by entry when there were several.
63///
64/// What it draws: the root as the top row, so the folder itself has a place for its menu; folders
65/// then files, each in name order; an entry whose name the platform does not spell as text shown
66/// lossily rather than left out; what was cut faint until it is pasted or let go.
67///
68/// See [`FileManagerState`] for a whole application, and
69/// [`FileManagerState::confined`](FileManagerState::confined) for keeping operations inside the
70/// root.
71///
72/// Keys: the tree's own (↑/↓ between rows, ←/→ and Enter to open and close a folder, Enter on a
73/// file to open it, Space and Ctrl to select several, Home and End, the menu key on the row the
74/// cursor is on).
75///
76/// Style keys: the tree's (`list-item`, `tree-chevron`, `tree-drop`, `list-detail`, `spinner`),
77/// the context menu's and the dialog's. Texts: `quvyta.file-manager.*`.
78pub 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    /// A manager showing `state`; `wrap` turns the manager's messages into the application's.
92    ///
93    /// `wrap` is a function such as `Msg::Files`, or a closure that captures what it needs, such
94    /// as a screen's own conversion: `move |message| convert(screen::Msg::Files(message))`.
95    #[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    /// What the top row says. The name of the root folder by default; an application with a name
111    /// of its own for it, such as a project's, gives that instead.
112    #[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    /// A file was asked to be opened: a click or Enter on its row.
119    ///
120    /// The manager has no viewer, tab or window of its own; one application opens the path in a
121    /// tab, another in a window, and a dialog returns it as the answer. Without this a click on a
122    /// file only selects it.
123    #[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    /// Offers "Open a terminal here" on a folder's menu, with the folder's path.
130    ///
131    /// The wording is the framework's, so every application says it the same way; what a terminal
132    /// is stays the application's own.
133    #[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    /// The application's own items on a row's menu, in a group of their own between the manager's
140    /// editing items and its last, destructive one.
141    ///
142    /// The row's key comes first and what an action there acts on second: the whole selection when
143    /// the row is one of several selected, the row alone otherwise, as
144    /// [`FileManagerState::targets`] works it out.
145    #[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    /// What the application says about the look of a row, by key: a sign in a tone, a faint row,
152    /// or both. See [`RowMark`].
153    ///
154    /// The manager knows names and folders; what an entry means to the application it cannot know.
155    /// qcode marks an entry its backup leaves out with a warning sign and draws the row faint; a
156    /// version control panel marks what is ignored. Return [`RowMark::new()`] for a row with
157    /// nothing to say, which is every row by default.
158    ///
159    /// A mark cannot make a row louder than the manager's own states: a cut entry and a disabled
160    /// manager stay faint whatever the mark says, because they are about what can be done rather
161    /// than about what the entry is.
162    #[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    /// The shape the folder is drawn in: the tree it is without being asked, a list of rows with
169    /// their size, date and permissions, or a grid of icons.
170    ///
171    /// The tree shows folders inside folders, opened where they stand. The other two show one
172    /// folder at a time: its own row comes first, so the folder has a place for its menu and a way
173    /// back out of it, and stepping into a folder shows that folder instead. Which folder is shown
174    /// is [`FileManagerState::folder`], and the keys, the menus and every operation are the same
175    /// in all three.
176    ///
177    /// The list reads the size, the date and the permissions of a page of entries around the
178    /// cursor, never of a whole folder; the tree and the icons read none.
179    #[must_use]
180    pub fn view(mut self, view: FileView) -> Self {
181        self.view = view;
182        self
183    }
184
185    /// Draws the rows faint and answers nothing: no click, key, drag or menu, while the
186    /// application has taken the folder away from the person.
187    #[must_use]
188    pub fn disabled(mut self, disabled: bool) -> Self {
189        self.disabled = disabled;
190        self
191    }
192
193    /// Adds the manager to `ui` and answers with its tree, to be given a size and a name.
194    ///
195    /// The dialog that asks for a name is added too while one is asked for; it is a layer and
196    /// takes no room of its own.
197    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        // The rows and the foot under them are one thing to place, so they are given a column of
210        // their own and the application sizes that.
211        let rows = self.flat_rows();
212        // The list shows details, so it asks for the page around the cursor it has none of yet;
213        // the tree and the icons show names alone and ask for nothing, which is what keeps a
214        // folder of ten thousand entries from becoming ten thousand calls to the system.
215        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    /// The row above the rows while a long operation runs: what it is doing, how far it has come
235    /// and a way to say stop.
236    ///
237    /// It sits above the tree rather than over it: the rows stay readable while a copy goes on, and
238    /// the row goes away by itself when the work ends. It takes no room at all while nothing runs.
239    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    /// The tree of the whole manager, with the root as its one top row.
257    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        // Enter or a click on a file opens it; on a folder they open the folder, which the tree
279        // does itself. Space and the modified clicks select instead.
280        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    /// The root folder itself, as the one row at the top.
290    ///
291    /// It is a row rather than nothing so the folder has a place of its own: its menu makes entries
292    /// and pastes at the top, reached by a right click or by selecting it and pressing the menu key
293    /// like any other row. A tree only as tall as its rows has no empty part below them to click,
294    /// so the row is the one way the mouse and the keyboard reach the folder alike.
295    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        // An unread folder is not an empty one, so it never says "empty" before it is known.
302        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    /// The rows below the folder `key`, as far as it has been read.
309    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                // What was cut is drawn faint until it is pasted or let go, with everything in it.
319                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    /// What the application says about the row `key`, nothing when it says nothing.
335    fn mark_of(&self, key: &str) -> RowMark {
336        self.marks.as_ref().map(|mark| mark(key)).unwrap_or_default()
337    }
338
339    /// The icon and the colour a row is drawn with: the mark's sign when it has one, and the
340    /// manager's own folder or file icon in the row's own colour otherwise.
341    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    /// What every row's menu holds.
349    fn menu_for(&self) -> impl Fn(&str) -> Vec<ContextItem<Msg>> + 'static {
350        let state = self.state;
351        // The menu is built long after the view, so it takes what it needs along rather than the
352        // state itself.
353        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            // The tree keeps the selection when the click is on one of its rows and makes the row
363            // the selection otherwise, so the menu acts on what the click was on.
364            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    /// The dialog that asks for a name, while one is asked for. It waits for an answer rather than
384    /// sitting beside the tree: the name is all there is to do until it is given or dropped.
385    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        // A rename selects the name without its extension, so typing gives a new name and keeps
412        // the kind of file; a new entry starts empty and has nothing to select.
413        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
436/// The path of the entry `key` under `root`.
437fn 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
441/// What the top row says about the folder at `root`: its own name, or the whole path when it has
442/// none, as the file system's own root has none.
443fn 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
447/// Puts `own`, when there is any, into a menu as a group of its own.
448fn 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
455/// What waits to be pasted, and whether pasting it will copy it.
456struct Pending {
457    keys: Vec<String>,
458    copying: bool,
459}
460
461impl Pending {
462    /// Whether anything waits to be pasted.
463    fn is_empty(&self) -> bool {
464        self.keys.is_empty()
465    }
466
467    /// What letting go of it is called: a move is cancelled, a copy is cancelled.
468    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
474/// The items for what waits to be pasted: pasting it here, and letting it go. A folder cannot take
475/// itself or a folder that holds it, so pasting there is shown but cannot be chosen.
476fn 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
490/// The last item of a row's menu: the trash when the manager has one, and deleting for good
491/// otherwise. Both are the destructive item, so both stand alone at the end in the danger colour.
492fn 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
513/// The menu of a folder, or of the root itself when `key` is [`ROOT`]: what can be made in it and,
514/// while something is cut, pasting it here.
515fn 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
547/// The menu of a file.
548fn 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
569/// The menu of a row that is one of `count` selected entries: what can be done to all of them at
570/// once. A name is given to one entry at a time, so renaming is not offered.
571fn 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}