From af332e78ea8febd54f16ea094a2f2131239705fa Mon Sep 17 00:00:00 2001 From: zhaoyang Date: Thu, 11 Jun 2026 19:58:16 +0800 Subject: [PATCH 1/3] feat: allow cycle rename --- yazi-actor/src/mgr/bulk_rename.rs | 176 ++++++++++++++++++++++++++---- 1 file changed, 157 insertions(+), 19 deletions(-) diff --git a/yazi-actor/src/mgr/bulk_rename.rs b/yazi-actor/src/mgr/bulk_rename.rs index b472aa446..ed8266871 100644 --- a/yazi-actor/src/mgr/bulk_rename.rs +++ b/yazi-actor/src/mgr/bulk_rename.rs @@ -1,4 +1,10 @@ -use std::{hash::Hash, io::{Read, Write}, ops::Deref, path::Path, sync::Arc}; +use std::{ + hash::Hash, + io::{Read, Write}, + ops::Deref, + path::Path, + sync::Arc, +}; use anyhow::{Result, anyhow}; use hashbrown::HashMap; @@ -7,12 +13,25 @@ use tokio::io::AsyncWriteExt; use yazi_binding::Permit; use yazi_config::{YAZI, opener::OpenerRule}; use yazi_dds::Pubsub; -use yazi_fs::{File, FilesOp, Splatter, max_common_root, path::skip_url, provider::{FileBuilder, Provider, local::{Gate, Local}}}; +use yazi_fs::{ + File, FilesOp, Splatter, max_common_root, + path::skip_url, + provider::{ + FileBuilder, Provider, + local::{Gate, Local}, + }, +}; use yazi_macro::{err, succ, writef}; use yazi_parser::VoidForm; use yazi_proxy::TasksProxy; use yazi_scheduler::{AppProxy, NotifyProxy}; -use yazi_shared::{data::Data, path::PathDyn, strand::{AsStrand, AsStrandJoin, Strand, StrandBuf, StrandLike}, url::{AsUrl, UrlBuf, UrlCow, UrlLike}}; +use yazi_shared::{ + data::Data, + path::PathDyn, + strand::{AsStrand, AsStrandJoin, Strand, StrandBuf, StrandLike}, + timestamp_us, + url::{AsUrl, UrlBuf, UrlCow, UrlLike}, +}; use yazi_term::{YIELD_TO_SUBPROCESS, sequence::EraseScreen}; use yazi_tty::TTY; use yazi_vfs::{VfsFile, maybe_exists, provider}; @@ -20,6 +39,8 @@ use yazi_watcher::WATCHER; use crate::{Actor, Ctx}; +type Renames = Vec<(Tuple, Tuple)>; + pub struct BulkRename; impl Actor for BulkRename { @@ -109,18 +130,20 @@ impl BulkRename { } 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() { + let (chain, cycles) = Self::prioritized_paths(old, new); + if chain.is_empty() && cycles.is_empty() { return Ok(()); } + let todo: Vec<_> = chain.iter().chain(cycles.iter().flatten()).cloned().collect(); 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 cap = chain.len() + cycles.iter().map(Vec::len).sum::(); + let (mut failed, mut succeeded) = (Vec::new(), HashMap::with_capacity(cap)); + for (o, n) in chain { let (Ok(old), Ok(new)) = (Self::replace_url(&selected[o.0], root, &o), Self::replace_url(&selected[n.0], root, &n)) else { @@ -139,6 +162,10 @@ impl BulkRename { } } + for cycle in cycles { + Self::rename_cycle(root, cycle, &selected, &mut failed, &mut succeeded).await; + } + 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)); @@ -163,6 +190,85 @@ impl BulkRename { Ok(url.try_replace(take, PathDyn::with(url.kind(), rep)?)?.into_owned()) } + async fn rename_cycle( + root: usize, + cycle: Renames, + selected: &[UrlBuf], + failed: &mut Vec<(Tuple, Tuple, anyhow::Error)>, + succeeded: &mut HashMap, + ) { + let edge = |i: usize, e: anyhow::Error| (cycle[i].0.clone(), cycle[i].1.clone(), e); + + let urls: Result> = cycle + .iter() + .map(|(o, n)| { + Ok(( + Self::replace_url(&selected[o.0], root, o)?, + Self::replace_url(&selected[n.0], root, n)?, + )) + }) + .collect(); + let Ok(urls) = urls else { + failed.extend((0..cycle.len()).map(|i| edge(i, anyhow!("Invalid new or old file name")))); + return; + }; + + let first = &urls[0].0; + let Some(tmp) = Self::temp_url(first, &urls).await else { + failed + .extend((0..cycle.len()).map(|i| edge(i, anyhow!("Failed to allocate a temporary name")))); + return; + }; + + if let Err(e) = provider::rename(first, &tmp).await { + failed.push(edge(0, e.into())); + failed.extend((1..cycle.len()).map(|i| edge(i, anyhow!("Skipped due to a broken cycle")))); + return; + } + + for i in (1..cycle.len()).rev() { + let (old, new) = &urls[i]; + if let Err(e) = provider::rename(old, new).await { + err!(provider::rename(&tmp, first).await); + failed.extend((0..i).map(|j| edge(j, anyhow!("Rolled back due to a broken cycle")))); + failed.push(edge(i, e.into())); + return; + } + match File::new(new.clone()).await { + Ok(f) => { + succeeded.insert(old.clone(), f); + } + Err(_) => failed.push(edge(i, anyhow!("Failed to retrieve file info"))), + } + } + + let final_to = &urls[0].1; + if let Err(e) = provider::rename(&tmp, final_to).await { + failed.push(edge(0, anyhow!("{e}; the file is left at {}", tmp.display()))); + } + match File::new(final_to.clone()).await { + Ok(f) => { + succeeded.insert(first.clone(), f); + } + Err(_) => failed.push(edge(0, anyhow!("Failed to retrieve file info"))), + } + } + + async fn temp_url(file: &UrlBuf, urls: &[(UrlBuf, UrlBuf)]) -> Option { + let parent = file.parent()?; + for _ in 0..16 { + let name = format!(".bulk-rename-{}", timestamp_us()); + let Ok(tmp) = parent.try_join(name.as_str()) else { continue }; + if urls.iter().any(|(_, n)| *n == tmp) { + continue; + } + if !maybe_exists(&tmp).await { + return Some(tmp); + } + } + None + } + fn ask_continue(todo: &[(Tuple, Tuple)], decision: Option) -> Result { if let Some(decision) = decision { return Ok(decision); @@ -197,7 +303,7 @@ impl BulkRename { Ok(()) } - fn prioritized_paths(old: Vec, new: Vec) -> Vec<(Tuple, Tuple)> { + fn prioritized_paths(old: Vec, new: Vec) -> (Renames, Vec) { 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 @@ -215,13 +321,10 @@ impl BulkRename { 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 there're no outcomes, every remaining edge belongs to a cycle 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; + return (sorted, Self::partition_cycles(todos, &orders)); } for old in outcomes { @@ -232,7 +335,32 @@ impl BulkRename { } } sorted.reverse(); - sorted + (sorted, Vec::new()) + } + + fn partition_cycles( + mut todos: HashMap<&Tuple, Tuple>, + orders: &HashMap<&Tuple, usize>, + ) -> Vec { + let mut starts: Vec<_> = todos.keys().copied().collect(); + starts.sort_unstable_by(|a, b| orders[a].cmp(&orders[b])); + + let mut cycles = Vec::new(); + for start in starts { + if !todos.contains_key(start) { + continue; + } + + let mut cycle = Vec::new(); + let mut cur = start; + while let Some(next) = todos.remove(cur) { + cycle.push((cur.clone(), next.clone())); + let Some((&owner, _)) = todos.get_key_value(&next) else { break }; + cur = owner; + } + cycles.push(cycle); + } + cycles } } @@ -243,25 +371,35 @@ struct Tuple(usize, StrandBuf); impl Deref for Tuple { type Target = StrandBuf; - fn deref(&self) -> &Self::Target { &self.1 } + fn deref(&self) -> &Self::Target { + &self.1 + } } impl PartialEq for Tuple { - fn eq(&self, other: &Self) -> bool { self.1 == other.1 } + 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); } + fn hash(&self, state: &mut H) { + self.1.hash(state); + } } impl AsStrand for &Tuple { - fn as_strand(&self) -> Strand<'_> { self.1.as_strand() } + fn as_strand(&self) -> Strand<'_> { + self.1.as_strand() + } } impl Tuple { - fn new(index: usize, inner: impl Into) -> Self { Self(index, inner.into()) } + fn new(index: usize, inner: impl Into) -> Self { + Self(index, inner.into()) + } } // --- Tests From 6f2b642d59964760990807a9577f6e7e24482846 Mon Sep 17 00:00:00 2001 From: zhaoyang Date: Thu, 11 Jun 2026 21:31:35 +0800 Subject: [PATCH 2/3] fix: use encoded bytes for Tuple hashing --- yazi-actor/src/mgr/bulk_rename.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/yazi-actor/src/mgr/bulk_rename.rs b/yazi-actor/src/mgr/bulk_rename.rs index ed8266871..6c32695ae 100644 --- a/yazi-actor/src/mgr/bulk_rename.rs +++ b/yazi-actor/src/mgr/bulk_rename.rs @@ -386,7 +386,7 @@ impl Eq for Tuple {} impl Hash for Tuple { fn hash(&self, state: &mut H) { - self.1.hash(state); + self.1.as_strand().encoded_bytes().hash(state); } } From e4c5df99b523f6cb86c6cf2fda45c18aab03f48e Mon Sep 17 00:00:00 2001 From: zhaoyang Date: Thu, 11 Jun 2026 21:38:27 +0800 Subject: [PATCH 3/3] test: add test cases for cycle rename --- yazi-actor/src/mgr/bulk_rename.rs | 50 +++++++++++++++++++++++-------- 1 file changed, 38 insertions(+), 12 deletions(-) diff --git a/yazi-actor/src/mgr/bulk_rename.rs b/yazi-actor/src/mgr/bulk_rename.rs index 6c32695ae..25db2d667 100644 --- a/yazi-actor/src/mgr/bulk_rename.rs +++ b/yazi-actor/src/mgr/bulk_rename.rs @@ -409,44 +409,70 @@ mod tests { #[test] fn test_sort() { - fn cmp(input: &[(&str, &str)], expected: &[(&str, &str)]) { - let sorted = BulkRename::prioritized_paths( + fn cmp(input: &[(&str, &str)], chain: &[(&str, &str)], cycles: &[&[(&str, &str)]]) { + let (res_chain, res_cycles) = 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); + let flat = |v: &[(Tuple, Tuple)]| -> Vec<(String, String)> { + v.iter().map(|(o, n)| (o.to_str().unwrap().into(), n.to_str().unwrap().into())).collect() + }; + let want = |v: &[(&str, &str)]| -> Vec<(String, String)> { + v.iter().map(|&(o, n)| (o.into(), n.into())).collect() + }; + assert_eq!(flat(&res_chain), want(chain)); + assert_eq!( + res_cycles.iter().map(|c| flat(c)).collect::>(), + cycles.iter().map(|c| want(c)).collect::>() + ); } #[rustfmt::skip] cmp( &[("2", "3"), ("1", "2"), ("3", "4")], - &[("3", "4"), ("2", "3"), ("1", "2")] + &[("3", "4"), ("2", "3"), ("1", "2")], + &[] ); #[rustfmt::skip] cmp( &[("1", "3"), ("2", "3"), ("3", "4")], - &[("3", "4"), ("1", "3"), ("2", "3")] + &[("3", "4"), ("1", "3"), ("2", "3")], + &[] + ); + #[rustfmt::skip] + cmp( + &[("b", "b_"), ("a", "a_"), ("c", "c_")], + &[("b", "b_"), ("a", "a_"), ("c", "c_")], + &[] ); #[rustfmt::skip] cmp( - &[("2", "1"), ("1", "2")], - &[("2", "1"), ("1", "2")] + &[("1", "2"), ("2", "1")], + &[], + &[&[("1", "2"), ("2", "1")]] + ); + + #[rustfmt::skip] + cmp( + &[("1", "2"), ("2", "3"), ("3", "1")], + &[], + &[&[("1", "2"), ("2", "3"), ("3", "1")]] ); #[rustfmt::skip] cmp( &[("3", "2"), ("2", "1"), ("1", "3"), ("a", "b"), ("b", "c")], - &[("b", "c"), ("a", "b"), ("3", "2"), ("2", "1"), ("1", "3")] + &[("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_")], + &[("1", "2"), ("2", "1"), ("3", "4"), ("4", "3")], + &[], + &[&[("1", "2"), ("2", "1")], &[("3", "4"), ("4", "3")]] ); } }