Implement history, show operations, implement trash

This commit is contained in:
Jeremy Soller 2024-01-30 10:47:41 -07:00
parent 004fd617ea
commit 12a2a39a9f
No known key found for this signature in database
GPG key ID: D02FD439211AF56F
6 changed files with 344 additions and 119 deletions

View file

@ -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<segmented_button::Entity>),
NewFolder(Option<segmented_button::Entity>),
Paste(Option<segmented_button::Entity>),
PendingComplete(u64),
PendingError(u64, String),
PendingProgress(u64, f32),
RestoreFromTrash(Option<segmented_button::Entity>),
SelectAll(Option<segmented_button::Entity>),
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<KeyBind, Action>,
modifiers: Modifiers,
pending_operation_id: u64,
pending_operations: BTreeMap<u64, (Operation, f32)>,
complete_operations: BTreeMap<u64, Operation>,
failed_operations: BTreeMap<u64, (Operation, String)>,
}
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<Message> {
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<Message> {
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::<Tab>(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::<Tab>(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<Element<Self::Message>> {
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<Element<Self::Message>> {
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)
}
}

View file

@ -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<KeyBind, Action>) -> 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),
],
),
])

View file

@ -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<T: ToString>(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<PathBuf>, to: PathBuf },
/// Move items to the trash
Delete { paths: Vec<PathBuf> },
/// Move items
Move { paths: Vec<PathBuf>, to: PathBuf },
/// Restore a path from the trash
Restore { path: PathBuf },
Restore { paths: Vec<trash::TrashItem> },
}
impl Operation {
pub fn delete(path: impl Into<PathBuf>) -> Self {
Self::Delete { path: path.into() }
}
/// Perform the operation
pub async fn perform(self, id: u64, msg_tx: &mut mpsc::Sender<Message>) -> Result<(), String> {
msg_tx.send(Message::PendingProgress(id, 0.0)).await;
pub fn rename(old: impl Into<PathBuf>, new: impl Into<PathBuf>) -> Self {
Self::Rename {
old: old.into(),
new: new.into(),
}
}
pub fn restore(path: impl Into<PathBuf>) -> 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(())
}
}

View file

@ -255,7 +255,7 @@ pub fn scan_path(tab_path: &PathBuf) -> Vec<Item> {
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<Item> {
};
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<Item> {
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<usize>),
EditLocation(Option<Location>),
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<Vec<Item>>,
pub view: View,
pub edit_location: Option<Location>,
pub history_i: usize,
pub history: Vec<Location>,
}
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<Message> {
pub fn location_view(&self, core: &Core) -> Element<Message> {
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<Element<_>> = 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<Message> {
@ -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<Element<_>> = 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::<chrono::Local>::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 {