mirror of
https://github.com/sxyazi/yazi.git
synced 2026-07-21 14:51:03 +00:00
313 lines
8.3 KiB
Rust
313 lines
8.3 KiB
Rust
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::{Demand, Local}}, file::File, 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::{VfsFile, engine, 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<Data> {
|
|
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");
|
|
|
|
Demand::default()
|
|
.write(true)
|
|
.create_new(true)
|
|
.open(&tmp)
|
|
.await?
|
|
.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(&[tmp.as_url()]).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<Tuple>,
|
|
new: Vec<Tuple>,
|
|
selected: Vec<UrlBuf>,
|
|
decision: Option<bool>,
|
|
) -> 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 <Enter> 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) = File::new(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<OpenerRuleArc> {
|
|
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<UrlBuf> {
|
|
Ok(url.try_replace(take, PathDyn::with(url.kind(), rep)?)?.into_owned())
|
|
}
|
|
|
|
fn ask_continue(todo: &[(Tuple, Tuple)], decision: Option<bool>) -> Result<bool> {
|
|
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<Tuple>, new: Vec<Tuple>) -> 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<H: std::hash::Hasher>(&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<StrandBuf>) -> 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_")],
|
|
);
|
|
}
|
|
}
|