From 1007dcc078b83efb4ffd7798dbdfd33e3e644bb1 Mon Sep 17 00:00:00 2001 From: Dan1718 Date: Fri, 20 Mar 2026 15:15:50 +0530 Subject: [PATCH] Added the main files, still have to link the action properly and verify the keybind. Testing pending # Conflicts: # yazi-actor/src/mgr/mod.rs # yazi-dds/src/spark/spark.rs --- yazi-actor/src/mgr/bulk_create.rs | 317 ++++++------------------- yazi-actor/src/mgr/mod.rs | 1 + yazi-config/preset/keymap-default.toml | 1 + yazi-fm/src/executor.rs | 1 + 4 files changed, 80 insertions(+), 240 deletions(-) diff --git a/yazi-actor/src/mgr/bulk_create.rs b/yazi-actor/src/mgr/bulk_create.rs index c29bbacb..3065bdbe 100644 --- a/yazi-actor/src/mgr/bulk_create.rs +++ b/yazi-actor/src/mgr/bulk_create.rs @@ -1,33 +1,24 @@ +use crate::{Actor, Ctx}; +use anyhow::{Result, anyhow, bail}; +use scopeguard::defer; use std::{ - hash::Hash, io::{Read, Write}, - ops::Deref, path::Path, }; - -use anyhow::{Result, anyhow}; -use crossterm::{execute, style::Print}; -use hashbrown::HashMap; -use scopeguard::defer; -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, + File, FilesOp, Splatter, provider::{ - FileBuilder, Provider, + Provider, local::{Gate, Local}, }, }; -use yazi_macro::{err, succ}; +use yazi_macro::{ok_or_not_found, succ}; use yazi_parser::VoidOpt; -use yazi_proxy::{AppProxy, NotifyProxy, TasksProxy}; +use yazi_proxy::{AppProxy, MgrProxy, NotifyProxy, TasksProxy}; use yazi_shared::{ data::Data, - path::PathDyn, - strand::{AsStrand, AsStrandJoin, Strand, StrandBuf, StrandLike}, terminal_clear, url::{AsUrl, UrlBuf, UrlCow, UrlLike}, }; @@ -35,41 +26,18 @@ use yazi_term::YIELD_TO_SUBPROCESS; use yazi_tty::TTY; use yazi_vfs::{VfsFile, maybe_exists, provider}; use yazi_watcher::WATCHER; - -use crate::{Actor, Ctx}; - -pub struct BulkRename; - -impl Actor for BulkRename { +pub struct BulkCreate; +impl Actor for BulkCreate { type Options = VoidOpt; - - const NAME: &str = "bulk_rename"; - + const NAME: &str = "bulk_create"; fn act(cx: &mut Ctx, _: Self::Options) -> Result { let Some(opener) = Self::opener() else { - succ!(NotifyProxy::push_warn("Bulk rename", "No text opener found")); + succ!(NotifyProxy::push_warn("Bulk create", "No text opener found")); }; - - let selected: Vec<_> = cx.tab().selected_or_hovered().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(); tokio::spawn(async move { let tmp = YAZI.preview.tmpfile("bulk"); - Gate::default() - .write(true) - .create_new(true) - .open(&tmp) - .await? - .write_all(old.join(Strand::Utf8("\n")).encoded_bytes()) - .await?; - + _ = Gate::default().write(true).create_new(true).open(&tmp).await?; defer! { let tmp = tmp.clone(); tokio::spawn(async move { @@ -77,249 +45,118 @@ impl Actor for BulkRename { }); } TasksProxy::process_exec( - cwd.into(), + cwd.clone().into(), Splatter::new(&[UrlCow::default(), tmp.as_url().into()]).splat(&opener.run), vec![UrlCow::default(), UrlBuf::from(&tmp).into()], opener.block, 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(); - - Self::r#do(root, old, new, selected).await + let todo: Vec<_> = + Local::regular(&tmp).read_to_string().await?.lines().filter_map(Entry::parse).collect(); + Self::r#do(cwd, todo).await }); - succ!(); + succ!() } } - -impl BulkRename { - async fn r#do( - root: usize, - old: Vec, - new: Vec, - selected: Vec, - ) -> Result<()> { +impl BulkCreate { + async fn r#do(cwd: UrlBuf, todo: Vec) -> Result<()> { terminal_clear(TTY.writer())?; - if old.len() != new.len() { - #[rustfmt::skip] - let s = format!("Number of new and old file names mismatch (New: {}, Old: {}).\nPress to exit...", new.len(), old.len()); - execute!(TTY.writer(), Print(s))?; - - 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(()); } - { let mut w = TTY.lockout(); - for (old, new) in &todo { - writeln!(w, "{} -> {}", old.display(), new.display())?; + for entry in &todo { + writeln!(w, "{}", entry.name)?; } - write!(w, "Continue to rename? (y/N): ")?; + write!(w, "Continue to create? (y/N): ")?; w.flush()?; } - let mut buf = [0; 10]; _ = TTY.reader().read(&mut buf)?; if buf[0] != b'y' && buf[0] != b'Y' { 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"))); + let _permit = WATCHER.acquire().await.unwrap(); + let mut failed = Vec::new(); + let mut reveal = None; + for entry in todo { + let Ok(new) = cwd.try_join(&entry.name) else { + failed.push((entry, anyhow!("Invalid path"))); continue; }; - - if maybe_exists(&new).await && !provider::must_identical(&old, &new).await { - failed.push((o, n, anyhow!("Destination already exists"))); - } else if let Err(e) = provider::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 maybe_exists(&new).await { + failed.push((entry, anyhow!("Destination already exists"))); + continue; + } + match Self::create_one(&new, entry.dir).await { + Ok(real) => reveal = Some(real), + Err(e) => failed.push((entry, e)), } } - - if !succeeded.is_empty() { - let it = succeeded.iter().map(|(o, n)| (o.as_url(), n.url.as_url())); - err!(Pubsub::pub_after_bulk(it)); - FilesOp::rename(succeeded); + if let Some(url) = reveal { + MgrProxy::reveal(&url); } - drop(permit); - if !failed.is_empty() { Self::output_failed(failed).await?; } Ok(()) } - fn opener() -> Option<&'static OpenerRule> { YAZI.opener.block(YAZI.open.all(Path::new("bulk-rename.txt"), "text/plain")) } - - fn replace_url(url: &UrlBuf, take: usize, rep: &StrandBuf) -> Result { - Ok(url.try_replace(take, PathDyn::with(url.kind(), rep)?)?.into_owned()) + async fn create_one(new: &UrlBuf, dir: bool) -> Result { + if dir { + provider::create_dir_all(new).await?; + } else if let Ok(real) = provider::casefold(new).await + && let Some((parent, urn)) = real.pair() + { + ok_or_not_found!(provider::remove_file(new).await); + FilesOp::Deleting(parent.into(), [urn.into()].into()).emit(); + provider::create(new).await?; + } else if let Some(parent) = new.parent() { + provider::create_dir_all(parent).await.ok(); + ok_or_not_found!(provider::remove_file(new).await); + provider::create(new).await?; + } else { + bail!("Cannot create file at root"); + } + if let Ok(real) = provider::casefold(new).await + && let Some((parent, urn)) = real.pair() + { + let file = File::new(&real).await?; + FilesOp::Upserting(parent.into(), [(urn.into(), file)].into()).emit(); + Ok(real) + } else { + bail!("Failed to retrieve file info"); + } } - - async fn output_failed(failed: Vec<(Tuple, Tuple, anyhow::Error)>) -> Result<()> { + async fn output_failed(failed: Vec<(Entry, anyhow::Error)>) -> Result<()> { let mut stdout = TTY.lockout(); terminal_clear(&mut *stdout)?; - - writeln!(stdout, "Failed to rename:")?; - for (old, new, err) in failed { - writeln!(stdout, "{} -> {}: {err}", old.display(), new.display())?; + writeln!(stdout, "Failed to create:")?; + for (entry, err) in failed { + writeln!(stdout, "{}: {err}", entry.name)?; } 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)); - } +} +struct Entry { + name: String, + dir: bool, +} +impl Entry { + fn parse(s: &str) -> Option { + let s = s.trim(); + if s.is_empty() { + return None; } - 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_")], - ); + Some(Self { dir: s.ends_with('/') || s.ends_with('\\'), name: s.to_owned() }) } } diff --git a/yazi-actor/src/mgr/mod.rs b/yazi-actor/src/mgr/mod.rs index 5276e8c0..dfbc3aae 100644 --- a/yazi-actor/src/mgr/mod.rs +++ b/yazi-actor/src/mgr/mod.rs @@ -1,6 +1,7 @@ yazi_macro::mod_flat!( arrow back + bulk_create bulk_exit bulk_rename cd diff --git a/yazi-config/preset/keymap-default.toml b/yazi-config/preset/keymap-default.toml index 242e4325..b3ec6c26 100644 --- a/yazi-config/preset/keymap-default.toml +++ b/yazi-config/preset/keymap-default.toml @@ -76,6 +76,7 @@ keymap = [ { on = "d", run = "remove", desc = "Trash selected files" }, { on = "D", run = "remove --permanently", desc = "Permanently delete selected files" }, { on = "a", run = "create", desc = "Create a file (ends with / for directories)" }, + { on = "A", run = "bulk-create", desc = "Create files from a list with your editor"}, { on = "r", run = "rename --cursor=before_ext", desc = "Rename selected file(s)" }, { on = ";", run = "shell --interactive", desc = "Run a shell command" }, { on = ":", run = "shell --block --interactive", desc = "Run a shell command (block until finishes)" }, diff --git a/yazi-fm/src/executor.rs b/yazi-fm/src/executor.rs index 9f9b319b..0b4f52d9 100644 --- a/yazi-fm/src/executor.rs +++ b/yazi-fm/src/executor.rs @@ -122,6 +122,7 @@ impl<'a> Executor<'a> { on!(hardlink); on!(remove); on!(remove_do); + on!(bulk_create); on!(create); on!(rename); on!(copy);