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 keys;
6mod kinds;
7mod mark;
8mod ops;
9mod state;
10mod trash;
11mod watch;
12
13#[cfg(test)]
14mod kinds_tests;
15#[cfg(test)]
16mod tests;
17
18use std::path::Path;
19use std::rc::Rc;
20
21use crate::icons::UserFolders;
22use crate::widget::{Length, NodeMut, View};
23
24use super::{Button, Click, ContextItem, Field, Form, FormErrors, Modal, ProgressBar, Text, TextInput, Tree, TreeNode};
25
26pub use details::FileDetails;
27pub use flat::FileView;
28pub use mark::RowMark;
29pub use ops::copy_into;
30use ops::stem;
31pub use ops::{FileChange, FileError, NameProblem, is_inside, is_within, name_of, parent_key};
32use state::ROOT;
33pub use state::{FileManagerMsg, FileManagerState, FileWork, FolderEntry, NameFor, Naming, child_key};
34
35/// Turns a manager's messages into the application's own, on the drawing side.
36type Wrap<Msg> = Rc<dyn Fn(FileManagerMsg) -> Msg>;
37
38/// What the application adds to the menu of the row `key`, which acts on `targets`.
39type Menu<Msg> = Rc<dyn Fn(&MenuTarget<'_>) -> Vec<ContextItem<Msg>>>;
40
41/// The row a menu was opened on, as [`FileManager::menu_for`] hands it to the application.
42#[derive(Debug, Clone, Copy)]
43#[non_exhaustive]
44pub struct MenuTarget<'a> {
45    /// The row's key, as [`FileManagerState`] names entries.
46    pub key: &'a str,
47    /// Where the entry is on disk.
48    pub path: &'a Path,
49    /// Whether the entry is a folder, the manager's own top row included.
50    pub folder: bool,
51    /// What an action from this menu acts on: the whole selection when the row is one of
52    /// several selected, the row alone otherwise, as [`FileManagerState::targets`] works it out.
53    pub selection: &'a [String],
54}
55
56/// What a row's path becomes for the application.
57type OnPath<Msg> = Rc<dyn Fn(&Path) -> Msg>;
58
59/// What the application says about the look of the row `key`.
60type Marks = Rc<dyn Fn(&str) -> RowMark>;
61
62/// The name the field of the naming dialog is focused by.
63const NAME_ID: &str = "file-manager-name";
64
65/// The name the rows carry when the application gives them none, so they stay one widget, and
66/// keep the keyboard, when the view changes.
67const ROWS_ID: &str = "file-manager-rows";
68
69/// Width of the naming dialog, in cells: room for a long file name without covering the screen.
70const NAMING_WIDTH: u16 = 48;
71
72/// A folder as a tree, with every file operation on it.
73///
74/// The manager is a **file view**, not an application: it reads the folder, draws it, does the
75/// file operations and says what happened. What opening a file means is always the application's:
76/// [`on_open`](Self::on_open) says a path was asked to be opened and nothing more.
77///
78/// The application owns a [`FileManagerState`], hands it every [`FileManagerMsg`] and draws it
79/// here. Folders are read on a background thread, never while drawing; a read that takes longer
80/// than about 300 ms shows a small spinner on the folder's own row, which then stays about 500 ms,
81/// so quick reads never flash one.
82///
83/// What it does: opening and closing folders, one and several selections, the keyboard's own way
84/// through the rows, dragging entries onto a folder to move them, cut and paste, a new file or
85/// folder, renaming with the name checked as it is typed, and deleting behind a question. Each
86/// operation says what it changed or why it was refused, entry by entry when there were several.
87///
88/// The mouse works as it does in a desktop file explorer, in all three views. A click only
89/// selects; a double click or Enter opens: a file through [`on_open`](Self::on_open), a folder by
90/// stepping into it in the list and the icons and by opening or closing it in the tree, where
91/// its chevron and ← and → still do that with one click. Ctrl+click adds an entry to the
92/// selection or takes it out, and Shift+click selects the entries from the last one clicked. A
93/// drag from the free space draws a box, a tone over the cells it covers, and selects the
94/// entries inside it, adding to the selection when Ctrl was held. A drag from a selected entry
95/// carries the whole selection: released on a folder it moves there, or is copied there when
96/// Ctrl is held at the release, and released anywhere else it does nothing. The folder under the
97/// drag takes the accent tone while it can take what is dragged; a folder never takes itself or a
98/// folder inside it. In the list and the icons the row of the shown folder is the way up, so a
99/// drop on it goes into the folder above, as a desktop explorer's path takes a drop for a parent;
100/// at the root it takes nothing. A terminal that does
101/// not report Ctrl with the pointer always moves. A name already taken in the folder is never
102/// overwritten; the entry says why it stayed. A right click on a selected entry opens the menu of
103/// the selection, and on any other entry selects it and opens its menu.
104/// [`open_on(Click::Single)`](Self::open_on) opens with one click instead.
105///
106/// What it draws: the root as the top row, so the folder itself has a place for its menu; folders
107/// then files, each in name order; an entry whose name the platform does not spell as text shown
108/// lossily rather than left out; what was cut faint until it is pasted or let go.
109///
110/// See [`FileManagerState`] for a whole application, and
111/// [`FileManagerState::confined`](FileManagerState::confined) for keeping operations inside the
112/// root.
113///
114/// Keys: the tree's own (↑/↓ between rows, ←/→ and Enter to open and close a folder, Enter on a
115/// file to open it, Home and End, the menu key on the row the cursor is on), and a desktop file
116/// explorer's selection keys in all three views: Shift with the arrows, PgUp/PgDn, Home or End
117/// extends the selection from where it started, Space adds or takes out the entry under the
118/// cursor, Ctrl+A selects every entry shown and Esc leaves only the entry under the cursor
119/// selected. Then Ctrl+X, Ctrl+C and Ctrl+V. Ctrl+X cuts the selection and
120/// Ctrl+C copies it, the entry under the cursor when nothing is selected; Ctrl+V pastes what waits
121/// into the folder the list and the icons show, and in the tree into the folder under the cursor,
122/// or the folder holding the file under it. A name already taken there is refused and said, as a
123/// paste from the menu is. The keys are the manager's only while its rows have focus and no text
124/// is selected with the mouse: a text field keeps copying and pasting text, and selected text is
125/// what Ctrl+C copies, see [`NodeMut::on_clipboard`](crate::widget::NodeMut::on_clipboard). Copy
126/// and paste follow the keymap's `copy` and `paste`.
127///
128/// What it can add: each row's icon by the kind of the entry, see [`kind_icons`](Self::kind_icons),
129/// and those icons in the colours of their families, see [`kind_tones`](Self::kind_tones).
130///
131/// Style keys: the tree's (`list-item`, `tree-chevron`, `tree-drop`, `list-detail`, `spinner`),
132/// the context menu's and the dialog's. Texts: `quvyta.file-manager.*`.
133pub struct FileManager<'a, Msg> {
134    state: &'a FileManagerState,
135    wrap: Wrap<Msg>,
136    root_label: Option<String>,
137    on_open: Option<OnPath<Msg>>,
138    on_open_terminal: Option<OnPath<Msg>>,
139    menu: Option<Menu<Msg>>,
140    marks: Option<Marks>,
141    view: FileView,
142    open_on: Click,
143    disabled: bool,
144    kind_icons: bool,
145    kind_tones: bool,
146    user_folders: Option<&'a UserFolders>,
147    rows_id: Option<String>,
148    /// How kinds are drawn on this screen, worked out when it is shown.
149    kinds: kinds::KindLook<'a>,
150}
151
152impl<'a, Msg: Clone + 'static> FileManager<'a, Msg> {
153    /// A manager showing `state`; `wrap` turns the manager's messages into the application's.
154    ///
155    /// `wrap` is a function such as `Msg::Files`, or a closure that captures what it needs, such
156    /// as a screen's own conversion: `move |message| convert(screen::Msg::Files(message))`.
157    #[must_use]
158    pub fn new(state: &'a FileManagerState, wrap: impl Fn(FileManagerMsg) -> Msg + 'static) -> Self {
159        Self {
160            state,
161            wrap: Rc::new(wrap),
162            root_label: None,
163            on_open: None,
164            on_open_terminal: None,
165            menu: None,
166            marks: None,
167            view: FileView::Tree,
168            open_on: Click::Double,
169            disabled: false,
170            kind_icons: false,
171            kind_tones: false,
172            user_folders: None,
173            rows_id: None,
174            kinds: kinds::KindLook::default(),
175        }
176    }
177
178    /// What the top row says. The name of the root folder by default; an application with a name
179    /// of its own for it, such as a project's, gives that instead.
180    #[must_use]
181    pub fn root_label(mut self, label: impl Into<String>) -> Self {
182        self.root_label = Some(label.into());
183        self
184    }
185
186    /// A file was asked to be opened: a double click or Enter on its row, or a click with
187    /// [`open_on(Click::Single)`](Self::open_on).
188    ///
189    /// The manager has no viewer, tab or window of its own; one application opens the path in a
190    /// tab, another in a window, and a dialog returns it as the answer. Without this a double
191    /// click on a file only selects it.
192    #[must_use]
193    pub fn on_open(mut self, message: impl Fn(&Path) -> Msg + 'static) -> Self {
194        self.on_open = Some(Rc::new(message));
195        self
196    }
197
198    /// How many clicks open an entry: [`Click::Double`], the default, the way a desktop file
199    /// explorer opens, so a click is free to select or to start a drag; or [`Click::Single`], a
200    /// click that selects and opens at once, for a picker whose rows are only ever opened.
201    ///
202    /// A double click is two presses on the same entry within [`Click::INTERVAL`]. Enter opens
203    /// either way, and a folder's chevron in the tree opens and closes it with one click.
204    #[must_use]
205    pub fn open_on(mut self, click: Click) -> Self {
206        self.open_on = click;
207        self
208    }
209
210    /// Offers "Open a terminal here" on a folder's menu, with the folder's path.
211    ///
212    /// The wording is the framework's, so every application says it the same way; what a terminal
213    /// is stays the application's own.
214    #[must_use]
215    pub fn on_open_terminal(mut self, message: impl Fn(&Path) -> Msg + 'static) -> Self {
216        self.on_open_terminal = Some(Rc::new(message));
217        self
218    }
219
220    /// The application's own items on a row's menu, in a group of their own between the manager's
221    /// editing items and its last, destructive one.
222    ///
223    /// The row's key comes first and what an action there acts on second: the whole selection when
224    /// the row is one of several selected, the row alone otherwise, as
225    /// [`FileManagerState::targets`] works it out.
226    ///
227    /// [`menu_for`](Self::menu_for) is the same with the row's path and kind as well.
228    #[must_use]
229    pub fn menu_items(self, items: impl Fn(&str, &[String]) -> Vec<ContextItem<Msg>> + 'static) -> Self {
230        self.menu_for(move |target| items(target.key, target.selection))
231    }
232
233    /// The application's own items on a row's menu, like [`menu_items`](Self::menu_items), told
234    /// everything the manager knows of the row: its key, its path, whether it is a folder and what
235    /// an action there acts on. An application offering "Open" for files and "Add to favourites"
236    /// for folders needs no list of folders of its own.
237    #[must_use]
238    pub fn menu_for(mut self, items: impl Fn(&MenuTarget<'_>) -> Vec<ContextItem<Msg>> + 'static) -> Self {
239        self.menu = Some(Rc::new(items));
240        self
241    }
242
243    /// What the application says about the look of a row, by key: a sign in a tone, a faint row,
244    /// or both. See [`RowMark`].
245    ///
246    /// The manager knows names and folders; what an entry means to the application it cannot know.
247    /// qcode marks an entry its backup leaves out with a warning sign and draws the row faint; a
248    /// version control panel marks what is ignored. Return [`RowMark::new()`] for a row with
249    /// nothing to say, which is every row by default.
250    ///
251    /// A mark cannot make a row louder than the manager's own states: a cut entry and a disabled
252    /// manager stay faint whatever the mark says, because they are about what can be done rather
253    /// than about what the entry is.
254    #[must_use]
255    pub fn row_mark(mut self, mark: impl Fn(&str) -> RowMark + 'static) -> Self {
256        self.marks = Some(Rc::new(mark));
257        self
258    }
259
260    /// The shape the folder is drawn in: the tree it is without being asked, a list of rows with
261    /// their size, date and permissions, or a grid of icons.
262    ///
263    /// The tree shows folders inside folders, opened where they stand. The other two show one
264    /// folder at a time: its own row comes first, so the folder has a place for its menu and a way
265    /// back out of it, and stepping into a folder shows that folder instead. Which folder is shown
266    /// is [`FileManagerState::folder`], and the keys, the menus and every operation are the same
267    /// in all three.
268    ///
269    /// The list reads the size, the date and the permissions of a page of entries around the
270    /// cursor, never of a whole folder; the tree and the icons read none.
271    #[must_use]
272    pub fn view(mut self, view: FileView) -> Self {
273        self.view = view;
274        self
275    }
276
277    /// Draws each row's icon by the kind of its entry: the Rust logo on a Rust file, a zipper on
278    /// an archive, a folder with a branch on `.git`, the downloads folder in the home. Off, every
279    /// row is a plain `folder` or `file`.
280    ///
281    /// A person knows what a file is from its icon before reading its name. The kind comes from
282    /// the name alone, see [`file_kind`](crate::icons::file_kind), so no file is opened to draw
283    /// it; whether a file whose name says nothing may be run is the one thing read, with the
284    /// folder. Outside a Nerd Font each icon is its family's shape, so code, pictures and archives
285    /// are still told apart.
286    ///
287    /// The icons have no colour of their own, as the plain ones have none: they are drawn in the
288    /// row's quiet colour and take the selected row's colour with the rest of it. A sign an
289    /// application gives with [`row_mark`](Self::row_mark) says something the kind cannot, so it
290    /// wins over the kind. Colours by kind are a further layer, [`kind_tones`](Self::kind_tones).
291    ///
292    /// The folders of the home are found by the names the person's language gives them, read from
293    /// `user-dirs.dirs` once the home is on screen; [`user_folders`](Self::user_folders) gives them
294    /// instead.
295    #[must_use]
296    pub fn kind_icons(mut self, on: bool) -> Self {
297        self.kind_icons = on;
298        self
299    }
300
301    /// Colours the icons of [`kind_icons`](Self::kind_icons) by their family: folders take the
302    /// accent and the files the theme's series tones, see
303    /// [`KindFamily::tone`](crate::icons::KindFamily::tone). A file whose kind is not known keeps
304    /// the row's colour.
305    ///
306    /// The colour only repeats what the shape says, so it adds nothing where tones cannot be told
307    /// apart: in sixteen colours and in ASCII it is not drawn. It does nothing without
308    /// [`kind_icons`](Self::kind_icons).
309    #[must_use]
310    pub fn kind_tones(mut self, on: bool) -> Self {
311        self.kind_tones = on;
312        self
313    }
314
315    /// The home and its folders [`kind_icons`](Self::kind_icons) recognises, in place of the
316    /// person's own, [`UserFolders::current`].
317    ///
318    /// For a manager showing another person's home, or a test that means a home of its own.
319    #[must_use]
320    pub fn user_folders(mut self, folders: &'a UserFolders) -> Self {
321        self.user_folders = Some(folders);
322        self
323    }
324
325    /// Names the rows, so [`Command::focus(name)`](crate::runtime::Command::focus) gives them
326    /// the keyboard: an application that takes the person to another folder, from a list of
327    /// places, a path bar or a back button, sends it so ↑ and ↓ move through the new folder at
328    /// once.
329    ///
330    /// The name is on the rows themselves, the tree, the list or the icons, whichever is drawn,
331    /// not on the column [`show`](Self::show) answers with, which holds the foot too and takes no
332    /// focus. The rows are the same widget in all three views, so rows that have the keyboard
333    /// keep it when the view changes, named or not.
334    #[must_use]
335    pub fn id(mut self, name: impl Into<String>) -> Self {
336        self.rows_id = Some(name.into());
337        self
338    }
339
340    /// Draws the rows faint and answers nothing: no click, key, drag or menu, while the
341    /// application has taken the folder away from the person.
342    #[must_use]
343    pub fn disabled(mut self, disabled: bool) -> Self {
344        self.disabled = disabled;
345        self
346    }
347
348    /// Adds the manager to `ui` and answers with the column that holds it, to be given a size.
349    ///
350    /// The column holds the rows and, in the list and the icons, the foot under them. It takes no
351    /// focus itself; [`id`](Self::id) names the rows inside it, which do.
352    ///
353    /// The dialog that asks for a name is added too while one is asked for; it is a layer and
354    /// takes no room of its own.
355    pub fn show<'v>(mut self, ui: &'v mut View<'_, Msg>) -> NodeMut<'v, Msg> {
356        let state = self.state;
357        self.kinds = self.kind_look(ui.env());
358        if let Some(problem) = state.error() {
359            ui.add(Text::new(crate::t!("quvyta.file-manager.unreadable")).role("secondary"));
360            return ui.add(Text::new(problem.to_owned()).role("faint")).selectable(true);
361        }
362        self.naming_dialog(ui);
363        self.work_row(ui);
364        // The list shows details, so it asks for the page around the cursor it has none of yet;
365        // the tree and the icons show names alone and ask for nothing, which is what keeps a
366        // folder of ten thousand entries from becoming ten thousand calls to the system.
367        if self.view == FileView::List {
368            let gaps = state.detail_gaps(state.folder());
369            if !gaps.is_empty() {
370                let wrap = Rc::clone(&self.wrap);
371                ui.on_idle(std::time::Duration::ZERO, move |_| wrap(FileManagerMsg::Detail(gaps.clone())));
372            }
373        }
374        // Every view is the same column with the rows first under the same name, so the rows are
375        // one widget whatever shape they take: focus on them outlives a change of view.
376        let name = self.rows_id.clone().unwrap_or_else(|| ROWS_ID.to_owned());
377        let node = ui.column(|ui| match self.view {
378            FileView::Tree => {
379                let tree = self.tree();
380                ui.add(tree).fill().id(name);
381            }
382            FileView::List | FileView::Icons => {
383                let rows = self.flat_rows();
384                if self.view == FileView::Icons {
385                    let grid = self.grid(&rows);
386                    ui.add(grid).fill().id(name);
387                } else {
388                    let table = self.table(&rows);
389                    ui.add(table).fill().id(name);
390                }
391                self.foot(ui, &rows);
392            }
393        });
394        self.claim_clipboard(node)
395    }
396
397    /// The row above the rows while a long operation runs: what it is doing, how far it has come
398    /// and a way to say stop.
399    ///
400    /// It sits above the tree rather than over it: the rows stay readable while a copy goes on, and
401    /// the row goes away by itself when the work ends. It takes no room at all while nothing runs.
402    fn work_row(&self, ui: &mut View<'_, Msg>) {
403        let Some(work) = self.state.work() else { return };
404        let label = crate::t!("quvyta.file-manager.copying", n = work.entries());
405        let stop = (self.wrap)(FileManagerMsg::Stop);
406        let note = work.note().to_owned();
407        let done = work.done();
408        ui.row(|ui| {
409            ui.add(Text::new(label).role("secondary").no_wrap());
410            ui.add(ProgressBar::new(done).percent(true)).width(Length::Fill(1));
411            if !note.is_empty() {
412                ui.add(Text::new(note).role("faint").no_wrap());
413            }
414            ui.add(Button::new(crate::t!("quvyta.file-manager.stop")).on_press(stop));
415        })
416        .fill_width();
417    }
418
419    /// The tree of the whole manager, with the root as its one top row.
420    fn tree(&self) -> Tree<Msg> {
421        let state = self.state;
422        let tree = Tree::new([self.root_node()]);
423        if self.disabled {
424            return tree;
425        }
426        let wrap = Rc::clone(&self.wrap);
427        let expand = Rc::clone(&self.wrap);
428        let choose = Rc::clone(&self.wrap);
429        let drop = Rc::clone(&self.wrap);
430        let copy = Rc::clone(&self.wrap);
431        let accepts = state.folder_keys();
432        let tree = tree
433            .selected(state.selected())
434            .on_select(move |key| wrap(FileManagerMsg::Select(key.to_owned())))
435            // The root is where the person is, not an entry to carry away, so a box or Ctrl+A that
436            // covers its row leaves it out.
437            .multi_select(state.chosen(), move |keys| {
438                choose(FileManagerMsg::Choose(keys.into_iter().filter(|key| key != ROOT).collect()))
439            })
440            .droppable(
441                move |dropped| drop(FileManagerMsg::Drop(dropped)),
442                move |key| key == ROOT || accepts.contains(key),
443            )
444            .on_copy_drop(move |dropped| copy(FileManagerMsg::DropCopy(dropped)))
445            .activate_on(self.open_on)
446            .box_select(true)
447            .on_expand(move |key, open| expand(FileManagerMsg::Expand(key.to_owned(), open)))
448            .context_menu(self.row_menu());
449        // Enter or a double click on a file opens it; on a folder they open the folder, which the
450        // tree does itself. Space and the modified clicks select instead.
451        match &self.on_open {
452            Some(open) => {
453                let (open, root) = (Rc::clone(open), state.root().to_path_buf());
454                tree.on_activate(move |key| open(&path_of(&root, key)))
455            }
456            None => tree,
457        }
458    }
459
460    /// The root folder itself, as the one row at the top.
461    ///
462    /// It is a row rather than nothing so the folder has a place of its own: its menu makes entries
463    /// and pastes at the top, reached by a right click or by selecting it and pressing the menu key
464    /// like any other row. A tree only as tall as its rows has no empty part below them to click,
465    /// so the row is the one way the mouse and the keyboard reach the folder alike.
466    fn root_node(&self) -> TreeNode {
467        let state = self.state;
468        let label = self.root_label.clone().unwrap_or_else(|| root_name(state.root()));
469        let mark = self.mark_of(ROOT);
470        let (icon, tone) = self.sign_of(&mark, ROOT, &root_name(state.root()), true, false);
471        let mut root = TreeNode::new(ROOT, label).icon(icon, tone.as_deref()).faint(self.disabled || mark.is_faint());
472        // An unread folder is not an empty one, so it never says "empty" before it is known.
473        if state.shown_children(ROOT).is_some_and(|entries| entries.is_empty()) {
474            root = root.detail(crate::t!("quvyta.file-manager.empty"));
475        }
476        root.expandable(true).expanded(state.is_open(ROOT)).loading(state.is_loading(ROOT)).children(self.nodes(ROOT))
477    }
478
479    /// The rows below the folder `key`, as far as it has been read.
480    fn nodes(&self, key: &str) -> Vec<TreeNode> {
481        let state = self.state;
482        let Some(entries) = state.shown_children(key) else { return Vec::new() };
483        entries
484            .into_iter()
485            .map(|entry| {
486                let child = child_key(key, &entry.name);
487                let mark = self.mark_of(&child);
488                let (icon, tone) = self.sign_of(&mark, &child, &entry.name, entry.folder, entry.executable);
489                // What was cut is drawn faint until it is pasted or let go, with everything in it.
490                let faint = self.disabled || state.is_cut(&child) || mark.is_faint();
491                let mut node =
492                    TreeNode::new(child.clone(), entry.name.clone()).icon(icon, tone.as_deref()).faint(faint);
493                if entry.folder {
494                    let open = state.is_open(&child);
495                    node = node.expandable(true).expanded(open).loading(state.is_loading(&child));
496                    // A folder the system refused says so on its own row. Without this it opened
497                    // to nothing, which reads as an empty folder: the person would be told a
498                    // folder they may not look into holds nothing.
499                    if state.folder_error(&child).is_some() {
500                        node = node.detail(crate::t!("quvyta.file-manager.unreadable-short"));
501                    }
502                    if open {
503                        node = node.children(self.nodes(&child));
504                    }
505                }
506                node
507            })
508            .collect()
509    }
510
511    /// What the application says about the row `key`, nothing when it says nothing.
512    fn mark_of(&self, key: &str) -> RowMark {
513        self.marks.as_ref().map(|mark| mark(key)).unwrap_or_default()
514    }
515
516    /// The icon and the colour the row `key` is drawn with: the mark's sign when it has one, and
517    /// the manager's own icon for the entry called `name` otherwise, in the row's own colour
518    /// unless kinds are coloured.
519    fn sign_of(
520        &self,
521        mark: &RowMark,
522        key: &str,
523        name: &str,
524        folder: bool,
525        executable: bool,
526    ) -> (String, Option<String>) {
527        match mark.icon() {
528            Some(icon) => (icon.to_owned(), mark.tone().map(str::to_owned)),
529            None => self.own_icon(key, name, folder, executable),
530        }
531    }
532
533    /// What every row's menu holds.
534    fn row_menu(&self) -> impl Fn(&str) -> Vec<ContextItem<Msg>> + 'static {
535        let state = self.state;
536        // The menu is built long after the view, so it takes what it needs along rather than the
537        // state itself.
538        let wrap = Rc::clone(&self.wrap);
539        let chosen = state.chosen().to_vec();
540        let pending = Pending { keys: state.pending().to_vec(), copying: state.is_copying() };
541        let trashing = state.is_trashing();
542        let folders = state.folder_keys();
543        let extra = self.menu.clone();
544        let terminal = self.on_open_terminal.clone();
545        let root = state.root().to_path_buf();
546        move |key: &str| {
547            // The tree keeps the selection when the click is on one of its rows and makes the row
548            // the selection otherwise, so the menu acts on what the click was on.
549            let targets = state::targets_of(&chosen, key);
550            let folder = key == ROOT || folders.contains(key);
551            let path = path_of(&root, key);
552            let target = MenuTarget { key, path: &path, folder, selection: &targets };
553            let mut own = extra.as_ref().map(|items| items(&target)).unwrap_or_default();
554            if let Some(message) = &terminal
555                && folder
556            {
557                let label = crate::t!("quvyta.file-manager.open-terminal");
558                own.push(ContextItem::new(label, message(&path)));
559            }
560            let send = |message: FileManagerMsg| wrap(message);
561            if targets.len() > 1 {
562                return many_menu(key, targets.len(), &pending, trashing, own, &send);
563            }
564            if folder {
565                return folder_menu(key, &pending, trashing, own, &send);
566            }
567            file_menu(key, &pending, trashing, own, &send)
568        }
569    }
570
571    /// The dialog that asks for a name, while one is asked for. It waits for an answer rather than
572    /// sitting beside the tree: the name is all there is to do until it is given or dropped.
573    fn naming_dialog(&self, ui: &mut View<'_, Msg>) {
574        let state = self.state;
575        let Some(naming) = state.naming() else { return };
576        let (title, confirm) = match &naming.purpose {
577            NameFor::File => (crate::t!("quvyta.file-manager.new-file-title"), crate::t!("quvyta.file-manager.create")),
578            NameFor::Folder => {
579                (crate::t!("quvyta.file-manager.new-folder-title"), crate::t!("quvyta.file-manager.create"))
580            }
581            NameFor::Rename(key) => (
582                crate::t!("quvyta.file-manager.rename-title", name = name_of(key)),
583                crate::t!("quvyta.file-manager.rename-do"),
584            ),
585        };
586        let close = (self.wrap)(FileManagerMsg::CloseNaming);
587        let submit = (self.wrap)(FileManagerMsg::Submit);
588        let dialog = Modal::new()
589            .title(title)
590            .width(NAMING_WIDTH)
591            .on_close(close.clone())
592            .action(Button::new(crate::t!("quvyta.file-manager.cancel")).on_press(close))
593            .action(Button::new(confirm).variant("primary").on_press(submit.clone()));
594        let mut errors = FormErrors::new();
595        if let Some(problem) = state.naming_problem() {
596            errors.set(NAME_ID, problem.message());
597        }
598        let value = naming.value.clone();
599        // A rename selects the name without its extension, so typing gives a new name and keeps
600        // the kind of file; a new entry starts empty and has nothing to select.
601        let selection = match &naming.purpose {
602            NameFor::Rename(key) => Some(stem(&value, state.is_folder(key))),
603            NameFor::File | NameFor::Folder => None,
604        };
605        let typed = Rc::clone(&self.wrap);
606        ui.add_with(dialog, |ui| {
607            Form::new().show(ui, |fields| {
608                let label = crate::t!("quvyta.file-manager.name-label");
609                fields.field(Field::new(label).error(errors.get(NAME_ID)), |ui| {
610                    let mut input = TextInput::new(value)
611                        .invalid(errors.has(NAME_ID))
612                        .on_change(move |value| typed(FileManagerMsg::Name(value)))
613                        .on_submit(move |_| submit.clone());
614                    if let Some(range) = selection {
615                        input = input.select_on_focus(range);
616                    }
617                    ui.add(input).id(NAME_ID).fill_width();
618                });
619            });
620        });
621    }
622}
623
624/// The path of the entry `key` under `root`, the folder a [`FileManagerState`] shows: the root's
625/// own key is `root` itself, and a key's parts separated by `/` are folders below it.
626///
627/// ```
628/// use std::path::Path;
629/// use qframe::widgets::path_of;
630///
631/// assert_eq!(path_of(Path::new("/home/ali"), "notes/2026/june.md"), Path::new("/home/ali/notes/2026/june.md"));
632/// ```
633#[must_use]
634pub fn path_of(root: &Path, key: &str) -> std::path::PathBuf {
635    key.split('/').filter(|part| !part.is_empty()).fold(root.to_path_buf(), |path, part| path.join(part))
636}
637
638/// What the top row says about the folder at `root`: its own name, or the whole path when it has
639/// none, as the file system's own root has none.
640fn root_name(root: &Path) -> String {
641    root.file_name().map_or_else(|| root.display().to_string(), |name| name.to_string_lossy().into_owned())
642}
643
644/// Puts `own`, when there is any, into a menu as a group of its own.
645fn add_own<Msg>(items: &mut Vec<ContextItem<Msg>>, own: Vec<ContextItem<Msg>>) {
646    if !own.is_empty() {
647        items.push(ContextItem::gap());
648        items.extend(own);
649    }
650}
651
652/// What waits to be pasted, and whether pasting it will copy it.
653struct Pending {
654    keys: Vec<String>,
655    copying: bool,
656}
657
658impl Pending {
659    /// Whether anything waits to be pasted.
660    fn is_empty(&self) -> bool {
661        self.keys.is_empty()
662    }
663
664    /// What letting go of it is called: a move is cancelled, a copy is cancelled.
665    fn drop_label(&self) -> String {
666        let key = if self.copying { "quvyta.file-manager.drop-copy" } else { "quvyta.file-manager.drop-cut" };
667        crate::t!(key)
668    }
669}
670
671/// The items for what waits to be pasted: pasting it here, and letting it go. A folder cannot take
672/// itself or a folder that holds it, so pasting there is shown but cannot be chosen.
673fn paste_items<Msg: Clone + 'static>(
674    items: &mut Vec<ContextItem<Msg>>,
675    key: &str,
676    pending: &Pending,
677    send: &impl Fn(FileManagerMsg) -> Msg,
678) {
679    if pending.is_empty() {
680        return;
681    }
682    let paste = ContextItem::new(crate::t!("quvyta.file-manager.paste"), send(FileManagerMsg::Paste(key.to_owned())));
683    items.push(paste.disabled(pending.keys.iter().any(|waiting| is_within(key, waiting))));
684    items.push(ContextItem::new(pending.drop_label(), send(FileManagerMsg::DropCut)));
685}
686
687/// The last item of a row's menu: the trash when the manager has one, and deleting for good
688/// otherwise. Both are the destructive item, so both stand alone at the end in the danger colour.
689fn away_item<Msg: Clone + 'static>(
690    key: &str,
691    count: usize,
692    trashing: bool,
693    send: &impl Fn(FileManagerMsg) -> Msg,
694) -> ContextItem<Msg> {
695    let many = count > 1;
696    let label = match (trashing, many) {
697        (true, false) => crate::t!("quvyta.file-manager.trash"),
698        (true, true) => crate::t!("quvyta.file-manager.trash-many", n = count),
699        (false, false) => crate::t!("quvyta.file-manager.delete"),
700        (false, true) => crate::t!("quvyta.file-manager.delete-many", n = count),
701    };
702    let message = if trashing {
703        send(FileManagerMsg::Trash(key.to_owned()))
704    } else {
705        send(FileManagerMsg::Delete(key.to_owned()))
706    };
707    ContextItem::new(label, message).danger(true)
708}
709
710/// The menu of a folder, or of the root itself when `key` is [`ROOT`]: what can be made in it and,
711/// while something is cut, pasting it here.
712fn folder_menu<Msg: Clone + 'static>(
713    key: &str,
714    pending: &Pending,
715    trashing: bool,
716    own: Vec<ContextItem<Msg>>,
717    send: &impl Fn(FileManagerMsg) -> Msg,
718) -> Vec<ContextItem<Msg>> {
719    let mut items = vec![
720        ContextItem::new(crate::t!("quvyta.file-manager.new-file"), send(FileManagerMsg::NewFile(key.to_owned()))),
721        ContextItem::new(crate::t!("quvyta.file-manager.new-folder"), send(FileManagerMsg::NewFolder(key.to_owned()))),
722    ];
723    let root = key == ROOT;
724    if !root {
725        items.push(ContextItem::gap());
726        items.push(ContextItem::new(
727            crate::t!("quvyta.file-manager.rename"),
728            send(FileManagerMsg::Rename(key.to_owned())),
729        ));
730        items.push(ContextItem::new(crate::t!("quvyta.file-manager.cut"), send(FileManagerMsg::Cut(key.to_owned()))));
731        items.push(ContextItem::new(crate::t!("quvyta.file-manager.copy"), send(FileManagerMsg::Copy(key.to_owned()))));
732    }
733    paste_items(&mut items, key, pending, send);
734    add_own(&mut items, own);
735    items.push(ContextItem::gap());
736    if root {
737        items.push(ContextItem::new(crate::t!("quvyta.file-manager.refresh"), send(FileManagerMsg::Refresh)));
738    } else {
739        items.push(away_item(key, 1, trashing, send));
740    }
741    items
742}
743
744/// The menu of a file.
745fn file_menu<Msg: Clone + 'static>(
746    key: &str,
747    pending: &Pending,
748    trashing: bool,
749    own: Vec<ContextItem<Msg>>,
750    send: &impl Fn(FileManagerMsg) -> Msg,
751) -> Vec<ContextItem<Msg>> {
752    let mut items = vec![
753        ContextItem::new(crate::t!("quvyta.file-manager.rename"), send(FileManagerMsg::Rename(key.to_owned()))),
754        ContextItem::new(crate::t!("quvyta.file-manager.cut"), send(FileManagerMsg::Cut(key.to_owned()))),
755        ContextItem::new(crate::t!("quvyta.file-manager.copy"), send(FileManagerMsg::Copy(key.to_owned()))),
756    ];
757    if !pending.is_empty() {
758        items.push(ContextItem::new(pending.drop_label(), send(FileManagerMsg::DropCut)));
759    }
760    add_own(&mut items, own);
761    items.push(ContextItem::gap());
762    items.push(away_item(key, 1, trashing, send));
763    items
764}
765
766/// The menu of a row that is one of `count` selected entries: what can be done to all of them at
767/// once. A name is given to one entry at a time, so renaming is not offered.
768fn many_menu<Msg: Clone + 'static>(
769    key: &str,
770    count: usize,
771    pending: &Pending,
772    trashing: bool,
773    own: Vec<ContextItem<Msg>>,
774    send: &impl Fn(FileManagerMsg) -> Msg,
775) -> Vec<ContextItem<Msg>> {
776    let mut items = vec![
777        ContextItem::new(
778            crate::t!("quvyta.file-manager.cut-many", n = count),
779            send(FileManagerMsg::Cut(key.to_owned())),
780        ),
781        ContextItem::new(
782            crate::t!("quvyta.file-manager.copy-many", n = count),
783            send(FileManagerMsg::Copy(key.to_owned())),
784        ),
785    ];
786    if !pending.is_empty() {
787        items.push(ContextItem::new(pending.drop_label(), send(FileManagerMsg::DropCut)));
788    }
789    add_own(&mut items, own);
790    items.push(ContextItem::gap());
791    items.push(away_item(key, count, trashing, send));
792    items
793}