use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::oneshot;
use tokio_util::sync::CancellationToken;
use gravityfile_scan::ScanProgress;
use super::state::{AppMode, ScanResult, View};
use super::{App, Background, deletion, scanning, state};
use crate::ui::TreeState;
impl App {
pub(super) fn has_background(&self) -> bool {
self.scan_rx.is_some()
|| self.op_rx.is_some()
|| self.take_rx.is_some()
|| self.preview_rx.is_some()
|| self.archive_rx.is_some()
|| self.refresh_rx.is_some()
|| !self.listings.is_empty()
|| self.history_rx.is_some()
|| self.hooks_in_flight > 0
}
pub(super) async fn next_background(&mut self) -> Background {
tokio::select! {
result = async {
self.scan_rx.as_mut().expect("guarded scan receiver").recv().await
}, if self.scan_rx.is_some() => Background::Scan(result),
result = async {
self.op_rx.as_mut().expect("guarded operation receiver").recv().await
}, if self.op_rx.is_some() => Background::Operation(result),
take = async {
self.take_rx.as_mut().expect("guarded take receiver").recv().await
}, if self.take_rx.is_some() => Background::Take(take),
preview = async {
self.preview_rx.as_mut().expect("guarded preview receiver").await.ok()
}, if self.preview_rx.is_some() => Background::Preview(preview),
archive = async {
self.archive_rx.as_mut().expect("guarded archive receiver").await.ok()
}, if self.archive_rx.is_some() => Background::Archive(archive),
refreshed = async {
self.refresh_rx.as_mut().expect("guarded refresh receiver").await.ok()
}, if self.refresh_rx.is_some() => Background::Refresh(refreshed),
listed = self.listing_rx.recv(), if !self.listings.is_empty() => Background::Listed(listed),
previous = async {
self.history_rx.as_mut().expect("guarded history receiver").await.ok()
}, if self.history_rx.is_some() => Background::History(previous),
results = self.hook_rx.recv(), if self.hooks_in_flight > 0 => Background::Hooks(results),
else => std::future::pending().await,
}
}
pub(super) fn handle_background(&mut self, background: Background) {
match background {
Background::Scan(Some(result)) | Background::Operation(Some(result)) => {
self.handle_scan_result(result)
}
Background::Scan(None) => self.handle_scan_channel_closed(),
Background::Operation(None) => self.handle_operation_channel_closed(),
Background::Take(take) => {
self.take_rx = None;
if let Some(ScanResult::TakeComplete { path, result }) = take {
self.finish_take(path, result);
} else {
self.error =
Some("Create-and-enter ended without completion; outcome unknown".into());
}
}
Background::Preview(preview) => self.finish_preview(preview),
Background::Archive(archive) => self.finish_archive_task(archive),
Background::Refresh(refreshed) => {
self.refresh_rx = None;
self.apply_refresh(refreshed.unwrap_or_default());
}
Background::Hooks(results) => self.finish_hook(results.unwrap_or_default()),
Background::History(previous) => {
self.history_rx = None;
self.history_baseline = previous.flatten();
}
Background::Listed(listed) => {
if let Some(listed) = listed {
self.finish_listing(listed);
}
}
}
if !self.queued_refresh.is_empty() {
self.refresh_paths(Vec::new());
}
self.needs_redraw = true;
}
pub(super) fn refresh_paths(&mut self, paths: Vec<PathBuf>) {
if self.snapshot.is_some() {
return;
}
self.queued_refresh.extend(paths);
if self.refresh_rx.is_some() || self.scan_rx.is_some() || self.op_rx.is_some() {
return;
}
let Some(tree) = &self.tree else {
self.queued_refresh.clear();
return;
};
let root = tree.root_path.clone();
if self.queued_refresh.contains(&root) {
self.start_scan();
return;
}
let mut paths: Vec<PathBuf> = std::mem::take(&mut self.queued_refresh)
.into_iter()
.filter(|path| path.starts_with(&root))
.collect();
paths.sort();
paths.dedup_by(|later, earlier| later.starts_with(earlier));
if paths.is_empty() {
return;
}
let config = tree.config.clone();
let (tx, rx) = oneshot::channel();
tokio::task::spawn_blocking(move || {
let refreshed = paths
.into_iter()
.map(|path| state::Refreshed {
entry: gravityfile_scan::scan_entry(&path, &config, CancellationToken::new())
.map_err(|error| error.to_string()),
path,
})
.collect();
let _ = tx.send(refreshed);
});
self.refresh_rx = Some(rx);
}
pub(super) fn refresh_view(&mut self) {
self.marked.clear();
if self.tree.is_some() {
self.refresh_paths(vec![self.nav.view_root.clone()]);
} else {
self.start_scan();
}
}
pub(super) fn apply_refresh(&mut self, refreshed: Vec<state::Refreshed>) {
let mut warnings = Vec::new();
let mut errors = Vec::new();
let mut changed = Vec::new();
if let Some(tree) = self.tree.as_mut() {
let tree = Arc::make_mut(tree);
for state::Refreshed { path, entry } in refreshed {
tree.remove(&path);
match entry {
Ok(Some(entry)) => {
warnings.extend(entry.warnings);
if let Some(parent) = path.parent() {
tree.insert(parent, entry.node);
}
}
Ok(None) => {}
Err(error) => errors.push(format!("{}: {error}", path.display())),
}
changed.push(path);
}
tree.recount();
Self::order_new_subtree(&mut tree.root, self.sort_mode);
}
self.merge_scan_warnings(&warnings);
if !errors.is_empty() {
self.error = Some(format!("Cannot refresh {}", errors.join("; ")));
}
self.duplicates = None;
self.age_report = None;
if let Some(cache) = &mut self.scanned_cache {
cache.retain(|cached, _| {
!changed
.iter()
.any(|path| path.starts_with(cached) || cached.starts_with(path))
});
}
self.leave_vanished_view();
self.update_cached_tree_len();
self.update_cached_miller_len();
if self.view == View::Treemap {
self.update_cached_treemap_len();
}
}
pub(super) fn start_scan(&mut self) {
if self.refuse_in_snapshot() {
return;
}
self.scan_token.cancel();
self.scan_token = CancellationToken::new();
self.refresh_rx = None;
self.queued_refresh.clear();
self.scan_progress = Some(ScanProgress::new());
self.duplicates = None;
self.age_report = None;
self.analyzing = false;
self.warnings.clear();
self.selected_warning = 0;
self.scan_rx = Some(scanning::start_scan(
self.path.clone(),
self.user_settings.show_hidden,
self.apparent_size,
self.user_settings.max_scan_entries,
self.scan_token.clone(),
));
self.fire(gravityfile_plugin::Hook::OnScanStart {
path: self.path.clone(),
});
}
pub(super) fn record_history(&mut self) {
use gravityfile_core::history::{self, Record};
let (Some(file), Some(tree)) = (self.history_file.clone(), &self.tree) else {
return;
};
let record = Record::of(tree);
self.history_baseline = None;
let (sender, receiver) = oneshot::channel();
tokio::task::spawn_blocking(move || {
let previous = history::read(&file, Some(&record.root))
.ok()
.and_then(|records| {
history::previous(&records, &record, std::time::Duration::from_secs(60))
.cloned()
});
if let Err(error) = history::append(&file, &record) {
tracing::warn!(%error, file = %file.display(), "Cannot record the scan");
}
let _ = sender.send(previous);
});
self.history_rx = Some(receiver);
}
pub(super) fn cancel_scan(&mut self) {
self.scan_token.cancel();
self.scan_token = CancellationToken::new();
self.scan_rx = None;
self.scan_progress = None;
self.analyzing = false;
}
pub(super) fn cancel_background_work(&mut self) {
let was_scanning = self.scan_progress.is_some();
let was_analyzing = self.analyzing;
self.cancel_scan();
if was_scanning {
self.operation_message =
Some((true, "Scan cancelled - showing current listing".to_string()));
} else if was_analyzing {
self.operation_message = Some((true, "Analysis cancelled".to_string()));
}
}
pub(super) fn maybe_start_analysis_for_view(&mut self) {
if !matches!(self.view, View::Duplicates | View::Age) {
return;
}
self.start_analysis_for_current_tree();
}
pub(super) fn start_analysis_for_current_tree(&mut self) {
if self.scan_progress.is_some() || self.analyzing || !self.has_full_scan {
return;
}
if self.duplicates.is_some() && self.age_report.is_some() {
return;
}
let Some(tree) = self.tree.as_ref().map(Arc::clone) else {
return;
};
self.analyzing = true;
let compare_contents = self.snapshot.is_none();
self.scan_rx = Some(scanning::start_analysis(
tree,
compare_contents,
self.scan_token.clone(),
));
}
pub(super) fn merge_scan_warnings(&mut self, warnings: &[gravityfile_core::ScanWarning]) {
let mut seen: HashSet<&gravityfile_core::ScanWarning> = self.warnings.iter().collect();
let additional: Vec<_> = warnings
.iter()
.filter(|warning| seen.insert(warning))
.cloned()
.collect();
drop(seen);
self.warnings.extend(additional);
}
pub(super) fn handle_scan_channel_closed(&mut self) {
let analyzing = self.analyzing;
self.cancel_scan();
self.error = Some(if analyzing {
"Analysis ended without a result".to_string()
} else {
"Scan ended without a result; showing the previous listing".to_string()
});
self.needs_redraw = true;
}
pub(super) fn handle_scan_result(&mut self, result: ScanResult) {
match result {
ScanResult::BulkRenameComplete(result) => {
self.op_rx = None;
let mut touched = vec![self.nav.view_root.clone()];
self.operation_message = Some(match result {
Ok(renames) => {
self.marked.clear();
let count = renames.len();
for (source, new_path) in renames.clone() {
self.fire(gravityfile_plugin::Hook::OnRenameComplete {
source,
new_path,
});
}
touched = renames
.iter()
.flat_map(|(from, to)| [from.clone(), to.clone()])
.collect();
self.undo_log.record_renames(renames);
(true, format!("Renamed {count} items"))
}
Err(error) => (false, format!("Bulk rename failed: {error}")),
});
self.mode = AppMode::Normal;
self.refresh_paths(touched);
}
ScanResult::Progress(progress) => {
self.scan_progress = Some(progress);
}
ScanResult::ScanComplete(Ok(tree)) => {
let root_path = tree.root_path.clone();
let scan_path = root_path.clone();
let total_files = tree.stats.total_files;
let total_dirs = tree.stats.total_dirs;
let total_size = tree.stats.total_size;
self.merge_scan_warnings(&tree.warnings);
self.install_tree(tree);
self.record_history();
if self.nav.tree_state.expanded.is_empty()
|| !self
.nav
.tree_state
.expanded
.iter()
.any(|p| p.starts_with(&root_path))
{
self.nav.tree_state = TreeState::new(root_path);
} else {
self.nav.tree_state.expand(&root_path);
}
self.update_cached_tree_len();
self.update_cached_miller_len();
self.update_cached_treemap_len();
self.update_cached_parent();
if self.nav.tree_state.selected >= self.cached_tree_len && self.cached_tree_len > 0
{
self.nav.tree_state.selected = self.cached_tree_len - 1;
}
self.scan_progress = None;
self.has_full_scan = true;
self.analyzing = false;
self.scan_rx = None;
self.maybe_start_analysis_for_view();
self.fire(gravityfile_plugin::Hook::OnScanComplete {
path: scan_path,
total_files,
total_dirs,
total_size,
});
}
ScanResult::ScanComplete(Err(e)) => {
self.error = Some(e.to_string());
self.fire(gravityfile_plugin::Hook::OnScanError {
path: self.path.clone(),
error: e.to_string(),
});
self.scan_progress = None;
self.scan_rx = None;
self.analyzing = false;
}
ScanResult::AnalysisComplete {
duplicates,
age_report,
duplicate_scope: (root, counts),
} => {
if self.snapshot.is_none() {
self.fire(gravityfile_plugin::Hook::OnDuplicatesFound {
group_count: duplicates.group_count,
wasted_bytes: duplicates.total_wasted_space,
});
}
self.fire(gravityfile_plugin::Hook::OnAgeAnalysisComplete {
stale_dirs: age_report.stale_directories.len(),
files: age_report.total_files,
average_age_secs: age_report.average_age.as_secs(),
});
let duplicates = Arc::new(duplicates);
self.duplicate_filter.seed(&duplicates, root, counts);
self.duplicates = Some(duplicates);
self.age_report = Some(age_report);
self.analyzing = false;
self.scan_rx = None;
}
ScanResult::AnalysisFailed(error) => {
self.analyzing = false;
self.scan_rx = None;
self.error = Some(format!("Analysis failed: {error}"));
}
ScanResult::AnalysisCancelled => {
self.analyzing = false;
self.scan_rx = None;
}
ScanResult::DeletionProgress(progress) => {
self.deletion_progress = Some(progress);
}
ScanResult::DeletionComplete {
mode,
deleted,
failed,
bytes_freed,
errors,
} => {
self.set_operation_errors(gravityfile_ops::OperationType::Delete, errors);
self.operation_message = None;
self.deletion_progress = None;
self.op_rx = None;
let (success, msg) =
deletion::format_deletion_result(mode, deleted.len(), failed, bytes_freed);
self.fire(gravityfile_plugin::Hook::OnDeleteComplete {
deleted: deleted.len(),
failed,
bytes_freed,
});
let touched = deleted.clone();
if mode == gravityfile_ops::DeleteMode::Trash {
self.undo_log.record_trash(deleted);
}
self.deletion_message = Some((success, msg));
self.mode = AppMode::Normal;
self.refresh_paths(touched);
}
ScanResult::TakeComplete { path, result } => self.finish_take(path, result),
ScanResult::OperationProgress(progress) => {
self.operation_progress = Some(progress);
}
ScanResult::OperationConflict(conflict) => {
if self.op_token.is_cancelled() {
conflict.resolve(gravityfile_ops::ConflictResolution::Abort);
return;
}
self.pending_conflict = Some(conflict);
self.mode = AppMode::ConflictResolution;
}
ScanResult::OperationComplete {
cancelled,
operation_type,
succeeded,
failed,
skipped,
bytes_processed,
errors,
undo_info,
undo_remaining,
touched,
} => {
self.operation_progress = None;
self.op_rx = None;
let error_count = errors.iter().fold(0usize, |total, error| {
total.saturating_add(error.represented_count())
});
let undo_incomplete = undo_remaining.is_some();
if let Some(entry) = undo_remaining {
let count = entry.operation.item_count();
self.undo_log.push(entry);
self.error = Some(format!(
"Undo incomplete: {count} item(s) were not restored; fix the cause and press u to retry"
));
}
self.set_operation_errors(operation_type, errors);
match (operation_type, &undo_info) {
(gravityfile_ops::OperationType::Copy, _) => {
self.fire(gravityfile_plugin::Hook::OnCopyComplete {
succeeded,
failed,
bytes_copied: bytes_processed,
});
}
(gravityfile_ops::OperationType::Move, _) => {
self.fire(gravityfile_plugin::Hook::OnMoveComplete { succeeded, failed });
}
(
gravityfile_ops::OperationType::Rename,
Some(gravityfile_ops::UndoableOperation::FilesRenamed { renames }),
) => {
for (source, new_path) in renames.clone() {
self.fire(gravityfile_plugin::Hook::OnRenameComplete {
source,
new_path,
});
}
}
_ => {}
}
if let Some(undo) = undo_info {
let description = match operation_type {
gravityfile_ops::OperationType::Copy => {
format!("Copied {} items", succeeded)
}
gravityfile_ops::OperationType::Move => {
format!("Moved {} items", succeeded)
}
gravityfile_ops::OperationType::Rename => "Renamed item".to_string(),
gravityfile_ops::OperationType::CreateFile => "Created file".to_string(),
gravityfile_ops::OperationType::CreateDirectory => {
"Created directory".to_string()
}
_ => format!("{:?} operation", operation_type),
};
self.undo_log.record(undo, description);
}
let action = match operation_type {
gravityfile_ops::OperationType::Copy => "Copied",
gravityfile_ops::OperationType::Move => "Moved",
gravityfile_ops::OperationType::Delete => "Deleted",
gravityfile_ops::OperationType::Restore => "Restored",
gravityfile_ops::OperationType::Rename => "Renamed",
gravityfile_ops::OperationType::CreateFile => "Created file",
gravityfile_ops::OperationType::CreateDirectory => "Created directory",
};
let success = failed == 0 && error_count == 0 && !cancelled && !undo_incomplete;
let mut msg = format!("{action} {succeeded} items");
if matches!(
operation_type,
gravityfile_ops::OperationType::Copy | gravityfile_ops::OperationType::Move
) && bytes_processed > 0
{
msg.push_str(&format!(" ({})", crate::ui::format_size(bytes_processed)));
}
if undo_incomplete {
msg.push_str("; undo incomplete");
}
if skipped > 0 {
msg.push_str(&format!(", {skipped} skipped"));
}
if failed > 0 {
msg.push_str(&format!(", {failed} failed"));
} else if error_count > 0 {
msg.push_str(&format!(", {error_count} errors"));
}
if cancelled {
msg.push_str("; operation cancelled");
}
self.operation_message = Some((success, msg));
self.mode = AppMode::Normal;
self.refresh_paths(touched);
}
}
}
}