This commit is contained in:
sxyazi 2026-05-12 01:48:17 +08:00
parent 6c226065b7
commit d429c1ecbd
No known key found for this signature in database
7 changed files with 112 additions and 96 deletions

View file

@ -1,18 +1,19 @@
use std::{io::{Read, Write}, path::Path, sync::Arc}; use std::{fmt::{self, Display}, io::{self, Read, Write}, path::{MAIN_SEPARATOR, Path}, sync::Arc};
use anyhow::{Result, anyhow, bail}; use anyhow::{Result, anyhow};
use scopeguard::defer; use scopeguard::defer;
use yazi_binding::Permit; use yazi_binding::Permit;
use yazi_config::{YAZI, opener::OpenerRule}; use yazi_config::{YAZI, opener::OpenerRule};
use yazi_fs::{File, FilesOp, Splatter, provider::{FileBuilder, Provider, local::{Gate, Local}}}; use yazi_fs::{File, FilesOp, Splatter, provider::{Provider, local::Local}};
use yazi_macro::{ok_or_not_found, succ}; use yazi_macro::succ;
use yazi_parser::VoidForm; use yazi_parser::VoidForm;
use yazi_proxy::{MgrProxy, TasksProxy}; use yazi_proxy::TasksProxy;
use yazi_scheduler::{AppProxy, NotifyProxy}; use yazi_scheduler::{AppProxy, NotifyProxy};
use yazi_shared::{data::Data, terminal_clear, url::{AsUrl, UrlBuf, UrlCow, UrlLike}}; use yazi_shared::{data::Data, strand::Strand, terminal_clear, url::{AsUrl, UrlBuf, UrlCow, UrlLike}};
use yazi_shim::path::CROSS_SEPARATOR;
use yazi_term::YIELD_TO_SUBPROCESS; use yazi_term::YIELD_TO_SUBPROCESS;
use yazi_tty::TTY; use yazi_tty::TTY;
use yazi_vfs::{VfsFile, maybe_exists, provider}; use yazi_vfs::{VfsFile, provider};
use yazi_watcher::WATCHER; use yazi_watcher::WATCHER;
use crate::{Actor, Ctx}; use crate::{Actor, Ctx};
@ -26,72 +27,79 @@ impl Actor for BulkCreate {
let Some(opener) = Self::opener() else { let Some(opener) = Self::opener() else {
succ!(NotifyProxy::push_warn("Bulk create", "No text opener found")); succ!(NotifyProxy::push_warn("Bulk create", "No text opener found"));
}; };
let cwd = cx.cwd().clone(); let cwd = cx.cwd().clone();
tokio::spawn(async move { tokio::spawn(async move {
let tmp = YAZI.preview.tmpfile("bulk"); let tmp = YAZI.preview.tmpfile("bulk-create");
_ = Gate::default().write(true).create_new(true).open(&tmp).await?; provider::create_new(&tmp).await?;
defer! { defer! {
let tmp = tmp.clone(); let tmp = tmp.clone();
tokio::spawn(async move { tokio::spawn(async move {
Local::regular(&tmp).remove_file().await Local::regular(&tmp).remove_file().await
}); });
} }
TasksProxy::process_exec( TasksProxy::process_exec(
cwd.clone().into(), cwd.clone(),
Splatter::new(&[UrlCow::default(), tmp.as_url().into()]).splat(&opener.run), Splatter::new(&[UrlCow::default(), tmp.as_url().into()]).splat(&opener.run),
vec![UrlCow::default(), UrlBuf::from(&tmp).into()], vec![UrlCow::default(), UrlBuf::from(&tmp).into()],
opener.block, opener.block,
opener.orphan, opener.orphan,
) )
.await; .await;
let _permit = Permit::new(YIELD_TO_SUBPROCESS.acquire().await.unwrap(), AppProxy::resume()); let _permit = Permit::new(YIELD_TO_SUBPROCESS.acquire().await.unwrap(), AppProxy::resume());
AppProxy::stop().await; AppProxy::stop().await;
let todo: Vec<_> =
Local::regular(&tmp).read_to_string().await?.lines().filter_map(Entry::parse).collect(); let content = Local::regular(&tmp).read_to_string().await?;
Self::r#do(cwd, todo).await Self::r#do(cwd, content.lines().filter_map(Entry::parse).collect()).await
}); });
succ!() succ!()
} }
} }
impl BulkCreate { impl BulkCreate {
async fn r#do(cwd: UrlBuf, todo: Vec<Entry>) -> Result<()> { async fn r#do(cwd: UrlBuf, todo: Vec<Entry<'_>>) -> Result<()> {
terminal_clear(TTY.writer())?; terminal_clear(TTY.writer())?;
if todo.is_empty() { if todo.is_empty() {
return Ok(()); return Ok(());
} else if !Self::ask_continue(&todo, None)? {
return Ok(()); // TODO: support `bulk_exit`?
} }
{
let mut w = TTY.lockout();
for entry in &todo {
writeln!(w, "{}", entry.name)?;
}
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 _permit = WATCHER.acquire().await.unwrap();
let mut failed = Vec::new(); let (mut failed, mut succeeded) = (vec![], Vec::with_capacity(todo.len()));
let mut reveal = None;
for entry in todo { for entry in todo {
let Ok(new) = cwd.try_join(&entry.name) else { let Ok(dist) = cwd.try_join(entry.path) else {
failed.push((entry, anyhow!("Invalid path"))); failed.push((entry, anyhow!("Invalid path")));
continue; continue;
}; };
if maybe_exists(&new).await {
failed.push((entry, anyhow!("Destination already exists"))); let result: io::Result<()> = if entry.is_dir {
continue; provider::create_dir_all(&dist).await
} } else if let Some(parent) = dist.parent() {
match Self::create_one(&new, entry.dir).await { provider::create_dir_all(parent).await.ok();
Ok(real) => reveal = Some(real), provider::create_new(&dist).await.map(|_| ())
Err(e) => failed.push((entry, e)), } else {
Err(io::Error::other("No parent directory"))
};
if let Err(e) = result {
failed.push((entry, e.into()));
} else if let Ok(f) = File::new(dist).await {
succeeded.push(f);
} else {
failed.push((entry, anyhow!("Failed to retrieve file info")));
} }
} }
if let Some(url) = reveal {
MgrProxy::reveal(&url); if !succeeded.is_empty() {
// err!(Pubsub::pub_after_bulk_create(it)); // FIXME
FilesOp::create(succeeded);
} }
drop(_permit);
if !failed.is_empty() { if !failed.is_empty() {
Self::output_failed(failed).await?; Self::output_failed(failed).await?;
} }
@ -105,56 +113,64 @@ impl BulkCreate {
.and_then(|r| YAZI.opener.block(&r)) .and_then(|r| YAZI.opener.block(&r))
} }
async fn create_one(new: &UrlBuf, dir: bool) -> Result<UrlBuf> { fn ask_continue(todo: &[Entry], decision: Option<bool>) -> Result<bool> {
if dir { if let Some(decision) = decision {
provider::create_dir_all(new).await?; return Ok(decision);
} 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?; let mut w = TTY.lockout();
FilesOp::Upserting(parent.into(), [(urn.into(), file)].into()).emit(); for entry in todo {
Ok(real) writeln!(w, "{entry}")?;
} else { }
bail!("Failed to retrieve file info"); write!(w, "Continue to create? (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<(Entry, anyhow::Error)>) -> Result<()> { async fn output_failed(failed: Vec<(Entry<'_>, anyhow::Error)>) -> Result<()> {
let mut stdout = TTY.lockout(); let mut stdout = TTY.lockout();
terminal_clear(&mut *stdout)?; terminal_clear(&mut *stdout)?;
writeln!(stdout, "Failed to create:")?; writeln!(stdout, "Failed to create:")?;
for (entry, err) in failed { for (entry, err) in failed {
writeln!(stdout, "{}: {err}", entry.name)?; writeln!(stdout, "{entry}: {err}")?;
} }
writeln!(stdout, "\nPress ENTER to exit")?; writeln!(stdout, "\nPress ENTER to exit")?;
stdout.flush()?; stdout.flush()?;
TTY.reader().read_exact(&mut [0])?; TTY.reader().read_exact(&mut [0])?;
Ok(()) Ok(())
} }
} }
struct Entry {
name: String, // --- Entry
dir: bool, struct Entry<'a> {
path: Strand<'a>,
is_dir: bool,
} }
impl Entry {
fn parse(s: &str) -> Option<Self> { impl<'a> Entry<'a> {
let s = s.trim(); fn parse(s: &'a str) -> Option<Self> {
if s.is_empty() { let (path, is_dir) = match s.strip_suffix(CROSS_SEPARATOR) {
return None; Some(p) => (p, true),
} None => (s, false),
Some(Self { dir: s.ends_with('/') || s.ends_with('\\'), name: s.to_owned() }) };
Some(Self { path: path.into(), is_dir }).filter(|_| !path.is_empty())
}
}
impl Display for Entry<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.is_dir {
write!(f, "{}{MAIN_SEPARATOR}", self.path.display())
} else {
self.path.display().fmt(f)
}
} }
} }

