feat: view folder's content in trash
- [x] I have disclosed use of any AI generated code in my commit messages. - If you are using an LLM, and do not fully understand the changes it is making to the code base, do not create a PR. - In our experience, AI generated code often results in overly complex code that lacks enough context for a proper fix or feature inclusion. This results in considerably longer code reviews. Due to this, AI authored or partially authored PRs may be closed without comment. - [x] I understand these changes in full and will be able to respond to review comments. - [x] My change is accurately described in the commit message. - [x] My contribution is tested and working as described. - [x] I have read the [Developer Certificate of Origin](https://developercertificate.org/) and certify my contribution under its conditions.
This commit is contained in:
parent
50deb28a11
commit
49107187a4
6 changed files with 154 additions and 29 deletions
9
Cargo.lock
generated
9
Cargo.lock
generated
|
|
@ -7385,14 +7385,15 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "trash"
|
name = "trash"
|
||||||
version = "5.2.2"
|
version = "5.2.6"
|
||||||
source = "git+https://github.com/jackpot51/trash-rs.git?branch=cosmic#a225f753a88e722aeeb27bb2fb00144739911035"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7602e0c7d66ec2d92a8c917219fbc7894039efa2063b9064260110828a356f46"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
"objc2 0.5.2",
|
"objc2 0.6.4",
|
||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.3.2",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"scopeguard",
|
"scopeguard",
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ shlex = { version = "1.3" }
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
tikv-jemallocator = { version = "0.6", optional = true }
|
tikv-jemallocator = { version = "0.6", optional = true }
|
||||||
tokio = { version = "1", features = ["process", "sync"] }
|
tokio = { version = "1", features = ["process", "sync"] }
|
||||||
trash = { git = "https://github.com/jackpot51/trash-rs.git", branch = "cosmic" }
|
trash = "5.2.6"
|
||||||
url = "2.5"
|
url = "2.5"
|
||||||
walkdir = "2.5.0"
|
walkdir = "2.5.0"
|
||||||
wayland-client = { version = "0.31.14", optional = true }
|
wayland-client = { version = "0.31.14", optional = true }
|
||||||
|
|
|
||||||
51
src/menu.rs
51
src/menu.rs
|
|
@ -18,7 +18,9 @@ use std::sync::LazyLock;
|
||||||
use crate::app::{Action, Message};
|
use crate::app::{Action, Message};
|
||||||
use crate::config::{Config, ContextActionPreset};
|
use crate::config::{Config, ContextActionPreset};
|
||||||
use crate::fl;
|
use crate::fl;
|
||||||
use crate::tab::{self, HeadingOptions, Location, LocationMenuAction, SearchLocation, Tab};
|
use crate::tab::{
|
||||||
|
self, HeadingOptions, ItemMetadata, Location, LocationMenuAction, SearchLocation, Tab,
|
||||||
|
};
|
||||||
use crate::trash::{Trash, TrashExt};
|
use crate::trash::{Trash, TrashExt};
|
||||||
|
|
||||||
static MENU_ID: LazyLock<cosmic::widget::Id> =
|
static MENU_ID: LazyLock<cosmic::widget::Id> =
|
||||||
|
|
@ -135,6 +137,7 @@ pub fn context_menu<'a>(
|
||||||
let mut selected_desktop_entry = None;
|
let mut selected_desktop_entry = None;
|
||||||
let mut selected_types: Vec<Mime> = vec![];
|
let mut selected_types: Vec<Mime> = vec![];
|
||||||
let mut selected_mount_point = 0;
|
let mut selected_mount_point = 0;
|
||||||
|
let mut any_trash_item = false;
|
||||||
if let Some(items) = tab.items_opt() {
|
if let Some(items) = tab.items_opt() {
|
||||||
for item in items {
|
for item in items {
|
||||||
if item.selected {
|
if item.selected {
|
||||||
|
|
@ -155,6 +158,9 @@ pub fn context_menu<'a>(
|
||||||
}
|
}
|
||||||
_ => (),
|
_ => (),
|
||||||
}
|
}
|
||||||
|
if matches!(&item.metadata, ItemMetadata::Trash { .. }) {
|
||||||
|
any_trash_item = true;
|
||||||
|
}
|
||||||
selected_types.push(item.mime.clone());
|
selected_types.push(item.mime.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -276,27 +282,38 @@ pub fn context_menu<'a>(
|
||||||
|
|
||||||
//TODO: Print?
|
//TODO: Print?
|
||||||
children.push(menu_item(fl!("show-details"), Action::Preview).into());
|
children.push(menu_item(fl!("show-details"), Action::Preview).into());
|
||||||
if matches!(tab.mode, tab::Mode::App) {
|
if any_trash_item {
|
||||||
children.push(divider::horizontal::light().into());
|
children.push(divider::horizontal::light().into());
|
||||||
children.push(menu_item(fl!("add-to-sidebar"), Action::AddToSidebar).into());
|
|
||||||
}
|
|
||||||
children.push(divider::horizontal::light().into());
|
|
||||||
if tab.location.is_recents() {
|
|
||||||
children.push(
|
children.push(
|
||||||
menu_item(fl!("remove-from-recents"), Action::RemoveFromRecents).into(),
|
menu_item(fl!("restore-from-trash"), Action::RestoreFromTrash).into(),
|
||||||
);
|
);
|
||||||
children.push(divider::horizontal::light().into());
|
children.push(divider::horizontal::light().into());
|
||||||
}
|
children.push(menu_item(fl!("delete-permanently"), Action::Delete).into());
|
||||||
if selected_mount_point == 0 {
|
} else {
|
||||||
if modifiers.shift() && !modifiers.control() {
|
if matches!(tab.mode, tab::Mode::App) {
|
||||||
children.push(
|
children.push(divider::horizontal::light().into());
|
||||||
menu_item(fl!("delete-permanently"), Action::PermanentlyDelete).into(),
|
children
|
||||||
);
|
.push(menu_item(fl!("add-to-sidebar"), Action::AddToSidebar).into());
|
||||||
} else {
|
}
|
||||||
children.push(menu_item(fl!("move-to-trash"), Action::Delete).into());
|
children.push(divider::horizontal::light().into());
|
||||||
|
if tab.location.is_recents() {
|
||||||
|
children.push(
|
||||||
|
menu_item(fl!("remove-from-recents"), Action::RemoveFromRecents).into(),
|
||||||
|
);
|
||||||
|
children.push(divider::horizontal::light().into());
|
||||||
|
}
|
||||||
|
if selected_mount_point == 0 {
|
||||||
|
if modifiers.shift() && !modifiers.control() {
|
||||||
|
children.push(
|
||||||
|
menu_item(fl!("delete-permanently"), Action::PermanentlyDelete)
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
children.push(menu_item(fl!("move-to-trash"), Action::Delete).into());
|
||||||
|
}
|
||||||
|
} else if selected == 1 {
|
||||||
|
children.push(menu_item(fl!("eject"), Action::Eject).into());
|
||||||
}
|
}
|
||||||
} else if selected == 1 {
|
|
||||||
children.push(menu_item(fl!("eject"), Action::Eject).into());
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
//TODO: need better designs for menu with no selection
|
//TODO: need better designs for menu with no selection
|
||||||
|
|
|
||||||
|
|
@ -1156,10 +1156,26 @@ impl Operation {
|
||||||
|
|
||||||
paths.push(item.original_path());
|
paths.push(item.original_path());
|
||||||
|
|
||||||
compio::runtime::spawn_blocking(|| trash::os_limited::restore_all([item]))
|
// Items with .trashinfo id use standard restore; sub-items use manual move
|
||||||
.await
|
if item
|
||||||
.map_err(wrap_compio_spawn_error)?
|
.id
|
||||||
.map_err(|e| OperationError::from_err(e, &controller))?;
|
.to_str()
|
||||||
|
.map_or(false, |s| s.ends_with(".trashinfo"))
|
||||||
|
{
|
||||||
|
compio::runtime::spawn_blocking(|| trash::os_limited::restore_all([item]))
|
||||||
|
.await
|
||||||
|
.map_err(wrap_compio_spawn_error)?
|
||||||
|
.map_err(|e| OperationError::from_err(e, &controller))?;
|
||||||
|
} else {
|
||||||
|
let from = PathBuf::from(&item.id);
|
||||||
|
let to = item.original_path();
|
||||||
|
if let Some(parent) = to.parent() {
|
||||||
|
std::fs::create_dir_all(parent)
|
||||||
|
.map_err(|e| OperationError::from_err(e, &controller))?;
|
||||||
|
}
|
||||||
|
std::fs::rename(&from, &to)
|
||||||
|
.map_err(|e| OperationError::from_err(e, &controller))?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(OperationSelection {
|
Ok(OperationSelection {
|
||||||
ignored: Vec::new(),
|
ignored: Vec::new(),
|
||||||
|
|
|
||||||
36
src/tab.rs
36
src/tab.rs
|
|
@ -877,6 +877,8 @@ pub fn item_from_trash_entry(
|
||||||
let name = entry.name.to_string_lossy().into_owned();
|
let name = entry.name.to_string_lossy().into_owned();
|
||||||
let display_name = Item::display_name(&name);
|
let display_name = Item::display_name(&name);
|
||||||
|
|
||||||
|
let location = crate::trash::trash_item_path(&entry).map(Location::Path);
|
||||||
|
|
||||||
let (mime, icon_handle_grid, icon_handle_list, icon_handle_list_condensed) = match metadata.size
|
let (mime, icon_handle_grid, icon_handle_list, icon_handle_list_condensed) = match metadata.size
|
||||||
{
|
{
|
||||||
trash::TrashItemSize::Entries(_) => (
|
trash::TrashItemSize::Entries(_) => (
|
||||||
|
|
@ -904,7 +906,7 @@ pub fn item_from_trash_entry(
|
||||||
is_mount_point: false,
|
is_mount_point: false,
|
||||||
metadata: ItemMetadata::Trash { metadata, entry },
|
metadata: ItemMetadata::Trash { metadata, entry },
|
||||||
hidden: false,
|
hidden: false,
|
||||||
location_opt: None,
|
location_opt: location,
|
||||||
image_dimensions: (mime.type_() == mime::IMAGE)
|
image_dimensions: (mime.type_() == mime::IMAGE)
|
||||||
.then(|| image::image_dimensions(&original_path).ok())
|
.then(|| image::image_dimensions(&original_path).ok())
|
||||||
.flatten(),
|
.flatten(),
|
||||||
|
|
@ -925,6 +927,31 @@ pub fn item_from_trash_entry(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn item_from_trash_child(
|
||||||
|
path: PathBuf,
|
||||||
|
name: String,
|
||||||
|
metadata: fs::Metadata,
|
||||||
|
sizes: IconSizes,
|
||||||
|
) -> Option<Item> {
|
||||||
|
let original_path = crate::trash::original_path_for_trash_child(&path)?;
|
||||||
|
let entry = trash::TrashItem {
|
||||||
|
id: path.as_os_str().to_os_string(),
|
||||||
|
name: std::ffi::OsString::from(&name),
|
||||||
|
original_parent: original_path.parent()?.to_path_buf(),
|
||||||
|
time_deleted: 0,
|
||||||
|
};
|
||||||
|
let size = if metadata.is_dir() {
|
||||||
|
trash::TrashItemSize::Entries(0)
|
||||||
|
} else {
|
||||||
|
trash::TrashItemSize::Bytes(metadata.len())
|
||||||
|
};
|
||||||
|
Some(item_from_trash_entry(
|
||||||
|
entry,
|
||||||
|
trash::TrashItemMetadata { size },
|
||||||
|
sizes,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
fn get_filename_from_path(path: &Path) -> Result<String, String> {
|
fn get_filename_from_path(path: &Path) -> Result<String, String> {
|
||||||
Ok(match path.file_name() {
|
Ok(match path.file_name() {
|
||||||
Some(name_os) => name_os
|
Some(name_os) => name_os
|
||||||
|
|
@ -1040,7 +1067,12 @@ pub fn scan_path(tab_path: &PathBuf, sizes: IconSizes) -> Vec<Item> {
|
||||||
})
|
})
|
||||||
.ok()?;
|
.ok()?;
|
||||||
|
|
||||||
Some(item_from_entry(path, name, metadata, sizes))
|
let trash = crate::trash::is_trash_path(tab_path);
|
||||||
|
if trash {
|
||||||
|
item_from_trash_child(path, name, metadata, sizes)
|
||||||
|
} else {
|
||||||
|
Some(item_from_entry(path, name, metadata, sizes))
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
61
src/trash.rs
61
src/trash.rs
|
|
@ -1,11 +1,30 @@
|
||||||
use cosmic::widget;
|
use cosmic::widget;
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use crate::config::IconSizes;
|
use crate::config::IconSizes;
|
||||||
use crate::tab::{Item, SearchItem};
|
use crate::tab::{Item, SearchItem};
|
||||||
|
|
||||||
|
fn percent_decode(s: &str) -> Option<String> {
|
||||||
|
let (mut r, mut b) = (String::with_capacity(s.len()), s.bytes());
|
||||||
|
while let Some(c) = b.next() {
|
||||||
|
if c == b'%' {
|
||||||
|
let (hi, lo) = (b.next()?, b.next()?);
|
||||||
|
let h = |x: u8| match x {
|
||||||
|
b'0'..=b'9' => Some(x - b'0'),
|
||||||
|
b'a'..=b'f' => Some(x - b'a' + 10),
|
||||||
|
b'A'..=b'F' => Some(x - b'A' + 10),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
r.push((h(hi)? << 4 | h(lo)?) as char);
|
||||||
|
} else {
|
||||||
|
r.push(c as char);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(r)
|
||||||
|
}
|
||||||
|
|
||||||
pub trait TrashExt {
|
pub trait TrashExt {
|
||||||
fn is_empty() -> bool {
|
fn is_empty() -> bool {
|
||||||
true
|
true
|
||||||
|
|
@ -49,6 +68,46 @@ pub trait TrashExt {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Derive the actual filesystem path of a trashed item from its .trashinfo path
|
||||||
|
/// (or return the id directly if it's already a filesystem path).
|
||||||
|
pub fn trash_item_path(item: &trash::TrashItem) -> Option<PathBuf> {
|
||||||
|
let id_path = Path::new(&item.id);
|
||||||
|
if id_path.extension().map_or(false, |e| e == "trashinfo") {
|
||||||
|
let trash_root = id_path.parent()?.parent()?;
|
||||||
|
let file_name = id_path.file_stem()?;
|
||||||
|
Some(trash_root.join("files").join(file_name))
|
||||||
|
} else {
|
||||||
|
Some(PathBuf::from(&item.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// For a path inside a trash `files/` directory, reconstruct the original path
|
||||||
|
/// by reading the parent `.trashinfo` file.
|
||||||
|
///
|
||||||
|
/// Given `~/.local/share/Trash/files/folder/sub/file.txt`:
|
||||||
|
/// - The top-level trashed item is `folder`
|
||||||
|
/// - Read `~/.local/share/Trash/info/folder.trashinfo` to get the original path
|
||||||
|
/// - Compute: `<original_path>/sub/file.txt`
|
||||||
|
pub fn original_path_for_trash_child(p: &Path) -> Option<PathBuf> {
|
||||||
|
let files = p.ancestors().find(|a| a.ends_with("files"))?;
|
||||||
|
let root = files.parent()?;
|
||||||
|
let top = p.strip_prefix(files).ok()?.components().next()?;
|
||||||
|
let info =
|
||||||
|
std::fs::read_to_string(root.join("info").join(top).with_extension("trashinfo")).ok()?;
|
||||||
|
let orig = percent_decode(info.lines().find_map(|l| l.strip_prefix("Path="))?.trim())?;
|
||||||
|
let rel = p.strip_prefix(files.join(top)).ok()?;
|
||||||
|
let mut result = PathBuf::from(&orig);
|
||||||
|
if !rel.as_os_str().is_empty() {
|
||||||
|
result.push(rel);
|
||||||
|
}
|
||||||
|
Some(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check whether a path is inside any trash `files/` directory.
|
||||||
|
pub fn is_trash_path(path: &Path) -> bool {
|
||||||
|
path.ancestors().any(|a| a.ends_with("files"))
|
||||||
|
}
|
||||||
|
|
||||||
pub struct Trash;
|
pub struct Trash;
|
||||||
|
|
||||||
// This config statement is from trash::os_limited
|
// This config statement is from trash::os_limited
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue