diff --git a/i18n/en/cosmic_files.ftl b/i18n/en/cosmic_files.ftl index 3477827..49dcd98 100644 --- a/i18n/en/cosmic_files.ftl +++ b/i18n/en/cosmic_files.ftl @@ -11,6 +11,12 @@ size = Size # Context Pages +## Operations +operations = Operations +pending = Pending +failed = Failed +complete = Complete + ## Properties properties = Properties diff --git a/justfile b/justfile index 6c71d5f..0eb89eb 100644 --- a/justfile +++ b/justfile @@ -46,9 +46,17 @@ check *args: # Runs a clippy check with JSON message format check-json: (check '--message-format=json') +# Developer target +dev *args: + cargo fmt + cargo test + cargo build --profile release-with-debug + env RUST_LOG=cosmic_files=debug RUST_BACKTRACE=full target/release-with-debug/cosmic-files {{args}} + # Run with debug logs run *args: - env RUST_LOG=cosmic_files=debug RUST_BACKTRACE=full cargo run --release {{args}} + cargo build --release + env RUST_LOG=cosmic_files=debug RUST_BACKTRACE=full target/release/cosmic-files {{args}} # Installs files install: diff --git a/src/main.rs b/src/main.rs index e8aac8a..3e177f9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,15 +7,22 @@ use cosmic::{ cosmic_theme, executor, iced::{ event, + futures::SinkExt, keyboard::{Event as KeyEvent, KeyCode, Modifiers}, - subscription::Subscription, + subscription::{self, Subscription}, window, Event, Length, Point, }, style, widget::{self, segmented_button}, Application, ApplicationExt, Element, }; -use std::{any::TypeId, env, fs, path::PathBuf, process, collections::HashMap}; +use std::{ + any::TypeId, + collections::{BTreeMap, HashMap}, + env, fs, io, + path::PathBuf, + process, time, +}; use config::{AppTheme, Config, CONFIG_VERSION}; mod config; @@ -31,9 +38,10 @@ mod mouse_area; mod mime_icon; +use operation::Operation; mod operation; -use tab::{Location, Tab}; +use tab::{ItemMetadata, Location, Tab}; mod tab; /// Runs application with these settings @@ -146,8 +154,12 @@ impl Action { Action::TabNew => Message::TabNew, Action::TabNext => Message::TabNext, Action::TabPrev => Message::TabPrev, - Action::TabViewGrid => Message::TabMessage(entity_opt, tab::Message::View(tab::View::Grid)), - Action::TabViewList => Message::TabMessage(entity_opt, tab::Message::View(tab::View::List)), + Action::TabViewGrid => { + Message::TabMessage(entity_opt, tab::Message::View(tab::View::Grid)) + } + Action::TabViewList => { + Message::TabMessage(entity_opt, tab::Message::View(tab::View::List)) + } Action::WindowClose => Message::WindowClose, Action::WindowNew => Message::WindowNew, } @@ -168,6 +180,9 @@ pub enum Message { NewFile(Option), NewFolder(Option), Paste(Option), + PendingComplete(u64), + PendingError(u64, String), + PendingProgress(u64, f32), RestoreFromTrash(Option), SelectAll(Option), SystemThemeModeChange(cosmic_theme::ThemeMode), @@ -187,6 +202,7 @@ pub enum Message { #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ContextPage { + Operations, Properties, Settings, } @@ -194,6 +210,7 @@ pub enum ContextPage { impl ContextPage { fn title(&self) -> String { match self { + Self::Operations => fl!("operations"), Self::Properties => fl!("properties"), Self::Settings => fl!("settings"), } @@ -211,6 +228,10 @@ pub struct App { context_page: ContextPage, key_binds: HashMap, modifiers: Modifiers, + pending_operation_id: u64, + pending_operations: BTreeMap, + complete_operations: BTreeMap, + failed_operations: BTreeMap, } impl App { @@ -227,6 +248,15 @@ impl App { Command::batch([self.update_title(), self.rescan_tab(entity, location)]) } + fn operation(&mut self, operation: Operation) { + let id = self.pending_operation_id; + self.pending_operation_id += 1; + self.pending_operations.insert(id, (operation, 0.0)); + //TODO: have some button to show current status + self.core.window.show_context = true; + self.context_page = ContextPage::Operations; + } + fn rescan_tab( &mut self, entity: segmented_button::Entity, @@ -275,6 +305,47 @@ impl App { self.set_window_title(window_title) } + fn operations(&self) -> Element { + let mut children = Vec::new(); + + //TODO: get height from theme? + let progress_bar_height = Length::Fixed(4.0); + + if !self.pending_operations.is_empty() { + let mut section = widget::settings::view_section(fl!("pending")); + for (id, (op, progress)) in self.pending_operations.iter() { + section = section.add(widget::column::with_children(vec![ + widget::text(format!("{:?}", op)).into(), + widget::progress_bar(0.0..=100.0, *progress) + .height(progress_bar_height) + .into(), + ])); + } + children.push(section.into()); + } + + if !self.failed_operations.is_empty() { + let mut section = widget::settings::view_section(fl!("failed")); + for (id, (op, error)) in self.failed_operations.iter() { + section = section.add(widget::column::with_children(vec![ + widget::text(format!("{:?}", op)).into(), + widget::text(error).into(), + ])); + } + children.push(section.into()); + } + + if !self.complete_operations.is_empty() { + let mut section = widget::settings::view_section(fl!("complete")); + for (id, op) in self.complete_operations.iter() { + section = section.add(widget::text(format!("{:?}", op))); + } + children.push(section.into()); + } + + widget::settings::view_column(children).into() + } + fn properties(&self) -> Element { let mut children = Vec::new(); let entity = self.tab_model.active(); @@ -388,6 +459,10 @@ impl Application for App { context_page: ContextPage::Settings, key_binds: key_binds(), modifiers: Modifiers::empty(), + pending_operation_id: 0, + pending_operations: BTreeMap::new(), + complete_operations: BTreeMap::new(), + failed_operations: BTreeMap::new(), }; let mut commands = Vec::new(); @@ -507,7 +582,20 @@ impl Application for App { self.modifiers = modifiers; } Message::MoveToTrash(entity_opt) => { - log::warn!("TODO: MOVE TO TRASH"); + let mut paths = Vec::new(); + let entity = entity_opt.unwrap_or_else(|| self.tab_model.active()); + if let Some(tab) = self.tab_model.data_mut::(entity) { + if let Some(ref mut items) = tab.items_opt { + for item in items.iter_mut() { + if item.selected { + paths.push(item.path.clone()); + } + } + } + } + if !paths.is_empty() { + self.operation(Operation::Delete { paths }); + } } Message::NewFile(entity_opt) => { log::warn!("TODO: NEW FILE"); @@ -518,8 +606,43 @@ impl Application for App { Message::Paste(entity_opt) => { log::warn!("TODO: PASTE"); } + Message::PendingComplete(id) => { + if let Some((op, _)) = self.pending_operations.remove(&id) { + self.complete_operations.insert(id, op); + } + } + Message::PendingError(id, err) => { + if let Some((op, _)) = self.pending_operations.remove(&id) { + self.failed_operations.insert(id, (op, err)); + } + } + Message::PendingProgress(id, new_progress) => { + if let Some((_, progress)) = self.pending_operations.get_mut(&id) { + *progress = new_progress; + } + } Message::RestoreFromTrash(entity_opt) => { - log::warn!("TODO: RESTORE FROM TRASH"); + let mut paths = Vec::new(); + let entity = entity_opt.unwrap_or_else(|| self.tab_model.active()); + if let Some(tab) = self.tab_model.data_mut::(entity) { + if let Some(ref mut items) = tab.items_opt { + for item in items.iter_mut() { + if item.selected { + match &item.metadata { + ItemMetadata::Trash { entry, .. } => { + paths.push(entry.clone()); + } + _ => { + //TODO: error on trying to restore non-trash file? + } + } + } + } + } + } + if !paths.is_empty() { + self.operation(Operation::Restore { paths }); + } } Message::SelectAll(entity_opt) => { let entity = entity_opt.unwrap_or_else(|| self.tab_model.active()); @@ -692,24 +815,18 @@ impl Application for App { } Some(match self.context_page { + ContextPage::Operations => self.operations(), ContextPage::Properties => self.properties(), ContextPage::Settings => self.settings(), }) } fn header_start(&self) -> Vec> { - vec![ - menu::menu_bar(&self.key_binds).into(), - //TODO: use theme defined space? - widget::horizontal_space(Length::Fixed(32.0)).into(), - ] + vec![menu::menu_bar(&self.key_binds).into()] } fn header_end(&self) -> Vec> { - vec![ - //TODO: use defined space - widget::horizontal_space(Length::Fixed(32.0)).into(), - ] + vec![] } /// Creates a view after each update. @@ -778,14 +895,12 @@ impl Application for App { struct ConfigSubscription; struct ThemeSubscription; - Subscription::batch([ + let mut subscriptions = vec![ event::listen_with(|event, _status| match event { Event::Keyboard(KeyEvent::KeyPressed { key_code, modifiers, - }) => { - Some(Message::Key(modifiers, key_code)) - } + }) => Some(Message::Key(modifiers, key_code)), Event::Keyboard(KeyEvent::ModifiersChanged(modifiers)) => { Some(Message::Modifiers(modifiers)) } @@ -821,6 +936,34 @@ impl Application for App { } Message::SystemThemeModeChange(update.config) }), - ]) + ]; + + for (id, (pending_operation, _)) in self.pending_operations.iter() { + //TODO: use recipe? + let id = *id; + let pending_operation = pending_operation.clone(); + subscriptions.push(subscription::channel( + id, + 16, + move |mut msg_tx| async move { + match pending_operation.perform(id, &mut msg_tx).await { + Ok(()) => { + msg_tx.send(Message::PendingComplete(id)).await; + } + Err(err) => { + msg_tx + .send(Message::PendingError(id, err.to_string())) + .await; + } + } + + loop { + tokio::time::sleep(time::Duration::new(1, 0)).await; + } + }, + )); + } + + Subscription::batch(subscriptions) } } diff --git a/src/menu.rs b/src/menu.rs index 3281620..7f19ed9 100644 --- a/src/menu.rs +++ b/src/menu.rs @@ -13,7 +13,7 @@ use cosmic::{ }; use std::collections::HashMap; -use crate::{fl, KeyBind, tab, Action, ContextPage, Location, Message, Tab}; +use crate::{fl, tab, Action, ContextPage, KeyBind, Location, Message, Tab}; macro_rules! menu_button { ($($x:expr),+ $(,)?) => ( @@ -144,19 +144,10 @@ pub fn menu_bar<'a>(key_binds: &HashMap) -> Element<'a, Message MenuTree::with_children( menu_root(fl!("view")), vec![ - menu_item( - fl!("grid-view"), - Action::TabViewGrid - ), - menu_item( - fl!("list-view"), - Action::TabViewList - ), + menu_item(fl!("grid-view"), Action::TabViewGrid), + menu_item(fl!("list-view"), Action::TabViewList), MenuTree::new(horizontal_rule(1)), - menu_item( - fl!("menu-settings"), - Action::Settings, - ), + menu_item(fl!("menu-settings"), Action::Settings), ], ), ]) diff --git a/src/operation.rs b/src/operation.rs index 1842ef9..4743f09 100644 --- a/src/operation.rs +++ b/src/operation.rs @@ -1,57 +1,73 @@ -use std::path::PathBuf; +use cosmic::iced::futures::{channel::mpsc, SinkExt}; +use std::{error::Error, future::Future, io, path::PathBuf, time}; -#[derive(Clone, Debug, Eq, PartialEq)] +use crate::Message; + +fn err_str(err: T) -> String { + err.to_string() +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] pub enum Operation { - /// Move a path to the trash - Delete { path: PathBuf }, - /// Rename a path - Rename { old: PathBuf, new: PathBuf }, + /// Copy items + Copy { paths: Vec, to: PathBuf }, + /// Move items to the trash + Delete { paths: Vec }, + /// Move items + Move { paths: Vec, to: PathBuf }, /// Restore a path from the trash - Restore { path: PathBuf }, + Restore { paths: Vec }, } impl Operation { - pub fn delete(path: impl Into) -> Self { - Self::Delete { path: path.into() } - } + /// Perform the operation + pub async fn perform(self, id: u64, msg_tx: &mut mpsc::Sender) -> Result<(), String> { + msg_tx.send(Message::PendingProgress(id, 0.0)).await; - pub fn rename(old: impl Into, new: impl Into) -> Self { - Self::Rename { - old: old.into(), - new: new.into(), - } - } - - pub fn restore(path: impl Into) -> Self { - Self::Restore { path: path.into() } - } - - pub fn reverse(self) -> Self { + //TODO: IF ERROR, RETURN AN Operation THAT CAN UNDO THE CURRENT STATE + //TODO: SAFELY HANDLE CANCEL match self { - Self::Delete { path } => Self::Restore { path }, - Self::Rename { old, new } => Self::Rename { old: new, new: old }, - Self::Restore { path } => Self::Delete { path }, + Self::Delete { paths } => { + let mut total = paths.len(); + let mut count = 0; + for path in paths { + tokio::task::spawn_blocking(|| trash::delete(path)) + .await + .map_err(err_str)? + .map_err(err_str)?; + count += 1; + msg_tx + .send(Message::PendingProgress( + id, + 100.0 * (count as f32) / (total as f32), + )) + .await; + } + } + Self::Restore { paths } => { + let mut total = paths.len(); + let mut count = 0; + for path in paths { + tokio::task::spawn_blocking(|| trash::os_limited::restore_all([path])) + .await + .map_err(err_str)? + .map_err(err_str)?; + count += 1; + msg_tx + .send(Message::PendingProgress( + id, + 100.0 * (count as f32) / (total as f32), + )) + .await; + } + } + _ => { + return Err("not implemented".to_string()); + } } - } -} - -#[cfg(test)] -mod tests { - use super::Operation; - - #[test] - fn operation() { - assert_eq!( - Operation::delete("foo").reverse(), - Operation::restore("foo") - ); - assert_eq!( - Operation::rename("foo", "bar").reverse(), - Operation::rename("bar", "foo") - ); - assert_eq!( - Operation::restore("foo").reverse(), - Operation::delete("foo") - ); + + msg_tx.send(Message::PendingProgress(id, 100.0)).await; + + Ok(()) } } diff --git a/src/tab.rs b/src/tab.rs index eb99b18..0f72dc8 100644 --- a/src/tab.rs +++ b/src/tab.rs @@ -255,7 +255,7 @@ pub fn scan_path(tab_path: &PathBuf) -> Vec { items.push(Item { name, - metadata: ItemMetadata::Path(metadata, children), + metadata: ItemMetadata::Path { metadata, children }, hidden, path, icon_handle_grid, @@ -316,7 +316,7 @@ pub fn scan_trash() -> Vec { }; let path = entry.original_path(); - let name = entry.name; + let name = entry.name.clone(); //TODO: configurable size let (icon_handle_grid, icon_handle_list) = match metadata.size { @@ -332,7 +332,7 @@ pub fn scan_trash() -> Vec { items.push(Item { name, - metadata: ItemMetadata::Trash(metadata), + metadata: ItemMetadata::Trash { metadata, entry }, hidden: false, path, icon_handle_grid, @@ -373,6 +373,8 @@ impl Location { pub enum Message { Click(Option), EditLocation(Option), + GoNext, + GoPrevious, Location(Location), RightClick(usize), View(View), @@ -380,15 +382,21 @@ pub enum Message { #[derive(Clone, Debug)] pub enum ItemMetadata { - Path(Metadata, usize), - Trash(trash::TrashItemMetadata), + Path { + metadata: Metadata, + children: usize, + }, + Trash { + metadata: trash::TrashItemMetadata, + entry: trash::TrashItem, + }, } impl ItemMetadata { pub fn is_dir(&self) -> bool { match self { - Self::Path(metadata, _) => metadata.is_dir(), - Self::Trash(metadata) => match metadata.size { + Self::Path { metadata, .. } => metadata.is_dir(), + Self::Trash { metadata, .. } => match metadata.size { trash::TrashItemSize::Entries(_) => true, trash::TrashItemSize::Bytes(_) => false, }, @@ -421,7 +429,7 @@ impl Item { //TODO: translate! //TODO: correct display of folder size? match &self.metadata { - ItemMetadata::Path(metadata, children) => { + ItemMetadata::Path { metadata, children } => { if metadata.is_dir() { section = section.add(widget::settings::item::item( "Items", @@ -467,7 +475,7 @@ impl Item { )); } } - ItemMetadata::Trash(_metadata) => { + ItemMetadata::Trash { .. } => { //TODO: trash metadata } } @@ -504,16 +512,21 @@ pub struct Tab { pub items_opt: Option>, pub view: View, pub edit_location: Option, + pub history_i: usize, + pub history: Vec, } impl Tab { pub fn new(location: Location) -> Self { + let history = vec![location.clone()]; Self { location, context_menu: None, items_opt: None, view: View::List, edit_location: None, + history_i: 0, + history, } } @@ -531,6 +544,7 @@ impl Tab { pub fn update(&mut self, message: Message, modifiers: Modifiers) -> bool { let mut cd = None; + let mut history_i_opt = None; match message { Message::Click(click_i_opt) => { if let Some(ref mut items) = self.items_opt { @@ -579,6 +593,22 @@ impl Tab { Message::EditLocation(edit_location) => { self.edit_location = edit_location; } + Message::GoNext => { + if let Some(history_i) = self.history_i.checked_add(1) { + if let Some(location) = self.history.get(history_i) { + cd = Some(location.clone()); + history_i_opt = Some(history_i); + } + } + } + Message::GoPrevious => { + if let Some(history_i) = self.history_i.checked_sub(1) { + if let Some(location) = self.history.get(history_i) { + cd = Some(location.clone()); + history_i_opt = Some(history_i); + } + } + } Message::Location(location) => { cd = Some(location); } @@ -603,11 +633,22 @@ impl Tab { self.view = view; } } - if let Some(location) = cd { + if let Some(mut location) = cd { if location != self.location { - self.location = location; + self.location = location.clone(); self.items_opt = None; self.edit_location = None; + if let Some(history_i) = history_i_opt { + // Navigating in history + self.history_i = history_i; + } else { + // Truncate history to remove next entries + self.history.truncate(self.history_i + 1); + + // Push to the front of history + self.history_i = self.history.len(); + self.history.push(location); + } true } else { false @@ -617,36 +658,64 @@ impl Tab { } } - pub fn breadcrumbs_view(&self, core: &Core) -> Element { + pub fn location_view(&self, core: &Core) -> Element { let cosmic_theme::Spacing { space_xxxs, space_xxs, + space_s, .. } = core.system_theme().cosmic().spacing; + let mut row = widget::row::with_capacity(5).align_items(Alignment::Center); + + let mut prev_button = + widget::button(widget::icon::from_name("go-previous-symbolic").size(16)) + .padding(space_xxs) + .style(theme::Button::Icon); + if self.history_i > 0 && !self.history.is_empty() { + prev_button = prev_button.on_press(Message::GoPrevious); + } + row = row.push(prev_button); + + let mut next_button = widget::button(widget::icon::from_name("go-next-symbolic").size(16)) + .padding(space_xxs) + .style(theme::Button::Icon); + if self.history_i + 1 < self.history.len() { + next_button = next_button.on_press(Message::GoNext); + } + row = row.push(next_button); + + row = row.push(widget::horizontal_space(Length::Fixed(space_s.into()))); + if let Some(location) = &self.edit_location { match location { Location::Path(path) => { - return widget::row::with_children(vec![ + row = row.push( widget::button(widget::icon::from_name("window-close-symbolic").size(16)) .on_press(Message::EditLocation(None)) .padding(space_xxs) - .style(theme::Button::Icon) - .into(), + .style(theme::Button::Icon), + ); + row = row.push( widget::text_input("", path.to_string_lossy()) .on_input(|input| { Message::EditLocation(Some(Location::Path(PathBuf::from(input)))) }) - .on_submit(Message::Location(location.clone())) - .into(), - ]) - .align_items(Alignment::Center) - .into(); + .on_submit(Message::Location(location.clone())), + ); + return row.into(); } _ => { //TODO: allow editing other locations } } + } else { + row = row.push( + widget::button(widget::icon::from_name("edit-symbolic").size(16)) + .on_press(Message::EditLocation(Some(self.location.clone()))) + .padding(space_xxs) + .style(theme::Button::Icon), + ); } let mut children: Vec> = Vec::new(); @@ -726,18 +795,10 @@ impl Tab { } } - children.insert( - 0, - widget::button(widget::icon::from_name("edit-symbolic").size(16)) - .on_press(Message::EditLocation(Some(self.location.clone()))) - .padding(space_xxs) - .style(theme::Button::Icon) - .into(), - ); - - widget::row::with_children(children) - .align_items(Alignment::Center) - .into() + for child in children { + row = row.push(child); + } + row.into() } pub fn empty_view(&self, has_hidden: bool, core: &Core) -> Element { @@ -815,7 +876,7 @@ impl Tab { } } widget::scrollable(widget::column::with_children(vec![ - self.breadcrumbs_view(core), + self.location_view(core), widget::flex_row(children).into(), ])) .width(Length::Fill) @@ -830,7 +891,7 @@ impl Tab { let mut children: Vec> = Vec::new(); - children.push(self.breadcrumbs_view(core)); + children.push(self.location_view(core)); children.push( widget::row::with_children(vec![ @@ -868,24 +929,24 @@ impl Tab { } let modified_text = match &item.metadata { - ItemMetadata::Path(metadata, _children) => match metadata.modified() { + ItemMetadata::Path { metadata, .. } => match metadata.modified() { Ok(time) => chrono::DateTime::::from(time) .format("%c") .to_string(), Err(_) => String::new(), }, - ItemMetadata::Trash(metadata) => String::new(), + ItemMetadata::Trash { .. } => String::new(), }; let size_text = match &item.metadata { - ItemMetadata::Path(metadata, children) => { + ItemMetadata::Path { metadata, children } => { if metadata.is_dir() { format!("{} items", children) } else { format_size(metadata.len()) } } - ItemMetadata::Trash(metadata) => match metadata.size { + ItemMetadata::Trash { metadata, .. } => match metadata.size { trash::TrashItemSize::Entries(entries) => { //TODO: translate if entries == 1 {