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:
ZlordHUN 2026-06-29 18:39:12 +02:00 committed by GitHub
parent 50deb28a11
commit 49107187a4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 154 additions and 29 deletions

9
Cargo.lock generated
View file

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

View file

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

View file

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

View file

@ -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(),

View file

@ -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();
} }

View file

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