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}