Add Compress menu option to add files to zip archive

This commit is contained in:
Nathan Rowe 2024-09-05 17:34:03 -05:00
parent a85d51acac
commit 13cd7138cc
6 changed files with 268 additions and 29 deletions

1
Cargo.lock generated
View file

@ -1271,6 +1271,7 @@ dependencies = [
"url",
"uzers",
"vergen",
"walkdir",
"xdg",
"xdg-mime",
"zip",

View file

@ -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

View file

@ -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

View file

@ -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<Entity>),
AppTheme(AppTheme),
CloseToast(widget::ToastId),
Compress(Option<Entity>),
Config(Config),
Copy(Option<Entity>),
Cut(Option<Entity>),
@ -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<PathBuf>,
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(

View file

@ -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::<Mime>().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());

View file

@ -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<ReplaceResult> for fs_extra::dir::TransitProcessResult {
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub enum Operation {
/// Compress files
Compress {
paths: Vec<PathBuf>,
to: PathBuf,
archive_type: ArchiveType,
},
/// Copy items
Copy {
paths: Vec<PathBuf>,
@ -241,6 +246,12 @@ fn paths_parent_name<'a>(paths: &'a Vec<PathBuf>) -> 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<String> {
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();