feat: add SHA256 checksums to file properties

This commit is contained in:
Fred 2026-06-26 16:57:13 +02:00 committed by GitHub
parent cd427c746c
commit 4d6788c9fd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 170 additions and 27 deletions

1
Cargo.lock generated
View file

@ -1425,6 +1425,7 @@ dependencies = [
"rust-embed", "rust-embed",
"rustc-hash 2.1.2", "rustc-hash 2.1.2",
"serde", "serde",
"sha2 0.10.9",
"shlex 1.3.0", "shlex 1.3.0",
"slotmap", "slotmap",
"tar", "tar",

View file

@ -56,6 +56,7 @@ slotmap = "1.1.1"
recently-used-xbel = "1.2.0" recently-used-xbel = "1.2.0"
zip = "8" zip = "8"
md-5 = "0.10.6" md-5 = "0.10.6"
sha2 = "0.10"
png = "0.18" png = "0.18"
jxl-oxide = { version = "0.12.5", features = ["image"] } jxl-oxide = { version = "0.12.5", features = ["image"] }
num_cpus = "1.17.0" num_cpus = "1.17.0"

View file

@ -303,6 +303,9 @@ item-created = Created: {$created}
item-modified = Modified: {$modified} item-modified = Modified: {$modified}
item-accessed = Accessed: {$accessed} item-accessed = Accessed: {$accessed}
calculating = Calculating... calculating = Calculating...
checksum = {$kind} checksum
calculate = Calculate
error = Error
## Settings ## Settings
settings = Settings settings = Settings

View file

@ -14,7 +14,7 @@ use tokio::sync::mpsc;
use super::{Mounter, MounterAuth, MounterItem, MounterItems, MounterMessage}; use super::{Mounter, MounterAuth, MounterItem, MounterItems, MounterMessage};
use crate::config::IconSizes; use crate::config::IconSizes;
use crate::err_str; use crate::err_str;
use crate::tab::{self, DirSize, ItemMetadata, ItemThumbnail, Location}; use crate::tab::{self, ChecksumState, DirSize, ItemMetadata, ItemThumbnail, Location};
const TARGET_URI_ATTRIBUTE: &str = "standard::target-uri"; const TARGET_URI_ATTRIBUTE: &str = "standard::target-uri";
@ -216,6 +216,7 @@ fn network_scan(uri: &str, sizes: IconSizes) -> Result<Vec<tab::Item>, String> {
//TODO: scan directory size on gvfs mounts? //TODO: scan directory size on gvfs mounts?
dir_size: DirSize::NotDirectory, dir_size: DirSize::NotDirectory,
cut: false, cut: false,
checksums: ChecksumState::default(),
}); });
} }
Ok(items) Ok(items)

View file