View file

@ -143,7 +143,7 @@ impl BulkRename {
if !succeeded.is_empty() { if !succeeded.is_empty() {
let it = succeeded.iter().map(|(o, n)| (o.as_url(), n.url.as_url())); let it = succeeded.iter().map(|(o, n)| (o.as_url(), n.url.as_url()));
err!(Pubsub::pub_after_bulk(it)); err!(Pubsub::pub_after_bulk_rename(it));
FilesOp::rename(succeeded); FilesOp::rename(succeeded);
} }
drop(permit); drop(permit);

View file

@ -6,11 +6,11 @@ use yazi_shared::url::{Url, UrlCow};
use super::Ember; use super::Ember;
#[derive(Clone, Debug, Deserialize, Serialize)] #[derive(Clone, Debug, Deserialize, Serialize)]
pub struct EmberBulk<'a> { pub struct EmberBulkRename<'a> {
pub changes: HashMap<UrlCow<'a>, UrlCow<'a>>, pub changes: HashMap<UrlCow<'a>, UrlCow<'a>>,
} }
impl<'a> EmberBulk<'a> { impl<'a> EmberBulkRename<'a> {
pub fn borrowed<I>(changes: I) -> Ember<'a> pub fn borrowed<I>(changes: I) -> Ember<'a>
where where
I: Iterator<Item = (Url<'a>, Url<'a>)>, I: Iterator<Item = (Url<'a>, Url<'a>)>,
@ -19,7 +19,7 @@ impl<'a> EmberBulk<'a> {
} }
} }
impl EmberBulk<'static> { impl EmberBulkRename<'static> {
pub fn owned<'a, I>(changes: I) -> Ember<'static> pub fn owned<'a, I>(changes: I) -> Ember<'static>
where where
I: Iterator<Item = (Url<'a>, Url<'a>)>, I: Iterator<Item = (Url<'a>, Url<'a>)>,
@ -31,11 +31,11 @@ impl EmberBulk<'static> {
} }
} }
impl<'a> From<EmberBulk<'a>> for Ember<'a> { impl<'a> From<EmberBulkRename<'a>> for Ember<'a> {
fn from(value: EmberBulk<'a>) -> Self { Self::Bulk(value) } fn from(value: EmberBulkRename<'a>) -> Self { Self::BulkRename(value) }
} }
impl IntoLua for EmberBulk<'_> { impl IntoLua for EmberBulkRename<'_> {
fn into_lua(self, lua: &Lua) -> mlua::Result<Value> { fn into_lua(self, lua: &Lua) -> mlua::Result<Value> {
lua lua
.create_table_from( .create_table_from(

View file

@ -2,7 +2,7 @@ use anyhow::{Result, bail};
use mlua::{ExternalResult, IntoLua, Lua, Value}; use mlua::{ExternalResult, IntoLua, Lua, Value};
use yazi_shared::Id; use yazi_shared::Id;
use super::{EmberBulk, EmberBye, EmberCd, EmberCustom, EmberDelete, EmberDownload, EmberDuplicate, EmberHey, EmberHi, EmberHover, EmberLoad, EmberMount, EmberMove, EmberRename, EmberTab, EmberTrash, EmberYank}; use super::{EmberBulkRename, EmberBye, EmberCd, EmberCustom, EmberDelete, EmberDownload, EmberDuplicate, EmberHey, EmberHi, EmberHover, EmberLoad, EmberMount, EmberMove, EmberRename, EmberTab, EmberTrash, EmberYank};
use crate::Payload; use crate::Payload;
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@ -15,7 +15,7 @@ pub enum Ember<'a> {
Load(EmberLoad<'a>), Load(EmberLoad<'a>),
Hover(EmberHover<'a>), Hover(EmberHover<'a>),
Rename(EmberRename<'a>), Rename(EmberRename<'a>),
Bulk(EmberBulk<'a>), BulkRename(EmberBulkRename<'a>),
Yank(EmberYank<'a>), Yank(EmberYank<'a>),
Duplicate(EmberDuplicate<'a>), Duplicate(EmberDuplicate<'a>),
Move(EmberMove<'a>), Move(EmberMove<'a>),
@ -37,7 +37,7 @@ impl Ember<'static> {
"load" => Self::Load(serde_json::from_str(body)?), "load" => Self::Load(serde_json::from_str(body)?),
"hover" => Self::Hover(serde_json::from_str(body)?), "hover" => Self::Hover(serde_json::from_str(body)?),
"rename" => Self::Rename(serde_json::from_str(body)?), "rename" => Self::Rename(serde_json::from_str(body)?),
"bulk" => Self::Bulk(serde_json::from_str(body)?), "bulk-rename" => Self::BulkRename(serde_json::from_str(body)?),
"@yank" => Self::Yank(serde_json::from_str(body)?), "@yank" => Self::Yank(serde_json::from_str(body)?),
"duplicate" => Self::Duplicate(serde_json::from_str(body)?), "duplicate" => Self::Duplicate(serde_json::from_str(body)?),
"move" => Self::Move(serde_json::from_str(body)?), "move" => Self::Move(serde_json::from_str(body)?),
@ -65,7 +65,7 @@ impl Ember<'static> {
| "load" | "load"
| "hover" | "hover"
| "rename" | "rename"
| "bulk" | "bulk-rename"
| "@yank" | "@yank"
| "duplicate" | "duplicate"
| "move" | "move"
@ -104,7 +104,7 @@ impl<'a> Ember<'a> {
Self::Load(_) => "load", Self::Load(_) => "load",
Self::Hover(_) => "hover", Self::Hover(_) => "hover",
Self::Rename(_) => "rename", Self::Rename(_) => "rename",
Self::Bulk(_) => "bulk", Self::BulkRename(_) => "bulk-rename",
Self::Yank(_) => "@yank", Self::Yank(_) => "@yank",
Self::Duplicate(_) => "duplicate", Self::Duplicate(_) => "duplicate",
Self::Move(_) => "move", Self::Move(_) => "move",
@ -132,7 +132,7 @@ impl<'a> IntoLua for Ember<'a> {
Self::Hover(b) => b.into_lua(lua), Self::Hover(b) => b.into_lua(lua),
Self::Tab(b) => b.into_lua(lua), Self::Tab(b) => b.into_lua(lua),
Self::Rename(b) => b.into_lua(lua), Self::Rename(b) => b.into_lua(lua),
Self::Bulk(b) => b.into_lua(lua), Self::BulkRename(b) => b.into_lua(lua),
Self::Yank(b) => b.into_lua(lua), Self::Yank(b) => b.into_lua(lua),
Self::Duplicate(b) => b.into_lua(lua), Self::Duplicate(b) => b.into_lua(lua),
Self::Move(b) => b.into_lua(lua), Self::Move(b) => b.into_lua(lua),

View file

@ -1,3 +1,3 @@
yazi_macro::mod_flat!( yazi_macro::mod_flat!(
bulk bye cd custom delete download duplicate ember hey hi hover load mount r#move rename tab trash yank bulk_rename bye cd custom delete download duplicate ember hey hi hover load mount r#move rename tab trash yank
); );

View file

@ -94,7 +94,7 @@ impl Display for Payload<'_> {
Ember::Hover(b) => serde_json::to_string(b), Ember::Hover(b) => serde_json::to_string(b),
Ember::Tab(b) => serde_json::to_string(b), Ember::Tab(b) => serde_json::to_string(b),
Ember::Rename(b) => serde_json::to_string(b), Ember::Rename(b) => serde_json::to_string(b),
Ember::Bulk(b) => serde_json::to_string(b), Ember::BulkRename(b) => serde_json::to_string(b),
Ember::Yank(b) => serde_json::to_string(b), Ember::Yank(b) => serde_json::to_string(b),
Ember::Duplicate(b) => serde_json::to_string(b), Ember::Duplicate(b) => serde_json::to_string(b),
Ember::Move(b) => serde_json::to_string(b), Ember::Move(b) => serde_json::to_string(b),

View file

@ -8,7 +8,7 @@ use yazi_fs::FolderStage;
use yazi_shared::{Id, url::{Url, UrlBuf, UrlBufCov}}; use yazi_shared::{Id, url::{Url, UrlBuf, UrlBufCov}};
use yazi_shim::cell::RoCell; use yazi_shim::cell::RoCell;
use crate::{Client, ID, PEERS, ember::{Ember, EmberBulk, EmberDuplicateItem, EmberHi, EmberMoveItem}}; use crate::{Client, ID, PEERS, ember::{Ember, EmberBulkRename, EmberDuplicateItem, EmberHi, EmberMoveItem}};
pub static LOCAL: RoCell<RwLock<HashMap<String, HashMap<String, Function>>>> = RoCell::new(); pub static LOCAL: RoCell<RwLock<HashMap<String, HashMap<String, Function>>>> = RoCell::new();
@ -128,18 +128,18 @@ impl Pubsub {
true true
} }
pub fn pub_after_bulk<'a, I>(changes: I) -> Result<()> pub fn pub_after_bulk_rename<'a, I>(changes: I) -> Result<()>
where where
I: Iterator<Item = (Url<'a>, Url<'a>)> + Clone, I: Iterator<Item = (Url<'a>, Url<'a>)> + Clone,
{ {
if BOOT.local_events.contains("bulk") { if BOOT.local_events.contains("bulk-rename") {
EmberBulk::borrowed(changes.clone()).with_receiver(*ID).flush()?; EmberBulkRename::borrowed(changes.clone()).with_receiver(*ID).flush()?;
} }
if PEERS.read().values().any(|p| p.able("bulk")) { if PEERS.read().values().any(|p| p.able("bulk-rename")) {
Client::push(EmberBulk::borrowed(changes.clone()))?; Client::push(EmberBulkRename::borrowed(changes.clone()))?;
} }
if LOCAL.read().contains_key("bulk") { if LOCAL.read().contains_key("bulk-rename") {
Self::r#pub(EmberBulk::owned(changes))?; Self::r#pub(EmberBulkRename::owned(changes))?;
} }
Ok(()) Ok(())
} }