2026-08-02 19:04:30 -04:00
|
|
|
|
//! The Duplicates tab: groups of files sharing a content hash.
|
|
|
|
|
|
|
|
|
|
|
|
use quicksearch_core::search::DuplicateGroup;
|
|
|
|
|
|
|
|
|
|
|
|
use crate::format::{group_thousands, human_size};
|
|
|
|
|
|
use crate::platform;
|
2026-08-09 16:25:43 -04:00
|
|
|
|
use crate::ui_util::hint;
|
2026-08-02 19:04:30 -04:00
|
|
|
|
|
|
|
|
|
|
pub enum DupState {
|
|
|
|
|
|
NotLoaded,
|
|
|
|
|
|
Loading,
|
|
|
|
|
|
Loaded(Vec<DuplicateGroup>),
|
|
|
|
|
|
Error(String),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pub struct DuplicatesTab {
|
|
|
|
|
|
pub state: DupState,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// What the tab asks the app to do after this frame.
|
|
|
|
|
|
#[derive(Default)]
|
|
|
|
|
|
pub struct DuplicatesActions {
|
|
|
|
|
|
pub refresh: bool,
|
2026-08-17 19:26:18 -04:00
|
|
|
|
/// Every member of one group, to be read through and compared byte for
|
|
|
|
|
|
/// byte. Group-scoped whichever row it was asked for from: the question
|
|
|
|
|
|
/// "is this row really a duplicate" is a question about the group.
|
|
|
|
|
|
pub verify: Option<Vec<String>>,
|
2026-08-02 19:04:30 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 19:26:18 -04:00
|
|
|
|
/// The entry both context menus carry. Named for what it settles, since the
|
|
|
|
|
|
/// grouping itself never claimed more than a shared size and head.
|
|
|
|
|
|
const VERIFY_LABEL: &str = "Verify copies are identical…";
|
|
|
|
|
|
const VERIFY_TIP: &str = "Reads every file in the group in full and compares them byte for \
|
|
|
|
|
|
byte. Grouping only reads each file's size and how it begins.";
|
|
|
|
|
|
|
2026-08-02 19:04:30 -04:00
|
|
|
|
impl DuplicatesTab {
|
|
|
|
|
|
pub fn new() -> DuplicatesTab {
|
|
|
|
|
|
DuplicatesTab {
|
|
|
|
|
|
state: DupState::NotLoaded,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 19:26:18 -04:00
|
|
|
|
/// `verify_open` is the verification window being up — running or showing
|
|
|
|
|
|
/// a result. There is one of it, so the entry greys out rather than
|
|
|
|
|
|
/// replacing what someone is reading.
|
|
|
|
|
|
pub fn ui(&mut self, ui: &mut egui::Ui, verify_open: bool) -> DuplicatesActions {
|
2026-08-02 19:04:30 -04:00
|
|
|
|
let mut actions = DuplicatesActions::default();
|
|
|
|
|
|
|
|
|
|
|
|
ui.horizontal(|ui| {
|
|
|
|
|
|
let loading = matches!(self.state, DupState::Loading);
|
2026-08-02 22:21:39 -04:00
|
|
|
|
if ui
|
|
|
|
|
|
.add_enabled(!loading, egui::Button::new("Refresh"))
|
|
|
|
|
|
.clicked()
|
|
|
|
|
|
{
|
2026-08-02 19:04:30 -04:00
|
|
|
|
actions.refresh = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
if loading {
|
|
|
|
|
|
ui.add(egui::Spinner::new().size(16.0));
|
|
|
|
|
|
ui.label("Scanning for duplicates…");
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
ui.separator();
|
|
|
|
|
|
|
|
|
|
|
|
match &self.state {
|
|
|
|
|
|
DupState::NotLoaded => {
|
|
|
|
|
|
ui.label(
|
|
|
|
|
|
egui::RichText::new("Press Refresh to scan the index for duplicate files.")
|
|
|
|
|
|
.weak(),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-08-09 16:25:43 -04:00
|
|
|
|
// The header row above already shows the spinner and its label.
|
2026-08-02 19:04:30 -04:00
|
|
|
|
DupState::Loading => {}
|
|
|
|
|
|
DupState::Error(e) => {
|
|
|
|
|
|
ui.colored_label(ui.visuals().error_fg_color, e);
|
|
|
|
|
|
}
|
|
|
|
|
|
DupState::Loaded(groups) => {
|
|
|
|
|
|
if groups.is_empty() {
|
|
|
|
|
|
ui.label("No duplicate files found.");
|
|
|
|
|
|
return actions;
|
|
|
|
|
|
}
|
|
|
|
|
|
if groups.len() == 500 {
|
2026-08-09 16:25:43 -04:00
|
|
|
|
ui.label(hint("Showing the 500 largest groups."));
|
2026-08-02 19:04:30 -04:00
|
|
|
|
}
|
2026-08-02 22:21:39 -04:00
|
|
|
|
let scroll = egui::ScrollArea::vertical()
|
|
|
|
|
|
.auto_shrink([false; 2])
|
|
|
|
|
|
.show(ui, |ui| {
|
|
|
|
|
|
for (i, group) in groups.iter().enumerate() {
|
|
|
|
|
|
let name = group
|
|
|
|
|
|
.members
|
|
|
|
|
|
.first()
|
|
|
|
|
|
.map(|m| m.1.as_str())
|
|
|
|
|
|
.unwrap_or("(unknown)");
|
|
|
|
|
|
let title = format!(
|
|
|
|
|
|
"{} × {}: {} reclaimable ({} total)",
|
|
|
|
|
|
group_thousands(group.count as u64),
|
|
|
|
|
|
name,
|
|
|
|
|
|
human_size(group.redundant_size.max(0) as u64),
|
|
|
|
|
|
human_size(group.total_size.max(0) as u64),
|
|
|
|
|
|
);
|
2026-08-17 19:26:18 -04:00
|
|
|
|
let header =
|
|
|
|
|
|
egui::CollapsingHeader::new(title)
|
|
|
|
|
|
.id_salt(i)
|
|
|
|
|
|
.show(ui, |ui| {
|
|
|
|
|
|
for (_, _, path, size, _) in &group.members {
|
|
|
|
|
|
ui.horizontal(|ui| {
|
|
|
|
|
|
ui.label(human_size(*size));
|
|
|
|
|
|
let response = ui.add(
|
|
|
|
|
|
egui::Label::new(
|
|
|
|
|
|
egui::RichText::new(path).monospace(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.sense(egui::Sense::click()),
|
|
|
|
|
|
);
|
|
|
|
|
|
if response.double_clicked() {
|
2026-08-02 22:21:39 -04:00
|
|
|
|
platform::open_file(path);
|
|
|
|
|
|
}
|
2026-08-17 19:26:18 -04:00
|
|
|
|
response.context_menu(|ui| {
|
|
|
|
|
|
if ui.button("Open File").clicked() {
|
|
|
|
|
|
platform::open_file(path);
|
|
|
|
|
|
ui.close();
|
|
|
|
|
|
}
|
|
|
|
|
|
if ui.button("Open containing folder").clicked()
|
|
|
|
|
|
{
|
|
|
|
|
|
platform::reveal_in_folder(path);
|
|
|
|
|
|
ui.close();
|
|
|
|
|
|
}
|
|
|
|
|
|
ui.separator();
|
|
|
|
|
|
if verify_entry(ui, verify_open) {
|
|
|
|
|
|
actions.verify = Some(member_paths(group));
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-08-02 22:21:39 -04:00
|
|
|
|
});
|
2026-08-17 19:26:18 -04:00
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
// Also on the group's own row: the question is
|
|
|
|
|
|
// about the group, and the rows it is about are
|
|
|
|
|
|
// behind a collapsed header until they are not.
|
|
|
|
|
|
header.header_response.context_menu(|ui| {
|
|
|
|
|
|
if verify_entry(ui, verify_open) {
|
|
|
|
|
|
actions.verify = Some(member_paths(group));
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-08-02 22:21:39 -04:00
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-08-02 20:21:19 -04:00
|
|
|
|
crate::ui_util::more_below_hint(ui, &scroll);
|
2026-08-02 19:04:30 -04:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
actions
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-08-17 19:26:18 -04:00
|
|
|
|
|
|
|
|
|
|
/// The shared menu entry. Returns whether it was clicked, and closes the menu
|
|
|
|
|
|
/// when it was.
|
|
|
|
|
|
fn verify_entry(ui: &mut egui::Ui, open: bool) -> bool {
|
|
|
|
|
|
let clicked = ui
|
|
|
|
|
|
.add_enabled(!open, egui::Button::new(VERIFY_LABEL))
|
|
|
|
|
|
.on_hover_text(VERIFY_TIP)
|
|
|
|
|
|
.on_disabled_hover_text("Close the verification window first.")
|
|
|
|
|
|
.clicked();
|
|
|
|
|
|
if clicked {
|
|
|
|
|
|
ui.close();
|
|
|
|
|
|
}
|
|
|
|
|
|
clicked
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fn member_paths(group: &DuplicateGroup) -> Vec<String> {
|
|
|
|
|
|
group.members.iter().map(|m| m.2.clone()).collect()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
mod tests;
|