mod details;
mod flat;
mod mark;
mod ops;
mod state;
mod trash;
mod watch;
#[cfg(test)]
mod tests;
use std::path::Path;
use std::rc::Rc;
use crate::widget::{Length, NodeMut, View};
use super::{Button, ContextItem, Field, Form, FormErrors, Modal, ProgressBar, Text, TextInput, Tree, TreeNode};
pub use details::FileDetails;
pub use flat::FileView;
pub use mark::RowMark;
pub use ops::copy_into;
use ops::stem;
pub use ops::{FileChange, FileError, NameProblem, is_inside, is_within, name_of, parent_key};
use state::ROOT;
pub use state::{FileManagerMsg, FileManagerState, FileWork, FolderEntry, NameFor, Naming, child_key};
type Wrap<Msg> = Rc<dyn Fn(FileManagerMsg) -> Msg>;
type Menu<Msg> = Rc<dyn Fn(&str, &[String]) -> Vec<ContextItem<Msg>>>;
type OnPath<Msg> = Rc<dyn Fn(&Path) -> Msg>;
type Marks = Rc<dyn Fn(&str) -> RowMark>;
const NAME_ID: &str = "file-manager-name";
const NAMING_WIDTH: u16 = 48;
pub struct FileManager<'a, Msg> {
state: &'a FileManagerState,
wrap: Wrap<Msg>,
root_label: Option<String>,
on_open: Option<OnPath<Msg>>,
on_open_terminal: Option<OnPath<Msg>>,
menu: Option<Menu<Msg>>,
marks: Option<Marks>,
view: FileView,
disabled: bool,
}
impl<'a, Msg: Clone + 'static> FileManager<'a, Msg> {
#[must_use]
pub fn new(state: &'a FileManagerState, wrap: impl Fn(FileManagerMsg) -> Msg + 'static) -> Self {
Self {
state,
wrap: Rc::new(wrap),
root_label: None,
on_open: None,
on_open_terminal: None,
menu: None,
marks: None,
view: FileView::Tree,
disabled: false,
}
}
#[must_use]
pub fn root_label(mut self, label: impl Into<String>) -> Self {
self.root_label = Some(label.into());
self
}
#[must_use]
pub fn on_open(mut self, message: impl Fn(&Path) -> Msg + 'static) -> Self {
self.on_open = Some(Rc::new(message));
self
}
#[must_use]
pub fn on_open_terminal(mut self, message: impl Fn(&Path) -> Msg + 'static) -> Self {
self.on_open_terminal = Some(Rc::new(message));
self
}
#[must_use]
pub fn menu_items(mut self, items: impl Fn(&str, &[String]) -> Vec<ContextItem<Msg>> + 'static) -> Self {
self.menu = Some(Rc::new(items));
self
}
#[must_use]
pub fn row_mark(mut self, mark: impl Fn(&str) -> RowMark + 'static) -> Self {
self.marks = Some(Rc::new(mark));
self
}
#[must_use]
pub fn view(mut self, view: FileView) -> Self {
self.view = view;
self
}
#[must_use]
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
pub fn show<'v>(self, ui: &'v mut View<'_, Msg>) -> NodeMut<'v, Msg> {
let state = self.state;
if let Some(problem) = state.error() {
ui.add(Text::new(crate::t!("quvyta.file-manager.unreadable")).role("secondary"));
return ui.add(Text::new(problem.to_owned()).role("faint")).selectable(true);
}
self.naming_dialog(ui);
self.work_row(ui);
if self.view == FileView::Tree {
let tree = self.tree();
return ui.add(tree);
}
let rows = self.flat_rows();
if self.view == FileView::List {
let gaps = state.detail_gaps(state.folder());
if !gaps.is_empty() {
let wrap = Rc::clone(&self.wrap);
ui.on_idle(std::time::Duration::ZERO, move |_| wrap(FileManagerMsg::Detail(gaps.clone())));
}
}
ui.column(|ui| {
if self.view == FileView::Icons {
let grid = self.grid(&rows);
ui.add(grid).fill();
} else {
let table = self.table(&rows);
ui.add(table).fill();
}
self.foot(ui, &rows);
})
}
fn work_row(&self, ui: &mut View<'_, Msg>) {
let Some(work) = self.state.work() else { return };
let label = crate::t!("quvyta.file-manager.copying", n = work.entries());
let stop = (self.wrap)(FileManagerMsg::Stop);
let note = work.note().to_owned();
let done = work.done();
ui.row(|ui| {
ui.add(Text::new(label).role("secondary").no_wrap());
ui.add(ProgressBar::new(done).percent(true)).width(Length::Fill(1));
if !note.is_empty() {
ui.add(Text::new(note).role("faint").no_wrap());
}
ui.add(Button::new(crate::t!("quvyta.file-manager.stop")).on_press(stop));
})
.fill_width();
}
fn tree(&self) -> Tree<Msg> {
let state = self.state;
let tree = Tree::new([self.root_node()]);
if self.disabled {
return tree;
}
let wrap = Rc::clone(&self.wrap);
let expand = Rc::clone(&self.wrap);
let choose = Rc::clone(&self.wrap);
let drop = Rc::clone(&self.wrap);
let accepts = state.folder_keys();
let tree = tree
.selected(state.selected())
.on_select(move |key| wrap(FileManagerMsg::Select(key.to_owned())))
.multi_select(state.chosen(), move |keys| choose(FileManagerMsg::Choose(keys)))
.droppable(
move |dropped| drop(FileManagerMsg::Drop(dropped)),
move |key| key == ROOT || accepts.contains(key),
)
.on_expand(move |key, open| expand(FileManagerMsg::Expand(key.to_owned(), open)))
.context_menu(self.menu_for());
match &self.on_open {
Some(open) => {
let (open, root) = (Rc::clone(open), state.root().to_path_buf());
tree.on_activate(move |key| open(&path_of(&root, key)))
}
None => tree,
}
}
fn root_node(&self) -> TreeNode {
let state = self.state;
let label = self.root_label.clone().unwrap_or_else(|| root_name(state.root()));
let mark = self.mark_of(ROOT);
let (icon, tone) = self.sign_of(&mark, "folder");
let mut root = TreeNode::new(ROOT, label).icon(icon, tone.as_deref()).faint(self.disabled || mark.is_faint());
if state.shown_children(ROOT).is_some_and(|entries| entries.is_empty()) {
root = root.detail(crate::t!("quvyta.file-manager.empty"));
}
root.expandable(true).expanded(state.is_open(ROOT)).loading(state.is_loading(ROOT)).children(self.nodes(ROOT))
}
fn nodes(&self, key: &str) -> Vec<TreeNode> {
let state = self.state;
let Some(entries) = state.shown_children(key) else { return Vec::new() };
entries
.into_iter()
.map(|entry| {
let child = child_key(key, &entry.name);
let mark = self.mark_of(&child);
let (icon, tone) = self.sign_of(&mark, if entry.folder { "folder" } else { "file" });
let faint = self.disabled || state.is_cut(&child) || mark.is_faint();
let mut node =
TreeNode::new(child.clone(), entry.name.clone()).icon(icon, tone.as_deref()).faint(faint);
if entry.folder {
let open = state.is_open(&child);
node = node.expandable(true).expanded(open).loading(state.is_loading(&child));
if state.folder_error(&child).is_some() {
node = node.detail(crate::t!("quvyta.file-manager.unreadable-short"));
}
if open {
node = node.children(self.nodes(&child));
}
}
node
})
.collect()
}
fn mark_of(&self, key: &str) -> RowMark {
self.marks.as_ref().map(|mark| mark(key)).unwrap_or_default()
}
fn sign_of(&self, mark: &RowMark, own: &'static str) -> (String, Option<String>) {
match mark.icon() {
Some(icon) => (icon.to_owned(), mark.tone().map(str::to_owned)),
None => (own.to_owned(), None),
}
}
fn menu_for(&self) -> impl Fn(&str) -> Vec<ContextItem<Msg>> + 'static {
let state = self.state;
let wrap = Rc::clone(&self.wrap);
let chosen = state.chosen().to_vec();
let pending = Pending { keys: state.pending().to_vec(), copying: state.is_copying() };
let trashing = state.is_trashing();
let folders = state.folder_keys();
let extra = self.menu.clone();
let terminal = self.on_open_terminal.clone();
let root = state.root().to_path_buf();
move |key: &str| {
let targets = state::targets_of(&chosen, key);
let mut own = extra.as_ref().map(|items| items(key, &targets)).unwrap_or_default();
if let Some(message) = &terminal
&& (key == ROOT || folders.contains(key))
{
let label = crate::t!("quvyta.file-manager.open-terminal");
own.push(ContextItem::new(label, message(&path_of(&root, key))));
}
let send = |message: FileManagerMsg| wrap(message);
if targets.len() > 1 {
return many_menu(key, targets.len(), &pending, trashing, own, &send);
}
if key == ROOT || folders.contains(key) {
return folder_menu(key, &pending, trashing, own, &send);
}
file_menu(key, &pending, trashing, own, &send)
}
}
fn naming_dialog(&self, ui: &mut View<'_, Msg>) {
let state = self.state;
let Some(naming) = state.naming() else { return };
let (title, confirm) = match &naming.purpose {
NameFor::File => (crate::t!("quvyta.file-manager.new-file-title"), crate::t!("quvyta.file-manager.create")),
NameFor::Folder => {
(crate::t!("quvyta.file-manager.new-folder-title"), crate::t!("quvyta.file-manager.create"))
}
NameFor::Rename(key) => (
crate::t!("quvyta.file-manager.rename-title", name = name_of(key)),
crate::t!("quvyta.file-manager.rename-do"),
),
};
let close = (self.wrap)(FileManagerMsg::CloseNaming);
let submit = (self.wrap)(FileManagerMsg::Submit);
let dialog = Modal::new()
.title(title)
.width(NAMING_WIDTH)
.on_close(close.clone())
.action(Button::new(crate::t!("quvyta.file-manager.cancel")).on_press(close))
.action(Button::new(confirm).variant("primary").on_press(submit.clone()));
let mut errors = FormErrors::new();
if let Some(problem) = state.naming_problem() {
errors.set(NAME_ID, problem.message());
}
let value = naming.value.clone();
let selection = match &naming.purpose {
NameFor::Rename(key) => Some(stem(&value, state.is_folder(key))),
NameFor::File | NameFor::Folder => None,
};
let typed = Rc::clone(&self.wrap);
ui.add_with(dialog, |ui| {
Form::new().show(ui, |fields| {
let label = crate::t!("quvyta.file-manager.name-label");
fields.field(Field::new(label).error(errors.get(NAME_ID)), |ui| {
let mut input = TextInput::new(value)
.invalid(errors.has(NAME_ID))
.on_change(move |value| typed(FileManagerMsg::Name(value)))
.on_submit(move |_| submit.clone());
if let Some(range) = selection {
input = input.select_on_focus(range);
}
ui.add(input).id(NAME_ID).fill_width();
});
});
});
}
}
fn path_of(root: &Path, key: &str) -> std::path::PathBuf {
key.split('/').filter(|part| !part.is_empty()).fold(root.to_path_buf(), |path, part| path.join(part))
}
fn root_name(root: &Path) -> String {
root.file_name().map_or_else(|| root.display().to_string(), |name| name.to_string_lossy().into_owned())
}
fn add_own<Msg>(items: &mut Vec<ContextItem<Msg>>, own: Vec<ContextItem<Msg>>) {
if !own.is_empty() {
items.push(ContextItem::gap());
items.extend(own);
}
}
struct Pending {
keys: Vec<String>,
copying: bool,
}
impl Pending {
fn is_empty(&self) -> bool {
self.keys.is_empty()
}
fn drop_label(&self) -> String {
let key = if self.copying { "quvyta.file-manager.drop-copy" } else { "quvyta.file-manager.drop-cut" };
crate::t!(key)
}
}
fn paste_items<Msg: Clone + 'static>(
items: &mut Vec<ContextItem<Msg>>,
key: &str,
pending: &Pending,
send: &impl Fn(FileManagerMsg) -> Msg,
) {
if pending.is_empty() {
return;
}
let paste = ContextItem::new(crate::t!("quvyta.file-manager.paste"), send(FileManagerMsg::Paste(key.to_owned())));
items.push(paste.disabled(pending.keys.iter().any(|waiting| is_within(key, waiting))));
items.push(ContextItem::new(pending.drop_label(), send(FileManagerMsg::DropCut)));
}
fn away_item<Msg: Clone + 'static>(
key: &str,
count: usize,
trashing: bool,
send: &impl Fn(FileManagerMsg) -> Msg,
) -> ContextItem<Msg> {
let many = count > 1;
let label = match (trashing, many) {
(true, false) => crate::t!("quvyta.file-manager.trash"),
(true, true) => crate::t!("quvyta.file-manager.trash-many", n = count),
(false, false) => crate::t!("quvyta.file-manager.delete"),
(false, true) => crate::t!("quvyta.file-manager.delete-many", n = count),
};
let message = if trashing {
send(FileManagerMsg::Trash(key.to_owned()))
} else {
send(FileManagerMsg::Delete(key.to_owned()))
};
ContextItem::new(label, message).danger(true)
}
fn folder_menu<Msg: Clone + 'static>(
key: &str,
pending: &Pending,
trashing: bool,
own: Vec<ContextItem<Msg>>,
send: &impl Fn(FileManagerMsg) -> Msg,
) -> Vec<ContextItem<Msg>> {
let mut items = vec![
ContextItem::new(crate::t!("quvyta.file-manager.new-file"), send(FileManagerMsg::NewFile(key.to_owned()))),
ContextItem::new(crate::t!("quvyta.file-manager.new-folder"), send(FileManagerMsg::NewFolder(key.to_owned()))),
];
let root = key == ROOT;
if !root {
items.push(ContextItem::gap());
items.push(ContextItem::new(
crate::t!("quvyta.file-manager.rename"),
send(FileManagerMsg::Rename(key.to_owned())),
));
items.push(ContextItem::new(crate::t!("quvyta.file-manager.cut"), send(FileManagerMsg::Cut(key.to_owned()))));
items.push(ContextItem::new(crate::t!("quvyta.file-manager.copy"), send(FileManagerMsg::Copy(key.to_owned()))));
}
paste_items(&mut items, key, pending, send);
add_own(&mut items, own);
items.push(ContextItem::gap());
if root {
items.push(ContextItem::new(crate::t!("quvyta.file-manager.refresh"), send(FileManagerMsg::Refresh)));
} else {
items.push(away_item(key, 1, trashing, send));
}
items
}
fn file_menu<Msg: Clone + 'static>(
key: &str,
pending: &Pending,
trashing: bool,
own: Vec<ContextItem<Msg>>,
send: &impl Fn(FileManagerMsg) -> Msg,
) -> Vec<ContextItem<Msg>> {
let mut items = vec![
ContextItem::new(crate::t!("quvyta.file-manager.rename"), send(FileManagerMsg::Rename(key.to_owned()))),
ContextItem::new(crate::t!("quvyta.file-manager.cut"), send(FileManagerMsg::Cut(key.to_owned()))),
ContextItem::new(crate::t!("quvyta.file-manager.copy"), send(FileManagerMsg::Copy(key.to_owned()))),
];
if !pending.is_empty() {
items.push(ContextItem::new(pending.drop_label(), send(FileManagerMsg::DropCut)));
}
add_own(&mut items, own);
items.push(ContextItem::gap());
items.push(away_item(key, 1, trashing, send));
items
}
fn many_menu<Msg: Clone + 'static>(
key: &str,
count: usize,
pending: &Pending,
trashing: bool,
own: Vec<ContextItem<Msg>>,
send: &impl Fn(FileManagerMsg) -> Msg,
) -> Vec<ContextItem<Msg>> {
let mut items = vec![
ContextItem::new(
crate::t!("quvyta.file-manager.cut-many", n = count),
send(FileManagerMsg::Cut(key.to_owned())),
),
ContextItem::new(
crate::t!("quvyta.file-manager.copy-many", n = count),
send(FileManagerMsg::Copy(key.to_owned())),
),
];
if !pending.is_empty() {
items.push(ContextItem::new(pending.drop_label(), send(FileManagerMsg::DropCut)));
}
add_own(&mut items, own);
items.push(ContextItem::gap());
items.push(away_item(key, count, trashing, send));
items
}