Partial trash implementation

This commit is contained in:
Jeremy Soller 2024-01-05 16:17:23 -07:00
parent 174fc53e45
commit 1ba5be1116
No known key found for this signature in database
GPG key ID: DCFCA852D3906975
6 changed files with 228 additions and 87 deletions

17
Cargo.lock generated
View file

@ -980,6 +980,7 @@ dependencies = [
"serde", "serde",
"systemicons", "systemicons",
"tokio", "tokio",
"trash",
] ]
[[package]] [[package]]
@ -4999,6 +5000,22 @@ dependencies = [
"once_cell", "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]] [[package]]
name = "ttf-parser" name = "ttf-parser"
version = "0.15.2" version = "0.15.2"

View file

@ -12,6 +12,7 @@ lexical-sort = "0.3.1"
log = "0.4" log = "0.4"
serde = { version = "1", features = ["serde_derive"] } serde = { version = "1", features = ["serde_derive"] }
tokio = { version = "1" } tokio = { version = "1" }
trash = "3.1.2"
# Internationalization # Internationalization
i18n-embed = { version = "0.13", features = ["fluent-system", "desktop-requester"] } i18n-embed = { version = "0.13", features = ["fluent-system", "desktop-requester"] }
i18n-embed-fl = "0.6" i18n-embed-fl = "0.6"

View file

@ -1,5 +1,6 @@
empty-folder = Empty folder empty-folder = Empty folder
empty-folder-hidden = Empty folder (has hidden items) empty-folder-hidden = Empty folder (has hidden items)
trash = Trash
# Context Pages # Context Pages
@ -22,3 +23,4 @@ new-folder = New folder
copy = Copy copy = Copy
paste = Paste paste = Paste
select-all = Select all select-all = Select all
move-to-trash = Move to trash

View file

@ -10,7 +10,13 @@ use cosmic::{
widget::{self, segmented_button}, widget::{self, segmented_button},
Application, ApplicationExt, Element, 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}; use config::{AppTheme, Config, CONFIG_VERSION};
mod config; mod config;
@ -23,7 +29,7 @@ mod localize;
mod mime_icon; mod mime_icon;
use tab::Tab; use tab::{Location, Tab};
mod tab; mod tab;
/// Runs application with these settings /// Runs application with these settings
@ -100,6 +106,7 @@ pub struct Flags {
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug)]
pub enum Action { pub enum Action {
Copy, Copy,
MoveToTrash,
NewFile, NewFile,
NewFolder, NewFolder,
Paste, Paste,
@ -113,6 +120,7 @@ impl Action {
pub fn message(self, entity: segmented_button::Entity) -> Message { pub fn message(self, entity: segmented_button::Entity) -> Message {
match self { match self {
Action::Copy => Message::Copy(Some(entity)), Action::Copy => Message::Copy(Some(entity)),
Action::MoveToTrash => Message::MoveToTrash(Some(entity)),
Action::NewFile => Message::NewFile(Some(entity)), Action::NewFile => Message::NewFile(Some(entity)),
Action::NewFolder => Message::NewFolder(Some(entity)), Action::NewFolder => Message::NewFolder(Some(entity)),
Action::Paste => Message::Paste(Some(entity)), Action::Paste => Message::Paste(Some(entity)),
@ -131,6 +139,7 @@ pub enum Message {
AppTheme(AppTheme), AppTheme(AppTheme),
Config(Config), Config(Config),
Copy(Option<segmented_button::Entity>), Copy(Option<segmented_button::Entity>),
MoveToTrash(Option<segmented_button::Entity>),
NewFile(Option<segmented_button::Entity>), NewFile(Option<segmented_button::Entity>),
NewFolder(Option<segmented_button::Entity>), NewFolder(Option<segmented_button::Entity>),
Paste(Option<segmented_button::Entity>), Paste(Option<segmented_button::Entity>),
@ -172,9 +181,8 @@ pub struct App {
} }
impl App { impl App {
fn open_tab<P: Into<PathBuf>>(&mut self, path: P) -> Command<Message> { fn open_tab(&mut self, location: Location) -> Command<Message> {
let path = path.into(); let tab = Tab::new(location.clone());
let tab = Tab::new(path.clone());
let entity = self let entity = self
.tab_model .tab_model
.insert() .insert()
@ -183,18 +191,17 @@ impl App {
.closable() .closable()
.activate() .activate()
.id(); .id();
Command::batch([self.update_title(), self.rescan_tab(entity, path)]) Command::batch([self.update_title(), self.rescan_tab(entity, location)])
} }
fn rescan_tab<P: Into<PathBuf>>( fn rescan_tab(
&mut self, &mut self,
entity: segmented_button::Entity, entity: segmented_button::Entity,
path: P, location: Location,
) -> Command<Message> { ) -> Command<Message> {
let path = path.into();
Command::perform( Command::perform(
async move { 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)), Ok(items) => message::app(Message::TabRescan(entity, items)),
Err(err) => { Err(err) => {
log::warn!("failed to rescan: {}", err); log::warn!("failed to rescan: {}", err);
@ -313,11 +320,18 @@ impl Application for App {
let mut commands = Vec::new(); let mut commands = Vec::new();
for arg in env::args().skip(1) { 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() { 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)) (app, Command::batch(commands))
@ -344,6 +358,9 @@ impl Application for App {
Message::Copy(entity_opt) => { Message::Copy(entity_opt) => {
log::warn!("TODO: COPY"); log::warn!("TODO: COPY");
} }
Message::MoveToTrash(entity_opt) => {
log::warn!("TODO: MOVE TO TRASH");
}
Message::NewFile(entity_opt) => { Message::NewFile(entity_opt) => {
log::warn!("TODO: NEW FILE"); log::warn!("TODO: NEW FILE");
} }
@ -424,7 +441,7 @@ impl Application for App {
match self.tab_model.data_mut::<Tab>(entity) { match self.tab_model.data_mut::<Tab>(entity) {
Some(tab) => { Some(tab) => {
if tab.update(tab_message) { 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 => { Message::TabNew => {
let active = self.tab_model.active(); let active = self.tab_model.active();
let path = match self.tab_model.data::<Tab>(active) { let location = match self.tab_model.data::<Tab>(active) {
Some(tab) => tab.path.clone(), Some(tab) => tab.location.clone(),
None => home_dir(), 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::<Tab>(entity) { Message::TabRescan(entity, items) => match self.tab_model.data_mut::<Tab>(entity) {
Some(tab) => { Some(tab) => {
@ -492,6 +509,7 @@ impl Application for App {
tab::View::List => (tab::View::Grid, "view-grid-symbolic"), tab::View::List => (tab::View::Grid, "view-grid-symbolic"),
}; };
//TODO: use nav bar instead, dynamically show items
vec![row![ vec![row![
widget::button(widget::icon::from_name("list-add-symbolic").size(16).icon()) widget::button(widget::icon::from_name("list-add-symbolic").size(16).icon())
.on_press(Message::TabNew) .on_press(Message::TabNew)
@ -505,6 +523,14 @@ impl Application for App {
.on_press(Message::TabMessage(active, tab::Message::Parent)) .on_press(Message::TabMessage(active, tab::Message::Parent))
.padding(space_xxs) .padding(space_xxs)
.style(style::Button::Icon), .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()) widget::button(widget::icon::from_name(view_icon).size(16).icon())
.on_press(Message::TabMessage(active, tab::Message::View(view))) .on_press(Message::TabMessage(active, tab::Message::View(view)))
.padding(space_xxs) .padding(space_xxs)

View file

@ -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)) menu_button!(widget::text(label)).on_press(Message::TabContextAction(entity, action))
}; };
//TODO: change items based on selection
widget::container(column!( widget::container(column!(
menu_action(fl!("new-file"), Action::NewFile), menu_action(fl!("new-file"), Action::NewFile),
menu_action(fl!("new-folder"), Action::NewFolder), 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!("paste"), Action::Paste),
menu_action(fl!("select-all"), Action::SelectAll), menu_action(fl!("select-all"), Action::SelectAll),
horizontal_rule(1), horizontal_rule(1),
menu_action(fl!("move-to-trash"), Action::MoveToTrash),
horizontal_rule(1),
menu_action(fl!("properties"), Action::Properties), menu_action(fl!("properties"), Action::Properties),
)) ))
.padding(1) .padding(1)

View file

@ -155,9 +155,9 @@ fn open_command(path: &PathBuf) -> process::Command {
command command
} }
pub fn rescan(tab_path: PathBuf) -> Vec<Item> { pub fn scan_path(tab_path: &PathBuf) -> Vec<Item> {
let mut items = Vec::new(); let mut items = Vec::new();
match fs::read_dir(&tab_path) { match fs::read_dir(tab_path) {
Ok(entries) => { Ok(entries) => {
for entry_res in entries { for entry_res in entries {
let entry = match entry_res { let entry = match entry_res {
@ -211,7 +211,7 @@ pub fn rescan(tab_path: PathBuf) -> Vec<Item> {
items.push(Item { items.push(Item {
name, name,
metadata, metadata_opt: Some(metadata),
hidden, hidden,
path, path,
icon_handle_grid, icon_handle_grid,
@ -224,7 +224,66 @@ pub fn rescan(tab_path: PathBuf) -> Vec<Item> {
log::warn!("failed to read directory {:?}: {}", tab_path, err); 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<Item> {
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<Item> {
let mut items: Vec<Item> = 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, (true, false) => Ordering::Less,
(false, true) => Ordering::Greater, (false, true) => Ordering::Greater,
_ => lexical_sort::natural_lexical_cmp(&a.name, &b.name), _ => lexical_sort::natural_lexical_cmp(&a.name, &b.name),
@ -232,18 +291,34 @@ pub fn rescan(tab_path: PathBuf) -> Vec<Item> {
items items
} }
#[derive(Clone, Debug)]
pub enum Location {
Path(PathBuf),
Trash,
}
impl Location {
pub fn scan(&self) -> Vec<Item> {
match self {
Self::Path(path) => scan_path(path),
Self::Trash => scan_trash(),
}
}
}
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug)]
pub enum Message { pub enum Message {
Click(Option<usize>), Click(Option<usize>),
Home, Home,
Parent, Parent,
Trash,
View(View), View(View),
} }
#[derive(Clone)] #[derive(Clone)]
pub struct Item { pub struct Item {
pub name: String, pub name: String,
pub metadata: Metadata, pub metadata_opt: Option<Metadata>,
pub hidden: bool, pub hidden: bool,
pub path: PathBuf, pub path: PathBuf,
pub icon_handle_grid: widget::icon::Handle, pub icon_handle_grid: widget::icon::Handle,
@ -252,6 +327,10 @@ pub struct Item {
} }
impl 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<crate::Message> { pub fn property_view(&self, core: &Core) -> Element<crate::Message> {
let mut section = widget::settings::view_section(""); let mut section = widget::settings::view_section("");
section = section.add(widget::settings::item::item_row(vec![ section = section.add(widget::settings::item::item_row(vec![
@ -263,44 +342,46 @@ impl Item {
//TODO: translate! //TODO: translate!
//TODO: correct display of folder size? //TODO: correct display of folder size?
if !self.metadata.is_dir() { if let Some(ref metadata) = self.metadata_opt {
section = section.add(widget::settings::item::item( if !metadata.is_dir() {
"Size", section = section.add(widget::settings::item::item(
widget::text(format_size(self.metadata.len())), "Size",
)); widget::text(format_size(metadata.len())),
} ));
}
if let Ok(time) = self.metadata.accessed() { if let Ok(time) = metadata.accessed() {
section = section.add(widget::settings::item( section = section.add(widget::settings::item(
"Accessed", "Accessed",
widget::text( widget::text(
chrono::DateTime::<chrono::Local>::from(time) chrono::DateTime::<chrono::Local>::from(time)
.format("%c") .format("%c")
.to_string(), .to_string(),
), ),
)); ));
} }
if let Ok(time) = self.metadata.modified() { if let Ok(time) = metadata.modified() {
section = section.add(widget::settings::item( section = section.add(widget::settings::item(
"Modified", "Modified",
widget::text( widget::text(
chrono::DateTime::<chrono::Local>::from(time) chrono::DateTime::<chrono::Local>::from(time)
.format("%c") .format("%c")
.to_string(), .to_string(),
), ),
)); ));
} }
if let Ok(time) = self.metadata.created() { if let Ok(time) = metadata.created() {
section = section.add(widget::settings::item( section = section.add(widget::settings::item(
"Created", "Created",
widget::text( widget::text(
chrono::DateTime::<chrono::Local>::from(time) chrono::DateTime::<chrono::Local>::from(time)
.format("%c") .format("%c")
.to_string(), .to_string(),
), ),
)); ));
}
} }
section.into() section.into()
@ -311,7 +392,7 @@ impl fmt::Debug for Item {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Item") f.debug_struct("Item")
.field("name", &self.name) .field("name", &self.name)
.field("metadata", &self.metadata) .field("metadata_opt", &self.metadata_opt)
.field("hidden", &self.hidden) .field("hidden", &self.hidden)
.field("path", &self.path) .field("path", &self.path)
// icon_handles // icon_handles
@ -328,7 +409,7 @@ pub enum View {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct Tab { pub struct Tab {
pub path: PathBuf, pub location: Location,
//TODO //TODO
pub context_menu: Option<Point>, pub context_menu: Option<Point>,
pub items_opt: Option<Vec<Item>>, pub items_opt: Option<Vec<Item>>,
@ -336,15 +417,9 @@ pub struct Tab {
} }
impl Tab { impl Tab {
pub fn new(path: PathBuf) -> Self { pub fn new(location: Location) -> Self {
Self { Self {
path: match fs::canonicalize(&path) { location,
Ok(absolute) => absolute,
Err(err) => {
log::warn!("failed to canonicalize {:?}: {}", path, err);
path
}
},
context_menu: None, context_menu: None,
items_opt: None, items_opt: None,
view: View::Grid, view: View::Grid,
@ -353,7 +428,14 @@ impl Tab {
pub fn title(&self) -> String { pub fn title(&self) -> String {
//TODO: better title //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 { pub fn update(&mut self, message: Message) -> bool {
@ -365,8 +447,8 @@ impl Tab {
if Some(i) == click_i_opt { if Some(i) == click_i_opt {
if let Some(select_time) = item.select_time { if let Some(select_time) = item.select_time {
if select_time.elapsed() < DOUBLE_CLICK_DURATION { if select_time.elapsed() < DOUBLE_CLICK_DURATION {
if item.metadata.is_dir() { if item.is_dir() {
cd = Some(item.path.clone()); cd = Some(Location::Path(item.path.clone()));
} else { } else {
let mut command = open_command(&item.path); let mut command = open_command(&item.path);
match command.spawn() { match command.spawn() {
@ -392,19 +474,24 @@ impl Tab {
self.context_menu = None; self.context_menu = None;
} }
Message::Home => { Message::Home => {
cd = Some(crate::home_dir()); cd = Some(Location::Path(crate::home_dir()));
} }
Message::Parent => { Message::Parent => {
if let Some(parent) = self.path.parent() { if let Location::Path(path) = &self.location {
cd = Some(parent.to_owned()); if let Some(parent) = path.parent() {
cd = Some(Location::Path(parent.to_owned()));
}
} }
} }
Message::Trash => {
cd = Some(Location::Trash);
}
Message::View(view) => { Message::View(view) => {
self.view = view; self.view = view;
} }
} }
if let Some(path) = cd { if let Some(location) = cd {
self.path = path; self.location = location;
self.items_opt = None; self.items_opt = None;
true true
} else { } else {
@ -433,8 +520,8 @@ impl Tab {
) )
.align_x(Horizontal::Center) .align_x(Horizontal::Center)
.align_y(Vertical::Center) .align_y(Vertical::Center)
.height(Length::Fill)
.width(Length::Fill) .width(Length::Fill)
.height(Length::Fill)
.into() .into()
} }
@ -483,7 +570,9 @@ impl Tab {
return self.empty_view(hidden > 0, core); 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<Message> { pub fn list_view(&self, core: &Core) -> Element<Message> {
@ -527,10 +616,14 @@ impl Tab {
.into(), .into(),
widget::text(item.name.clone()).into(), widget::text(item.name.clone()).into(),
widget::horizontal_space(Length::Fill).into(), widget::horizontal_space(Length::Fill).into(),
widget::text(if item.metadata.is_dir() { widget::text(if item.is_dir() {
"\u{2014}".to_string() "\u{2014}".to_string()
} else { } else {
format_size(item.metadata.len()) if let Some(ref metadata) = item.metadata_opt {
format_size(metadata.len())
} else {
"\u{2014}".to_string()
}
}) })
.into(), .into(),
// Hack to make room for scroll bar // Hack to make room for scroll bar
@ -557,17 +650,16 @@ impl Tab {
return self.empty_view(hidden > 0, core); 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<Message> { pub fn view(&self, core: &Core) -> Element<Message> {
widget::container( widget::container(match self.view {
widget::scrollable(match self.view { View::Grid => self.grid_view(core),
View::Grid => self.grid_view(core), View::List => self.list_view(core),
View::List => self.list_view(core), })
})
.width(Length::Fill),
)
.height(Length::Fill) .height(Length::Fill)
.width(Length::Fill) .width(Length::Fill)
.into() .into()