@ -28,6 +28,7 @@ use jiff_icu::ConvertFrom;
use mime_guess::{Mime, mime}; use mime_guess::{Mime, mime};
use rustc_hash::FxHashMap; use rustc_hash::FxHashMap;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::borrow::Cow; use std::borrow::Cow;
use std::cell::Cell; use std::cell::Cell;
use std::cmp::{Ordering, Reverse}; use std::cmp::{Ordering, Reverse};
@ -731,6 +732,7 @@ pub fn item_from_gvfs_info(path: PathBuf, file_info: gio::FileInfo, sizes: IconS
overlaps_drag_rect: false, overlaps_drag_rect: false,
dir_size, dir_size,
cut: false, cut: false,
checksums: ChecksumState::default(),
} }
} }
@ -862,6 +864,7 @@ pub fn item_from_entry(
overlaps_drag_rect: false, overlaps_drag_rect: false,
dir_size, dir_size,
cut: false, cut: false,
checksums: ChecksumState::default(),
} }
} }
@ -918,6 +921,7 @@ pub fn item_from_trash_entry(
overlaps_drag_rect: false, overlaps_drag_rect: false,
dir_size: DirSize::NotDirectory, dir_size: DirSize::NotDirectory,
cut: false, cut: false,
checksums: ChecksumState::default(),
} }
} }
@ -1341,6 +1345,7 @@ pub fn scan_desktop(
overlaps_drag_rect: false, overlaps_drag_rect: false,
dir_size: DirSize::NotDirectory, dir_size: DirSize::NotDirectory,
cut: false, cut: false,
checksums: ChecksumState::default(),
}); });
} }
@ -1762,6 +1767,9 @@ pub enum Message {
HighlightDeactivate(usize), HighlightDeactivate(usize),
HighlightActivate(usize), HighlightActivate(usize),
DirectorySize(PathBuf, DirSize), DirectorySize(PathBuf, DirSize),
Checksums(PathBuf, ChecksumState),
CalculateChecksums(PathBuf),
CopyChecksum(String),
ImageDecoded(PathBuf, u32, u32, Vec<u8>, Option<(u32, u32)>, u64), // path, width, height, pixels, display_size, generation ImageDecoded(PathBuf, u32, u32, Vec<u8>, Option<(u32, u32)>, u64), // path, width, height, pixels, display_size, generation
} }
@ -1789,6 +1797,23 @@ pub enum DirSize {
Error(String), Error(String),
} }
/// Checksums computed for a file. Only SHA256 is currently exposed; see
/// [`calculate_checksums`] for how to add more.
#[derive(Clone, Debug, Default)]
pub struct FileChecksums {
pub sha256: String,
}
/// State of checksum computation for a file.
#[derive(Clone, Debug, Default)]
pub enum ChecksumState {
#[default]
NotCalculated,
Calculating,
Calculated(FileChecksums),
Error(String),
}
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub enum ItemMetadata { pub enum ItemMetadata {
Path { Path {
@ -2240,6 +2265,7 @@ pub struct Item {
pub cut: bool, pub cut: bool,
pub overlaps_drag_rect: bool, pub overlaps_drag_rect: bool,
pub dir_size: DirSize, pub dir_size: DirSize,
pub checksums: ChecksumState,
} }
impl Item { impl Item {
@ -2516,6 +2542,55 @@ impl Item {
} }
column = column.push(details); column = column.push(details);
if let Some(metadata) = self.file_metadata()
&& !metadata.is_dir()
&& let Some(path) = self.path_opt()
{
let control: Element<'_, Message> = match &self.checksums {
ChecksumState::NotCalculated => widget::button::standard(fl!("calculate"))
.on_press(Message::CalculateChecksums(path.clone()))
.into(),
ChecksumState::Calculating => widget::row::with_capacity(2)
.align_y(Alignment::Center)
.spacing(space_xxxs)
.push(widget::indeterminate_circular().size(16.0))
.push(widget::text::body(fl!("calculating")))
.into(),
ChecksumState::Calculated(checksums) => {
let value = checksums.sha256.clone();
// Middle-ellipsize the digest to fit, full value on hover.
let value_text = widget::tooltip(
widget::text::body(value.clone())
.font(cosmic::font::mono())
.width(Length::Fill)
.wrapping(text::Wrapping::None)
.ellipsize(text::Ellipsize::Middle(text::EllipsizeHeightLimit::Lines(
1,
))),
widget::text::body(value.clone()),
widget::tooltip::Position::Bottom,
);
let copy_button = widget::button::icon(
widget::icon::from_name("edit-copy-symbolic").size(16),
)
.on_press(Message::CopyChecksum(value.clone()))
.tooltip(fl!("copy"));
widget::row::with_capacity(2)
.align_y(Alignment::Center)
.spacing(space_xxxs)
.push(value_text)
.push(copy_button)
.into()
}
ChecksumState::Error(err) => {
widget::text::body(format!("{}: {}", fl!("error"), err)).into()
}
};
settings.push(
widget::settings::item::builder(fl!("checksum", kind = "SHA256")).control(control),
);
}
if let Some(path) = self.path_opt() if let Some(path) = self.path_opt()
&& self.selected && self.selected
{ {
@ -2713,6 +2788,32 @@ async fn calculate_dir_size(path: &Path, controller: Controller) -> Result<u64,
Ok(total) Ok(total)
} }
/// Calculate file checksums in a single pass over the file. To add another
/// digest, hash it alongside `sha256_hasher` in the loop below and add a field
/// to [`FileChecksums`]; the file is only read once.
async fn calculate_checksums(path: &Path) -> Result<FileChecksums, String> {
let path = path.to_path_buf();
tokio::task::spawn_blocking(move || {
let mut file = File::open(&path).map_err(|e| e.to_string())?;
let mut sha256_hasher = Sha256::new();
let mut buffer = [0u8; 8192];
loop {
let bytes_read = file.read(&mut buffer).map_err(|e| e.to_string())?;
if bytes_read == 0 {
break;
}
sha256_hasher.update(&buffer[..bytes_read]);
}
Ok(FileChecksums {
sha256: format!("{:x}", sha256_hasher.finalize()),
})
})
.await
.map_err(|e| e.to_string())?
}
fn folder_name<P: AsRef<Path>>(path: P) -> (String, bool) { fn folder_name<P: AsRef<Path>>(path: P) -> (String, bool) {
let path = path.as_ref(); let path = path.as_ref();
let mut found_home = false; let mut found_home = false;
@ -3176,10 +3277,7 @@ impl Tab {
} }
fn rows_per_page(&self) -> usize { fn rows_per_page(&self) -> usize {
let viewport_height = self let viewport_height = self.item_view_size_opt.get().map_or(0.0, |s| s.height);
.item_view_size_opt
.get()
.map_or(0.0, |s| s.height);
let row_height = self let row_height = self
.select_focus .select_focus
.and_then(|i| self.items_opt.as_ref()?.get(i)) .and_then(|i| self.items_opt.as_ref()?.get(i))
@ -3891,8 +3989,7 @@ impl Tab {
} }
Message::ItemPageDown => { Message::ItemPageDown => {
self.dehighlight_all(); self.dehighlight_all();
if let Some((row, col)) = if let Some((row, col)) = self.select_focus_pos_opt().or(self.select_last_pos_opt())
self.select_focus_pos_opt().or(self.select_last_pos_opt())
{ {
if self.select_focus.is_none() { if self.select_focus.is_none() {
self.select_position(row, col, mod_shift); self.select_position(row, col, mod_shift);
@ -3903,16 +4000,13 @@ impl Tab {
if !self.select_position(target_row, col, mod_shift) { if !self.select_position(target_row, col, mod_shift) {
// Fall back to the last item at or before target_row // Fall back to the last item at or before target_row
let best = self let best = self.items_opt.as_ref().and_then(|items| {
.items_opt items
.as_ref() .iter()
.and_then(|items| { .filter_map(|item| item.pos_opt.get())
items .filter(|(r, _)| *r <= target_row)
.iter() .max()
.filter_map(|item| item.pos_opt.get()) });
.filter(|(r, _)| *r <= target_row)
.max()
});
if let Some((best_row, best_col)) = best { if let Some((best_row, best_col)) = best {
self.select_position(best_row, best_col, mod_shift); self.select_position(best_row, best_col, mod_shift);
} }
@ -3950,16 +4044,13 @@ impl Tab {
if !self.select_position(target_row, col, mod_shift) { if !self.select_position(target_row, col, mod_shift) {
// Fall back to the first item at or after target_row // Fall back to the first item at or after target_row
let best = self let best = self.items_opt.as_ref().and_then(|items| {
.items_opt items
.as_ref() .iter()
.and_then(|items| { .filter_map(|item| item.pos_opt.get())
items .filter(|(r, _)| *r >= target_row)
.iter() .min()
.filter_map(|item| item.pos_opt.get()) });
.filter(|(r, _)| *r >= target_row)
.min()
});
if let Some((best_row, best_col)) = best { if let Some((best_row, best_col)) = best {
self.select_position(best_row, best_col, mod_shift); self.select_position(best_row, best_col, mod_shift);
} }
@ -4662,6 +4753,52 @@ impl Tab {
} }
} }
} }
Message::Checksums(path, checksum_state) => {
let location = Location::Path(path);
if let Some(ref mut item) = self.parent_item_opt
&& item.location_opt.as_ref() == Some(&location)
{
item.checksums = checksum_state.clone();
}
if let Some(ref mut items) = self.items_opt {
for item in items.iter_mut() {
if item.location_opt.as_ref() == Some(&location) {
item.checksums = checksum_state;
break;
}
}
}
}
Message::CalculateChecksums(path) => {
let location = Location::Path(path.clone());
if let Some(ref mut item) = self.parent_item_opt
&& item.location_opt.as_ref() == Some(&location)
{
item.checksums = ChecksumState::Calculating;
}
if let Some(ref mut items) = self.items_opt {
for item in items.iter_mut() {
if item.location_opt.as_ref() == Some(&location) {
item.checksums = ChecksumState::Calculating;
break;
}
}
}
commands.push(Command::Iced(
cosmic::Task::future(async move {
match calculate_checksums(&path).await {
Ok(checksums) => {
Message::Checksums(path, ChecksumState::Calculated(checksums))
}
Err(err) => Message::Checksums(path, ChecksumState::Error(err)),
}
})
.into(),
));
}
Message::CopyChecksum(value) => {
commands.push(Command::Iced(cosmic::iced::clipboard::write(value).into()));
}
} }
// Scroll to top if needed // Scroll to top if needed