diff --git a/Cargo.lock b/Cargo.lock index c0b9064..edcf861 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1271,6 +1271,7 @@ dependencies = [ "url", "uzers", "vergen", + "walkdir", "xdg", "xdg-mime", "zip", diff --git a/Cargo.toml b/Cargo.toml index 7f9e2dd..ab555cf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,7 @@ tar = "0.4.41" tokio = { version = "1", features = ["sync"] } trash = { git = "https://github.com/jackpot51/trash-rs.git", branch = "delete-info" } url = "2.5" +walkdir = "2.5.0" xdg = { version = "2.5.2", optional = true } xdg-mime = "0.3" # Internationalization diff --git a/i18n/en/cosmic_files.ftl b/i18n/en/cosmic_files.ftl index b150ffc..0358237 100644 --- a/i18n/en/cosmic_files.ftl +++ b/i18n/en/cosmic_files.ftl @@ -15,6 +15,9 @@ size = Size # Dialogs +## Compress Dialog +create-archive = Create archive + ## Empty Trash Dialog empty-trash = Empty trash empty-trash-warning = Are you sure you want to permanently delete all the items in Trash? @@ -32,6 +35,7 @@ name-no-slashes = Name cannot contain slashes. ## Open/Save Dialog cancel = Cancel +create = Create open = Open open-file = Open file open-folder = Open folder @@ -79,6 +83,14 @@ no-history = No items in history. pending = Pending failed = Failed complete = Complete +compressing = Compressing {$items} {$items -> + [one] item + *[other] items + } from {$from} to {$to} +compressed = Compressed {$items} {$items -> + [one] item + *[other] items + } from {$from} to {$to} copy_noun = Copy creating = Creating {$name} in {$parent} created = Created {$name} in {$parent} @@ -148,8 +160,9 @@ dark = Dark light = Light # Context menu -extract-here = Extract add-to-sidebar = Add to sidebar +compress = Compress +extract-here = Extract new-file = New file... new-folder = New folder... open-in-terminal = Open in terminal diff --git a/src/app.rs b/src/app.rs index 9041498..77b4745 100644 --- a/src/app.rs +++ b/src/app.rs @@ -67,6 +67,7 @@ pub struct Flags { pub enum Action { About, AddToSidebar, + Compress, Copy, Cut, EditHistory, @@ -116,6 +117,7 @@ impl Action { match self { Action::About => Message::ToggleContextPage(ContextPage::About), Action::AddToSidebar => Message::AddToSidebar(entity_opt), + Action::Compress => Message::Compress(entity_opt), Action::Copy => Message::Copy(entity_opt), Action::Cut => Message::Cut(entity_opt), Action::EditHistory => Message::ToggleContextPage(ContextPage::EditHistory), @@ -210,6 +212,7 @@ pub enum Message { AddToSidebar(Option), AppTheme(AppTheme), CloseToast(widget::ToastId), + Compress(Option), Config(Config), Copy(Option), Cut(Option), @@ -297,8 +300,27 @@ impl ContextPage { } } +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub enum ArchiveType { + Zip, +} + +impl ArchiveType { + pub fn extension(&self) -> String { + match self { + ArchiveType::Zip => ".zip".to_string(), + } + } +} + #[derive(Clone, Debug)] pub enum DialogPage { + Compress { + paths: Vec, + to: PathBuf, + name: String, + archive_type: ArchiveType, + }, EmptyTrash, FailedOperation(u64), NewItem { @@ -1264,6 +1286,23 @@ impl Application for App { config_set!(app_theme, app_theme); return self.update_config(); } + Message::Compress(entity_opt) => { + let paths = self.selected_paths(entity_opt); + if let Some(current_path) = paths.first() { + if let Some(destination) = current_path.parent().zip(current_path.file_stem()) { + let to = destination.0.to_path_buf(); + let name = destination.1.to_str().unwrap_or_default().to_string(); + let archive_type = ArchiveType::Zip; + self.dialog_pages.push_back(DialogPage::Compress { + paths, + to, + name, + archive_type, + }); + return widget::text_input::focus(self.dialog_text_input.clone()); + } + } + } Message::Config(config) => { if config != self.config { log::info!("update config"); @@ -1291,6 +1330,21 @@ impl Application for App { Message::DialogComplete => { if let Some(dialog_page) = self.dialog_pages.pop_front() { match dialog_page { + DialogPage::Compress { + paths, + to, + name, + archive_type, + } => { + let extension = archive_type.extension(); + let name = format!("{}{}", name, extension); + let to = to.join(name); + self.operation(Operation::Compress { + paths, + to, + archive_type, + }) + } DialogPage::EmptyTrash => { self.operation(Operation::EmptyTrash); } @@ -2323,6 +2377,74 @@ impl Application for App { let cosmic_theme::Spacing { space_xxs, .. } = theme::active().cosmic().spacing; let dialog = match dialog_page { + DialogPage::Compress { + paths, + to, + name, + archive_type, + } => { + let mut dialog = widget::dialog(fl!("create-archive")); + + let complete_maybe = if name.is_empty() { + None + } else if name == "." || name == ".." { + dialog = dialog.tertiary_action(widget::text::body(fl!( + "name-invalid", + filename = name.as_str() + ))); + None + } else if name.contains('/') { + dialog = dialog.tertiary_action(widget::text::body(fl!("name-no-slashes"))); + None + } else { + let extension = archive_type.extension(); + let name = format!("{}{}", name, extension); + let path = to.join(&name); + if path.exists() { + dialog = + dialog.tertiary_action(widget::text::body(fl!("file-already-exists"))); + None + } else { + if name.starts_with('.') { + dialog = dialog.tertiary_action(widget::text::body(fl!("name-hidden"))); + } + Some(Message::DialogComplete) + } + }; + + dialog + .primary_action( + widget::button::suggested(fl!("create")) + .on_press_maybe(complete_maybe.clone()), + ) + .secondary_action( + widget::button::standard(fl!("cancel")).on_press(Message::DialogCancel), + ) + .control( + widget::column::with_children(vec![ + widget::text::body(fl!("file-name")).into(), + widget::row::with_children(vec![ + widget::text_input("", name.as_str()) + .id(self.dialog_text_input.clone()) + .on_input(move |name| { + Message::DialogUpdate(DialogPage::Compress { + paths: paths.clone(), + to: to.clone(), + name: name.clone(), + archive_type: archive_type.clone(), + }) + }) + .on_submit_maybe(complete_maybe) + .into(), + widget::text::body(".zip").into(), + ]) + .align_items(Alignment::Center) + .spacing(space_xxs) + .into(), + ]) + .spacing(space_xxs), + ) + } DialogPage::EmptyTrash => widget::dialog(fl!("empty-trash")) .body(fl!("empty-trash-warning")) .primary_action( diff --git a/src/menu.rs b/src/menu.rs index 68b08ba..adae6ad 100644 --- a/src/menu.rs +++ b/src/menu.rs @@ -125,6 +125,7 @@ pub fn context_menu<'a>( children.push(menu_item(fl!("cut"), Action::Cut).into()); children.push(menu_item(fl!("copy"), Action::Copy).into()); + children.push(container(horizontal_rule(1)).padding([0, 8]).into()); let supported_archive_types = ["application/x-tar", "application/zip"] .iter() .filter_map(|mime_type| mime_type.parse::().ok()) @@ -133,6 +134,8 @@ pub fn context_menu<'a>( if selected_types.is_empty() { children.push(menu_item(fl!("extract-here"), Action::ExtractHere).into()); } + children.push(menu_item(fl!("compress"), Action::Compress).into()); + children.push(container(horizontal_rule(1)).padding([0, 8]).into()); //TODO: Print? children.push(container(horizontal_rule(1)).padding([0, 8]).into()); diff --git a/src/operation.rs b/src/operation.rs index 37bdd42..4d7afdb 100644 --- a/src/operation.rs +++ b/src/operation.rs @@ -1,20 +1,19 @@ use cosmic::iced::futures::{channel::mpsc::Sender, executor, SinkExt}; -use mime_guess::MimeGuess; use std::{ borrow::Cow, fs, - io::{self, Error}, + io::{self, Read, Write}, path::{Path, PathBuf}, - process, sync::{ atomic::{self, AtomicU64}, Arc, }, }; use tokio::sync::{mpsc, Mutex}; +use walkdir::WalkDir; use crate::{ - app::{DialogPage, Message}, + app::{ArchiveType, DialogPage, Message}, config::IconSizes, fl, tab, }; @@ -130,6 +129,12 @@ impl From for fs_extra::dir::TransitProcessResult { #[derive(Clone, Debug, Eq, Hash, PartialEq)] pub enum Operation { + /// Compress files + Compress { + paths: Vec, + to: PathBuf, + archive_type: ArchiveType, + }, /// Copy items Copy { paths: Vec, @@ -241,6 +246,12 @@ fn paths_parent_name<'a>(paths: &'a Vec) -> Cow<'a, str> { impl Operation { pub fn pending_text(&self) -> String { match self { + Self::Compress { paths, to, .. } => fl!( + "compressing", + items = paths.len(), + from = paths_parent_name(paths), + to = file_name(to) + ), Self::Copy { paths, to } => fl!( "copying", items = paths.len(), @@ -285,6 +296,12 @@ impl Operation { pub fn completed_text(&self) -> String { match self { + Self::Compress { paths, to, .. } => fl!( + "compressed", + items = paths.len(), + from = paths_parent_name(paths), + to = file_name(to) + ), Self::Copy { paths, to } => fl!( "copied", items = paths.len(), @@ -327,7 +344,9 @@ impl Operation { pub fn toast(&self) -> Option { match self { + Self::Compress { .. } => Some(self.completed_text()), Self::Delete { .. } => Some(self.completed_text()), + Self::Extract { .. } => Some(self.completed_text()), //TODO: more toasts _ => None, } @@ -348,6 +367,79 @@ impl Operation { //TODO: IF ERROR, RETURN AN Operation THAT CAN UNDO THE CURRENT STATE //TODO: SAFELY HANDLE CANCEL match self { + Self::Compress { + paths, + to, + archive_type, + } => { + let msg_tx = msg_tx.clone(); + tokio::task::spawn_blocking(move || -> Result<(), String> { + match archive_type { + ArchiveType::Zip => { + let mut archive = fs::File::create(&to) + .map(io::BufWriter::new) + .map(zip::ZipWriter::new) + .map_err(err_str)?; + + let zip_options = zip::write::SimpleFileOptions::default(); + + let mut paths = paths; + for path in paths.clone().iter() { + if path.is_dir() { + let new_paths_it = WalkDir::new(path).into_iter(); + for entry in new_paths_it.skip(1) { + let entry = entry.map_err(err_str)?; + paths.push(entry.path().to_path_buf()); + } + } + } + + let total_paths = paths.len(); + for (i, path) in paths.iter().enumerate() { + executor::block_on(async { + let total_progress = (i as f32) / total_paths as f32; + let _ = msg_tx + .lock() + .await + .send(Message::PendingProgress(id, 100.0 * total_progress)) + .await; + }); + + if let Some(relative_root) = to.parent() { + if let Some(relative_path) = + path.strip_prefix(relative_root).map_err(err_str)?.to_str() + { + if path.is_file() { + archive + .start_file(relative_path, zip_options) + .map_err(err_str)?; + + let mut buffer = Vec::new(); + let mut file = fs::File::open(&path) + .map(io::BufReader::new) + .map_err(err_str)?; + + file.read_to_end(&mut buffer).map_err(err_str)?; + archive.write_all(&buffer).map_err(err_str)?; + } else { + archive + .add_directory(relative_path, zip_options) + .map_err(err_str)?; + } + } + } + } + + archive.finish().map_err(err_str)?; + } + } + + Ok(()) + }) + .await + .map_err(err_str)? + .map_err(err_str)?; + } Self::Copy { paths, to } => { // Handle duplicate file names by renaming paths let (paths, to): (Vec<_>, Vec<_>) = tokio::task::spawn_blocking(move || { @@ -494,10 +586,21 @@ impl Operation { .await; } Self::Extract { paths, to } => { - for path in paths { - let to = to.to_owned(); + let msg_tx = msg_tx.clone(); + tokio::task::spawn_blocking(move || -> Result<(), String> { + let total_paths = paths.len(); + for (i, path) in paths.iter().enumerate() { + executor::block_on(async { + let total_progress = (i as f32) / total_paths as f32; + let _ = msg_tx + .lock() + .await + .send(Message::PendingProgress(id, 100.0 * total_progress)) + .await; + }); + + let to = to.to_owned(); - tokio::task::spawn_blocking(move || -> Result<(), String> { if let Some(file_stem) = path.file_stem() { let mut new_dir = to.join(file_stem); if new_dir.exists() { @@ -510,32 +613,28 @@ impl Operation { if let Some(mime) = mime_guess::from_path(&path).first() { match mime.essence_str() { - "application/x-tar" => { - return fs::File::open(path) - .map(io::BufReader::new) - .map(tar::Archive::new) - .and_then(|mut archive| archive.unpack(new_dir)) - .map_err(err_str) - } - "application/zip" => { - return fs::File::open(path) - .map(io::BufReader::new) - .map(zip::ZipArchive::new) - .map_err(err_str)? - .and_then(|mut archive| archive.extract(new_dir)) - .map_err(err_str) - } + "application/x-tar" => fs::File::open(path) + .map(io::BufReader::new) + .map(tar::Archive::new) + .and_then(|mut archive| archive.unpack(new_dir)) + .map_err(err_str)?, + "application/zip" => fs::File::open(path) + .map(io::BufReader::new) + .map(zip::ZipArchive::new) + .map_err(err_str)? + .and_then(|mut archive| archive.extract(new_dir)) + .map_err(err_str)?, _ => Err(format!("unsupported mime type {:?}", mime))?, } } } + } - Ok(()) - }) - .await - .map_err(err_str)? - .map_err(err_str)?; - } + Ok(()) + }) + .await + .map_err(err_str)? + .map_err(err_str)?; } Self::Move { paths, to } => { let msg_tx = msg_tx.clone();