From 1ba5be1116d369fbfd05d551fc3c0827db32e850 Mon Sep 17 00:00:00 2001 From: Jeremy Soller Date: Fri, 5 Jan 2024 16:17:23 -0700 Subject: [PATCH] Partial trash implementation --- Cargo.lock | 17 +++ Cargo.toml | 1 + i18n/en/cosmic_files.ftl | 2 + src/main.rs | 60 +++++++--- src/menu.rs | 3 + src/tab.rs | 232 +++++++++++++++++++++++++++------------ 6 files changed, 228 insertions(+), 87 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 39f3b64..c966e14 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -980,6 +980,7 @@ dependencies = [ "serde", "systemicons", "tokio", + "trash", ] [[package]] @@ -4999,6 +5000,22 @@ dependencies = [ "once_cell", ] +[[package]] +name = "trash" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c646008e5144d988005bec12b1e56f5e0a951e957176686815eba8b025e0418" +dependencies = [ + "chrono", + "libc", + "log", + "objc", + "once_cell", + "scopeguard", + "url", + "windows 0.44.0", +] + [[package]] name = "ttf-parser" version = "0.15.2" diff --git a/Cargo.toml b/Cargo.toml index 2a9689b..a1aadad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ lexical-sort = "0.3.1" log = "0.4" serde = { version = "1", features = ["serde_derive"] } tokio = { version = "1" } +trash = "3.1.2" # Internationalization i18n-embed = { version = "0.13", features = ["fluent-system", "desktop-requester"] } i18n-embed-fl = "0.6" diff --git a/i18n/en/cosmic_files.ftl b/i18n/en/cosmic_files.ftl index 6e997e7..ede272f 100644 --- a/i18n/en/cosmic_files.ftl +++ b/i18n/en/cosmic_files.ftl @@ -1,5 +1,6 @@ empty-folder = Empty folder empty-folder-hidden = Empty folder (has hidden items) +trash = Trash # Context Pages @@ -22,3 +23,4 @@ new-folder = New folder copy = Copy paste = Paste select-all = Select all +move-to-trash = Move to trash diff --git a/src/main.rs b/src/main.rs index a48ff49..5c09f78 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,7 +10,13 @@ use cosmic::{ widget::{self, segmented_button}, Application, ApplicationExt, Element, }; -use std::{any::TypeId, env, path::PathBuf, process, time::Instant}; +use std::{ + any::TypeId, + env, fs, + path::{Path, PathBuf}, + process, + time::Instant, +}; use config::{AppTheme, Config, CONFIG_VERSION}; mod config; @@ -23,7 +29,7 @@ mod localize; mod mime_icon; -use tab::Tab; +use tab::{Location, Tab}; mod tab; /// Runs application with these settings @@ -100,6 +106,7 @@ pub struct Flags { #[derive(Clone, Copy, Debug)] pub enum Action { Copy, + MoveToTrash, NewFile, NewFolder, Paste, @@ -113,6 +120,7 @@ impl Action { pub fn message(self, entity: segmented_button::Entity) -> Message { match self { Action::Copy => Message::Copy(Some(entity)), + Action::MoveToTrash => Message::MoveToTrash(Some(entity)), Action::NewFile => Message::NewFile(Some(entity)), Action::NewFolder => Message::NewFolder(Some(entity)), Action::Paste => Message::Paste(Some(entity)), @@ -131,6 +139,7 @@ pub enum Message { AppTheme(AppTheme), Config(Config), Copy(Option), + MoveToTrash(Option), NewFile(Option), NewFolder(Option), Paste(Option), @@ -172,9 +181,8 @@ pub struct App { } impl App { - fn open_tab>(&mut self, path: P) -> Command { - let path = path.into(); - let tab = Tab::new(path.clone()); + fn open_tab(&mut self, location: Location) -> Command { + let tab = Tab::new(location.clone()); let entity = self .tab_model .insert() @@ -183,18 +191,17 @@ impl App { .closable() .activate() .id(); - Command::batch([self.update_title(), self.rescan_tab(entity, path)]) + Command::batch([self.update_title(), self.rescan_tab(entity, location)]) } - fn rescan_tab>( + fn rescan_tab( &mut self, entity: segmented_button::Entity, - path: P, + location: Location, ) -> Command { - let path = path.into(); Command::perform( async move { - match tokio::task::spawn_blocking(move || tab::rescan(path)).await { + match tokio::task::spawn_blocking(move || location.scan()).await { Ok(items) => message::app(Message::TabRescan(entity, items)), Err(err) => { log::warn!("failed to rescan: {}", err); @@ -313,11 +320,18 @@ impl Application for App { let mut commands = Vec::new(); for arg in env::args().skip(1) { - commands.push(app.open_tab(arg)); + let location = match fs::canonicalize(&arg) { + Ok(absolute) => Location::Path(absolute), + Err(err) => { + log::warn!("failed to canonicalize {:?}: {}", arg, err); + continue; + } + }; + commands.push(app.open_tab(location)); } if app.tab_model.iter().next().is_none() { - commands.push(app.open_tab(home_dir())); + commands.push(app.open_tab(Location::Path(home_dir()))); } (app, Command::batch(commands)) @@ -344,6 +358,9 @@ impl Application for App { Message::Copy(entity_opt) => { log::warn!("TODO: COPY"); } + Message::MoveToTrash(entity_opt) => { + log::warn!("TODO: MOVE TO TRASH"); + } Message::NewFile(entity_opt) => { log::warn!("TODO: NEW FILE"); } @@ -424,7 +441,7 @@ impl Application for App { match self.tab_model.data_mut::(entity) { Some(tab) => { if tab.update(tab_message) { - update_opt = Some((tab.title(), tab.path.clone())); + update_opt = Some((tab.title(), tab.location.clone())); } } _ => (), @@ -439,11 +456,11 @@ impl Application for App { } Message::TabNew => { let active = self.tab_model.active(); - let path = match self.tab_model.data::(active) { - Some(tab) => tab.path.clone(), - None => home_dir(), + let location = match self.tab_model.data::(active) { + Some(tab) => tab.location.clone(), + None => Location::Path(home_dir()), }; - return self.open_tab(path); + return self.open_tab(location); } Message::TabRescan(entity, items) => match self.tab_model.data_mut::(entity) { Some(tab) => { @@ -492,6 +509,7 @@ impl Application for App { tab::View::List => (tab::View::Grid, "view-grid-symbolic"), }; + //TODO: use nav bar instead, dynamically show items vec![row![ widget::button(widget::icon::from_name("list-add-symbolic").size(16).icon()) .on_press(Message::TabNew) @@ -505,6 +523,14 @@ impl Application for App { .on_press(Message::TabMessage(active, tab::Message::Parent)) .padding(space_xxs) .style(style::Button::Icon), + widget::button( + widget::icon::from_name("user-trash-full-symbolic") + .size(16) + .icon() + ) + .on_press(Message::TabMessage(active, tab::Message::Trash)) + .padding(space_xxs) + .style(style::Button::Icon), widget::button(widget::icon::from_name(view_icon).size(16).icon()) .on_press(Message::TabMessage(active, tab::Message::View(view))) .padding(space_xxs) diff --git a/src/menu.rs b/src/menu.rs index b2c8214..fb516a8 100644 --- a/src/menu.rs +++ b/src/menu.rs @@ -33,6 +33,7 @@ pub fn context_menu<'a>(entity: segmented_button::Entity) -> Element<'a, Message menu_button!(widget::text(label)).on_press(Message::TabContextAction(entity, action)) }; + //TODO: change items based on selection widget::container(column!( menu_action(fl!("new-file"), Action::NewFile), menu_action(fl!("new-folder"), Action::NewFolder), @@ -41,6 +42,8 @@ pub fn context_menu<'a>(entity: segmented_button::Entity) -> Element<'a, Message menu_action(fl!("paste"), Action::Paste), menu_action(fl!("select-all"), Action::SelectAll), horizontal_rule(1), + menu_action(fl!("move-to-trash"), Action::MoveToTrash), + horizontal_rule(1), menu_action(fl!("properties"), Action::Properties), )) .padding(1) diff --git a/src/tab.rs b/src/tab.rs index 0975960..935bdf2 100644 --- a/src/tab.rs +++ b/src/tab.rs @@ -155,9 +155,9 @@ fn open_command(path: &PathBuf) -> process::Command { command } -pub fn rescan(tab_path: PathBuf) -> Vec { +pub fn scan_path(tab_path: &PathBuf) -> Vec { let mut items = Vec::new(); - match fs::read_dir(&tab_path) { + match fs::read_dir(tab_path) { Ok(entries) => { for entry_res in entries { let entry = match entry_res { @@ -211,7 +211,7 @@ pub fn rescan(tab_path: PathBuf) -> Vec { items.push(Item { name, - metadata, + metadata_opt: Some(metadata), hidden, path, icon_handle_grid, @@ -224,7 +224,66 @@ pub fn rescan(tab_path: PathBuf) -> Vec { log::warn!("failed to read directory {:?}: {}", tab_path, err); } } - items.sort_by(|a, b| match (a.metadata.is_dir(), b.metadata.is_dir()) { + items.sort_by(|a, b| lexical_sort::natural_lexical_cmp(&a.name, &b.name)); + items +} + +// This config statement is from trash::os_limited, inverted +#[cfg(not(any( + target_os = "windows", + all( + unix, + not(target_os = "macos"), + not(target_os = "ios"), + not(target_os = "android") + ) +)))] +pub fn scan_trash() -> Vec { + log::warn!("viewing trash not supported on this platform"); + Vec::new() +} + +// This config statement is from trash::os_limited +#[cfg(any( + target_os = "windows", + all( + unix, + not(target_os = "macos"), + not(target_os = "ios"), + not(target_os = "android") + ) +))] +pub fn scan_trash() -> Vec { + let mut items: Vec = Vec::new(); + match trash::os_limited::list() { + Ok(entries) => { + for entry in entries { + let path = entry.original_path(); + let name = entry.name; + + //TODO: configurable size + let (icon_handle_grid, icon_handle_list) = ( + mime_icon(&path, ICON_SIZE_GRID), + mime_icon(&path, ICON_SIZE_LIST), + ); + + items.push(Item { + name, + //TODO: how will we get proper info on if this is a file or directory? + metadata_opt: None, + hidden: false, + path, + icon_handle_grid, + icon_handle_list, + select_time: None, + }); + } + } + Err(err) => { + log::warn!("failed to read trash items: {}", err); + } + } + items.sort_by(|a, b| match (a.is_dir(), b.is_dir()) { (true, false) => Ordering::Less, (false, true) => Ordering::Greater, _ => lexical_sort::natural_lexical_cmp(&a.name, &b.name), @@ -232,18 +291,34 @@ pub fn rescan(tab_path: PathBuf) -> Vec { items } +#[derive(Clone, Debug)] +pub enum Location { + Path(PathBuf), + Trash, +} + +impl Location { + pub fn scan(&self) -> Vec { + match self { + Self::Path(path) => scan_path(path), + Self::Trash => scan_trash(), + } + } +} + #[derive(Clone, Copy, Debug)] pub enum Message { Click(Option), Home, Parent, + Trash, View(View), } #[derive(Clone)] pub struct Item { pub name: String, - pub metadata: Metadata, + pub metadata_opt: Option, pub hidden: bool, pub path: PathBuf, pub icon_handle_grid: widget::icon::Handle, @@ -252,6 +327,10 @@ pub struct Item { } impl Item { + pub fn is_dir(&self) -> bool { + self.metadata_opt.as_ref().map_or(false, |x| x.is_dir()) + } + pub fn property_view(&self, core: &Core) -> Element { let mut section = widget::settings::view_section(""); section = section.add(widget::settings::item::item_row(vec![ @@ -263,44 +342,46 @@ impl Item { //TODO: translate! //TODO: correct display of folder size? - if !self.metadata.is_dir() { - section = section.add(widget::settings::item::item( - "Size", - widget::text(format_size(self.metadata.len())), - )); - } + if let Some(ref metadata) = self.metadata_opt { + if !metadata.is_dir() { + section = section.add(widget::settings::item::item( + "Size", + widget::text(format_size(metadata.len())), + )); + } - if let Ok(time) = self.metadata.accessed() { - section = section.add(widget::settings::item( - "Accessed", - widget::text( - chrono::DateTime::::from(time) - .format("%c") - .to_string(), - ), - )); - } + if let Ok(time) = metadata.accessed() { + section = section.add(widget::settings::item( + "Accessed", + widget::text( + chrono::DateTime::::from(time) + .format("%c") + .to_string(), + ), + )); + } - if let Ok(time) = self.metadata.modified() { - section = section.add(widget::settings::item( - "Modified", - widget::text( - chrono::DateTime::::from(time) - .format("%c") - .to_string(), - ), - )); - } + if let Ok(time) = metadata.modified() { + section = section.add(widget::settings::item( + "Modified", + widget::text( + chrono::DateTime::::from(time) + .format("%c") + .to_string(), + ), + )); + } - if let Ok(time) = self.metadata.created() { - section = section.add(widget::settings::item( - "Created", - widget::text( - chrono::DateTime::::from(time) - .format("%c") - .to_string(), - ), - )); + if let Ok(time) = metadata.created() { + section = section.add(widget::settings::item( + "Created", + widget::text( + chrono::DateTime::::from(time) + .format("%c") + .to_string(), + ), + )); + } } section.into() @@ -311,7 +392,7 @@ impl fmt::Debug for Item { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("Item") .field("name", &self.name) - .field("metadata", &self.metadata) + .field("metadata_opt", &self.metadata_opt) .field("hidden", &self.hidden) .field("path", &self.path) // icon_handles @@ -328,7 +409,7 @@ pub enum View { #[derive(Clone, Debug)] pub struct Tab { - pub path: PathBuf, + pub location: Location, //TODO pub context_menu: Option, pub items_opt: Option>, @@ -336,15 +417,9 @@ pub struct Tab { } impl Tab { - pub fn new(path: PathBuf) -> Self { + pub fn new(location: Location) -> Self { Self { - path: match fs::canonicalize(&path) { - Ok(absolute) => absolute, - Err(err) => { - log::warn!("failed to canonicalize {:?}: {}", path, err); - path - } - }, + location, context_menu: None, items_opt: None, view: View::Grid, @@ -353,7 +428,14 @@ impl Tab { pub fn title(&self) -> String { //TODO: better title - format!("{}", self.path.display()) + match &self.location { + Location::Path(path) => { + format!("{}", path.display()) + } + Location::Trash => { + fl!("trash") + } + } } pub fn update(&mut self, message: Message) -> bool { @@ -365,8 +447,8 @@ impl Tab { if Some(i) == click_i_opt { if let Some(select_time) = item.select_time { if select_time.elapsed() < DOUBLE_CLICK_DURATION { - if item.metadata.is_dir() { - cd = Some(item.path.clone()); + if item.is_dir() { + cd = Some(Location::Path(item.path.clone())); } else { let mut command = open_command(&item.path); match command.spawn() { @@ -392,19 +474,24 @@ impl Tab { self.context_menu = None; } Message::Home => { - cd = Some(crate::home_dir()); + cd = Some(Location::Path(crate::home_dir())); } Message::Parent => { - if let Some(parent) = self.path.parent() { - cd = Some(parent.to_owned()); + if let Location::Path(path) = &self.location { + if let Some(parent) = path.parent() { + cd = Some(Location::Path(parent.to_owned())); + } } } + Message::Trash => { + cd = Some(Location::Trash); + } Message::View(view) => { self.view = view; } } - if let Some(path) = cd { - self.path = path; + if let Some(location) = cd { + self.location = location; self.items_opt = None; true } else { @@ -433,8 +520,8 @@ impl Tab { ) .align_x(Horizontal::Center) .align_y(Vertical::Center) - .height(Length::Fill) .width(Length::Fill) + .height(Length::Fill) .into() } @@ -483,7 +570,9 @@ impl Tab { return self.empty_view(hidden > 0, core); } } - widget::flex_row(children).into() + widget::scrollable(widget::flex_row(children)) + .width(Length::Fill) + .into() } pub fn list_view(&self, core: &Core) -> Element { @@ -527,10 +616,14 @@ impl Tab { .into(), widget::text(item.name.clone()).into(), widget::horizontal_space(Length::Fill).into(), - widget::text(if item.metadata.is_dir() { + widget::text(if item.is_dir() { "\u{2014}".to_string() } else { - format_size(item.metadata.len()) + if let Some(ref metadata) = item.metadata_opt { + format_size(metadata.len()) + } else { + "\u{2014}".to_string() + } }) .into(), // Hack to make room for scroll bar @@ -557,17 +650,16 @@ impl Tab { return self.empty_view(hidden > 0, core); } } - widget::column::with_children(children).into() + widget::scrollable(widget::column::with_children(children)) + .width(Length::Fill) + .into() } pub fn view(&self, core: &Core) -> Element { - widget::container( - widget::scrollable(match self.view { - View::Grid => self.grid_view(core), - View::List => self.list_view(core), - }) - .width(Length::Fill), - ) + widget::container(match self.view { + View::Grid => self.grid_view(core), + View::List => self.list_view(core), + }) .height(Length::Fill) .width(Length::Fill) .into()