use std::{hash::Hash, io::{Read, Write}, ops::Deref, path::Path}; use anyhow::{Result, anyhow}; use hashbrown::HashMap; use scopeguard::defer; use tokio::io::AsyncWriteExt; use yazi_binding::Permit; use yazi_config::{YAZI, opener::OpenerRuleArc}; use yazi_dds::Pubsub; use yazi_fs::{FilesOp, Splatter, engine::{Engine, FileBuilder, local::Local}, max_common_root, path::skip_url}; use yazi_macro::{err, succ, writef}; use yazi_parser::VoidForm; use yazi_proxy::TasksProxy; use yazi_scheduler::{AppProxy, NotifyProxy, process::ShellOpt}; use yazi_shared::{data::Data, path::PathDyn, strand::{AsStrand, AsStrandJoin, Strand, StrandBuf, StrandLike}, url::{AsUrl, UrlBuf, UrlLike}}; use yazi_term::YIELD_TO_SUBPROCESS; use yazi_tty::{TTY, sequence::EraseScreen}; use yazi_vfs::{engine::{self, Demand}, maybe_exists}; use yazi_watcher::WATCHER; use crate::{Actor, Ctx}; pub struct BulkRename; impl Actor for BulkRename { type Form = VoidForm; const NAME: &str = "bulk_rename"; fn act(cx: &mut Ctx, _: Self::Form) -> Result { let Some(opener) = Self::opener() else { succ!(NotifyProxy::push_warn("Bulk rename", "No text opener found")); }; let selected: Vec<_> = cx.tab().selected_or_hovered_urls().cloned().collect(); if selected.is_empty() { succ!(NotifyProxy::push_warn("Bulk rename", "No files selected")); } let root = max_common_root(&selected); let old: Vec<_> = selected.iter().enumerate().map(|(i, u)| Tuple::new(i, skip_url(u, root))).collect(); let cwd = cx.cwd().clone(); let batcher = cx.core.mgr.batcher.clone(); tokio::spawn(async move { let tmp = YAZI.preview.tmpfile("bulk-rename"); let mut rw = Demand::default().write(true).create_new(true).open(&tmp).await?; rw.write_all(old.join(Strand::Utf8("\n")).encoded_bytes()).await?; defer! { let tmp = tmp.clone(); batcher.drain(&tmp); tokio::spawn(async move { Local::regular(&tmp).remove_file().await }); } batcher.prime(&tmp); TasksProxy::process_exec(ShellOpt { cwd, cmd: Splatter::new(&[rw.into_file().await?]).splat(&opener.run), block: opener.block, orphan: opener.orphan, }) .await; let _permit = Permit::new(YIELD_TO_SUBPROCESS.acquire().await.unwrap(), AppProxy::resume()); AppProxy::stop().await; let new: Vec<_> = Local::regular(&tmp) .read_to_string() .await? .lines() .take(old.len()) .enumerate() .map(|(i, s)| Tuple::new(i, s)) .collect(); let decision = batcher.drain(&tmp); Self::r#do(root, old, new, selected, decision).await }); succ!(); } } impl BulkRename { async fn r#do( root: usize, old: Vec, new: Vec, selected: Vec, decision: Option, ) -> Result<()> { writef!(TTY.writer(), "{EraseScreen}\n")?; if old.len() != new.len() { #[rustfmt::skip] writef!(TTY.writer(), "Number of new and old file names mismatch (New: {}, Old: {}).\nPress to exit...", new.len(), old.len())?; TTY.reader().read_exact(&mut [0])?; return Ok(()); } let (old, new) = old.into_iter().zip(new).filter(|(o, n)| o != n).unzip(); let todo = Self::prioritized_paths(old, new); if todo.is_empty() { return Ok(()); } if !Self::ask_continue(&todo, decision)? { return Ok(()); } let permit = WATCHER.acquire().await.unwrap(); let (mut failed, mut succeeded) = (Vec::new(), HashMap::with_capacity(todo.len())); for (o, n) in todo { let (Ok(old), Ok(new)) = (Self::replace_url(&selected[o.0], root, &o), Self::replace_url(&selected[n.0], root, &n)) else { failed.push((o, n, anyhow!("Invalid new or old file name"))); continue; }; if maybe_exists(&new).await && !engine::must_identical(&old, &new).await { failed.push((o, n, anyhow!("Destination already exists"))); } else if let Err(e) = engine::rename(&old, &new).await { failed.push((o, n, e.into())); } else if let Ok(f) = engine::file(new).await { succeeded.insert(old, f); } else { failed.push((o, n, anyhow!("Failed to retrieve file info"))); } } if !succeeded.is_empty() { let it = succeeded.iter().map(|(o, n)| (o.as_url(), n.url.as_url())); err!(Pubsub::pub_after_bulk_rename(it)); FilesOp::rename(succeeded); } drop(permit); if !failed.is_empty() { Self::output_failed(failed).await?; } Ok(()) } fn opener() -> Option { YAZI .open .match_dummy(Path::new("bulk-rename.txt"), "text/plain") .and_then(|r| YAZI.opener.block(&r)) } fn replace_url(url: &UrlBuf, take: usize, rep: &StrandBuf) -> Result { Ok(url.try_replace(take, PathDyn::with(url.kind(), rep)?)?.into_owned()) } fn ask_continue(todo: &[(Tuple, Tuple)], decision: Option) -> Result { if let Some(decision) = decision { return Ok(decision); } { let mut w = TTY.lockout(); for (old, new) in todo { writeln!(w, "{} -> {}", old.display(), new.display())?; } write!(w, "Continue to rename? (y/N): ")?; w.flush()?; } let mut buf = [0; 10]; _ = TTY.reader().read(&mut buf)?; Ok(buf[0] == b'y' || buf[0] == b'Y') } async fn output_failed(failed: Vec<(Tuple, Tuple, anyhow::Error)>) -> Result<()> { let mut stdout = TTY.lockout(); writeln!(stdout, "{EraseScreen}")?; writeln!(stdout, "Failed to rename:")?; for (old, new, err) in failed { writeln!(stdout, "{} -> {}: {err}", old.display(), new.display())?; } writeln!(stdout, "\nPress ENTER to exit")?; stdout.flush()?; TTY.reader().read_exact(&mut [0])?; Ok(()) } fn prioritized_paths(old: Vec, new: Vec) -> Vec<(Tuple, Tuple)> { let orders: HashMap<_, _> = old.iter().enumerate().map(|(i, t)| (t, i)).collect(); let mut incomes: HashMap<_, _> = old.iter().map(|t| (t, false)).collect(); let mut todos: HashMap<_, _> = old .iter() .zip(new) .map(|(o, n)| { incomes.get_mut(&n).map(|b| *b = true); (o, n) }) .collect(); let mut sorted = Vec::with_capacity(old.len()); while !todos.is_empty() { // Paths that are non-incomes and don't need to be prioritized in this round let mut outcomes: Vec<_> = incomes.iter().filter(|&(_, b)| !b).map(|(&t, _)| t).collect(); outcomes.sort_unstable_by(|a, b| orders[b].cmp(&orders[a])); // If there're no outcomes, it means there are cycles in the renaming if outcomes.is_empty() { let mut remain: Vec<_> = todos.into_iter().map(|(o, n)| (o.clone(), n)).collect(); remain.sort_unstable_by(|(a, _), (b, _)| orders[a].cmp(&orders[b])); sorted.reverse(); sorted.extend(remain); return sorted; } for old in outcomes { let Some(new) = todos.remove(old) else { unreachable!() }; incomes.remove(&old); incomes.get_mut(&new).map(|b| *b = false); sorted.push((old.clone(), new)); } } sorted.reverse(); sorted } } // --- Tuple #[derive(Clone, Debug)] struct Tuple(usize, StrandBuf); impl Deref for Tuple { type Target = StrandBuf; fn deref(&self) -> &Self::Target { &self.1 } } impl PartialEq for Tuple { fn eq(&self, other: &Self) -> bool { self.1 == other.1 } } impl Eq for Tuple {} impl Hash for Tuple { fn hash(&self, state: &mut H) { self.1.hash(state); } } impl AsStrand for &Tuple { fn as_strand(&self) -> Strand<'_> { self.1.as_strand() } } impl Tuple { fn new(index: usize, inner: impl Into) -> Self { Self(index, inner.into()) } } // --- Tests #[cfg(test)] mod tests { use super::*; #[test] fn test_sort() { fn cmp(input: &[(&str, &str)], expected: &[(&str, &str)]) { let sorted = BulkRename::prioritized_paths( input.iter().map(|&(o, _)| Tuple::new(0, o)).collect(), input.iter().map(|&(_, n)| Tuple::new(0, n)).collect(), ); let sorted: Vec<_> = sorted.iter().map(|(o, n)| (o.to_str().unwrap(), n.to_str().unwrap())).collect(); assert_eq!(sorted, expected); } #[rustfmt::skip] cmp( &[("2", "3"), ("1", "2"), ("3", "4")], &[("3", "4"), ("2", "3"), ("1", "2")] ); #[rustfmt::skip] cmp( &[("1", "3"), ("2", "3"), ("3", "4")], &[("3", "4"), ("1", "3"), ("2", "3")] ); #[rustfmt::skip] cmp( &[("2", "1"), ("1", "2")], &[("2", "1"), ("1", "2")] ); #[rustfmt::skip] cmp( &[("3", "2"), ("2", "1"), ("1", "3"), ("a", "b"), ("b", "c")], &[("b", "c"), ("a", "b"), ("3", "2"), ("2", "1"), ("1", "3")] ); #[rustfmt::skip] cmp( &[("b", "b_"), ("a", "a_"), ("c", "c_")], &[("b", "b_"), ("a", "a_"), ("c", "c_")], ); } }