This commit is contained in:
likelikeslike 2026-06-14 21:17:18 +08:00 committed by GitHub
commit 9a188dc24d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -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::<usize>();
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<UrlBuf, File>,
) {
let edge = |i: usize, e: anyhow::Error| (cycle[i].0.clone(), cycle[i].1.clone(), e);
let urls: Result<Vec<_>> = 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<UrlBuf> {
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<bool>) -> Result<bool> {
if let Some(decision) = decision {
return Ok(decision);
@ -197,7 +303,7 @@ impl BulkRename {
Ok(())
}
fn prioritized_paths(old: Vec<Tuple>, new: Vec<Tuple>) -> Vec<(Tuple, Tuple)> {
fn prioritized_paths(old: Vec<Tuple>, new: Vec<Tuple>) -> (Renames, Vec<Renames>) {
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<Renames> {
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<H: std::hash::Hasher>(&self, state: &mut H) { self.1.hash(state); }
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.1.as_strand().encoded_bytes().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<StrandBuf>) -> Self { Self(index, inner.into()) }
fn new(index: usize, inner: impl Into<StrandBuf>) -> Self {
Self(index, inner.into())
}
}
// --- Tests
@ -271,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::<Vec<_>>(),
cycles.iter().map(|c| want(c)).collect::<Vec<_>>()
);
}
#[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")]]
);
}
}