diff --git a/yazi-config/preset/yazi-default.toml b/yazi-config/preset/yazi-default.toml index 12100e71e..8d6e7a52b 100644 --- a/yazi-config/preset/yazi-default.toml +++ b/yazi-config/preset/yazi-default.toml @@ -212,6 +212,11 @@ quit_content = "The following tasks are still running, are you sure you want to quit_origin = "center" quit_offset = [ 0, 0, 50, 15 ] +# bulk rename +bulk_rename_title = "Rename {n} file{s}?" +bulk_rename_origin = "center" +bulk_rename_offset = [0, 0, 70, 30] + [pick] open_title = "Open with:" open_origin = "hovered" diff --git a/yazi-config/src/popup/confirm.rs b/yazi-config/src/popup/confirm.rs index 703102b2d..7874671a9 100644 --- a/yazi-config/src/popup/confirm.rs +++ b/yazi-config/src/popup/confirm.rs @@ -22,10 +22,14 @@ pub struct Confirm { pub overwrite_offset: Offset, // quit - pub quit_title: String, - pub quit_content: String, - pub quit_origin: Origin, - pub quit_offset: Offset, + pub quit_title: String, + pub quit_content: String, + pub quit_origin: Origin, + pub quit_offset: Offset, + // bulk rename + pub bulk_rename_title: String, + pub bulk_rename_origin: Origin, + pub bulk_rename_offset: Offset, } impl Confirm { diff --git a/yazi-config/src/popup/options.rs b/yazi-config/src/popup/options.rs index b27d0c65b..72b0777af 100644 --- a/yazi-config/src/popup/options.rs +++ b/yazi-config/src/popup/options.rs @@ -1,3 +1,5 @@ +use std::path::PathBuf; + use ratatui::{text::{Line, Text}, widgets::{Paragraph, Wrap}}; use yazi_shared::url::Url; @@ -154,6 +156,19 @@ impl ConfirmCfg { ) } + pub fn bulk_rename(todo: &[(PathBuf, PathBuf)]) -> Self { + let count = todo.len(); + + Self::new( + Self::replace_number(&YAZI.confirm.bulk_rename_title, count), + (YAZI.confirm.bulk_rename_origin, YAZI.confirm.bulk_rename_offset), + None, + Some(Text::from_iter( + todo.iter().map(|(old, new)| format!("{} -> {}", old.display(), new.display())), + )), + ) + } + fn replace_number(tpl: &str, n: usize) -> String { tpl.replace("{n}", &n.to_string()).replace("{s}", if n > 1 { "s" } else { "" }) } diff --git a/yazi-core/src/mgr/commands/bulk_rename.rs b/yazi-core/src/mgr/commands/bulk_rename.rs index f70c1a703..ce4d4ad23 100644 --- a/yazi-core/src/mgr/commands/bulk_rename.rs +++ b/yazi-core/src/mgr/commands/bulk_rename.rs @@ -1,20 +1,17 @@ -use std::{borrow::Cow, collections::HashMap, ffi::{OsStr, OsString}, io::{Read, Write}, path::PathBuf}; +use std::{borrow::Cow, collections::HashMap, ffi::{OsStr, OsString}, path::PathBuf, sync::Arc}; -use anyhow::{Result, anyhow}; -use crossterm::{execute, style::Print}; +use anyhow::Result; use scopeguard::defer; use tokio::{fs::{self, OpenOptions}, io::AsyncWriteExt}; -use yazi_config::YAZI; -use yazi_dds::Pubsub; -use yazi_fs::{File, FilesOp, max_common_root, maybe_exists, paths_to_same_file}; -use yazi_proxy::{AppProxy, HIDER, TasksProxy, WATCHER}; -use yazi_shared::{terminal_clear, url::Url}; -use yazi_term::tty::TTY; +use yazi_config::{YAZI, popup::ConfirmCfg}; +use yazi_fs::max_common_root; +use yazi_proxy::{AppProxy, ConfirmProxy, TasksProxy}; +use yazi_scheduler::Scheduler; use crate::mgr::Mgr; impl Mgr { - pub(super) fn bulk_rename(&self) { + pub(super) fn bulk_rename(&self, sched: Arc) { let Some(opener) = YAZI.opener.block(YAZI.open.all("bulk-rename.txt", "text/plain")) else { return AppProxy::notify_warn("Bulk rename", "No text opener found"); }; @@ -43,24 +40,27 @@ impl Mgr { ]) .await; - let _permit = HIDER.acquire().await.unwrap(); - defer!(AppProxy::resume()); - AppProxy::stop().await; - let new: Vec<_> = fs::read_to_string(&tmp).await?.lines().take(old.len()).map(PathBuf::from).collect(); - Self::bulk_rename_do(root, old, new).await + Self::bulk_rename_do(root, old, new, sched).await }); } - async fn bulk_rename_do(root: PathBuf, old: Vec, new: Vec) -> Result<()> { - terminal_clear(TTY.writer())?; + async fn bulk_rename_do( + root: PathBuf, + old: Vec, + new: Vec, + sched: Arc, + ) -> Result<()> { 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])?; + AppProxy::notify_error( + "Bulk rename", + format!( + "Number of new and old file names mismatch (New: {}, Old: {})", + new.len(), + old.len() + ), + ); return Ok(()); } @@ -70,61 +70,14 @@ impl Mgr { return Ok(()); } - { - 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)?; - if buf[0] != b'y' && buf[0] != b'Y' { + if !ConfirmProxy::show(ConfirmCfg::bulk_rename(&todo)).await { 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 (old, new) = (root.join(&o), root.join(&n)); - - if maybe_exists(&new).await && !paths_to_same_file(&old, &new).await { - failed.push((o, n, anyhow!("Destination already exists"))); - } else if let Err(e) = fs::rename(&old, &new).await { - failed.push((o, n, e.into())); - } else if let Ok(f) = File::from(new.into()).await { - succeeded.insert(Url::from(old), f); - } else { - failed.push((o, n, anyhow!("Failed to retrieve file info"))); - } - } - - if !succeeded.is_empty() { - Pubsub::pub_from_bulk(succeeded.iter().map(|(o, n)| (o, &n.url)).collect()); - FilesOp::rename(succeeded); - } - drop(permit); - - if !failed.is_empty() { - Self::output_failed(failed).await?; - } - Ok(()) - } - - async fn output_failed(failed: Vec<(PathBuf, PathBuf, 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())?; + for (old, new) in todo { + sched.file_rename_at(&root, &old, &new); } - writeln!(stdout, "\nPress ENTER to exit")?; - stdout.flush()?; - TTY.reader().read_exact(&mut [0])?; Ok(()) } diff --git a/yazi-core/src/mgr/commands/rename.rs b/yazi-core/src/mgr/commands/rename.rs index bf6e809de..5bf736fe7 100644 --- a/yazi-core/src/mgr/commands/rename.rs +++ b/yazi-core/src/mgr/commands/rename.rs @@ -8,7 +8,7 @@ use yazi_fs::{File, FilesOp, maybe_exists, ok_or_not_found, paths_to_same_file, use yazi_proxy::{ConfirmProxy, InputProxy, TabProxy, WATCHER}; use yazi_shared::{Id, event::CmdCow, url::{Url, UrnBuf}}; -use crate::mgr::Mgr; +use crate::{mgr::Mgr, tasks::Tasks}; struct Opt { hovered: bool, @@ -30,11 +30,11 @@ impl From for Opt { impl Mgr { #[yazi_codegen::command] - pub fn rename(&mut self, opt: Opt) { + pub fn rename(&mut self, opt: Opt, tasks: &Tasks) { if !self.active_mut().try_escape_visual() { return; } else if !opt.hovered && !self.active().selected.is_empty() { - return self.bulk_rename(); + return self.bulk_rename(tasks.scheduler.clone()); } let Some(hovered) = self.hovered() else { return }; diff --git a/yazi-core/src/tasks/tasks.rs b/yazi-core/src/tasks/tasks.rs index 11a906d2d..bc3fe1cb0 100644 --- a/yazi-core/src/tasks/tasks.rs +++ b/yazi-core/src/tasks/tasks.rs @@ -10,8 +10,8 @@ use yazi_shared::event::Cmd; use super::{TASKS_BORDER, TASKS_PADDING, TASKS_PERCENT, TasksProgress}; pub struct Tasks { - pub(super) scheduler: Arc, - handle: JoinHandle<()>, + pub scheduler: Arc, + handle: JoinHandle<()>, pub visible: bool, pub cursor: usize, diff --git a/yazi-dds/src/pump.rs b/yazi-dds/src/pump.rs index 335ae521d..c2d204107 100644 --- a/yazi-dds/src/pump.rs +++ b/yazi-dds/src/pump.rs @@ -1,9 +1,10 @@ -use std::time::Duration; +use std::{collections::HashMap, time::Duration}; use parking_lot::Mutex; use tokio::{pin, select, sync::mpsc}; use tokio_stream::{StreamExt, wrappers::UnboundedReceiverStream}; use tokio_util::sync::CancellationToken; +use yazi_fs::{File, FilesOp}; use yazi_shared::{RoCell, url::Url}; use crate::{Pubsub, body::BodyMoveItem}; @@ -12,6 +13,7 @@ static CT: RoCell = RoCell::new(); static MOVE_TX: Mutex>> = Mutex::new(None); static TRASH_TX: Mutex>> = Mutex::new(None); static DELETE_TX: Mutex>> = Mutex::new(None); +static BULK_RENAME_TX: Mutex>> = Mutex::new(None); pub struct Pump; @@ -37,15 +39,24 @@ impl Pump { } } + #[inline] + pub fn push_bulk_rename_pair(from: Url, to: File) { + if let Some(tx) = &*BULK_RENAME_TX.lock() { + tx.send((from, to)).ok(); + } + } + pub(super) fn serve() { let (move_tx, move_rx) = mpsc::unbounded_channel(); let (trash_tx, trash_rx) = mpsc::unbounded_channel(); let (delete_tx, delete_rx) = mpsc::unbounded_channel(); + let (bulk_rename_tx, bulk_rename_rx) = mpsc::unbounded_channel(); CT.with(<_>::default); MOVE_TX.lock().replace(move_tx); TRASH_TX.lock().replace(trash_tx); DELETE_TX.lock().replace(delete_tx); + BULK_RENAME_TX.lock().replace(bulk_rename_tx); tokio::spawn(async move { let move_rx = @@ -54,16 +65,23 @@ impl Pump { UnboundedReceiverStream::new(trash_rx).chunks_timeout(1000, Duration::from_millis(500)); let delete_rx = UnboundedReceiverStream::new(delete_rx).chunks_timeout(1000, Duration::from_millis(500)); + let bulk_rename_rx = UnboundedReceiverStream::new(bulk_rename_rx) + .chunks_timeout(1000, Duration::from_millis(500)); pin!(move_rx); pin!(trash_rx); pin!(delete_rx); + pin!(bulk_rename_rx); loop { select! { Some(items) = move_rx.next() => Pubsub::pub_from_move(items), Some(urls) = trash_rx.next() => Pubsub::pub_from_trash(urls), Some(urls) = delete_rx.next() => Pubsub::pub_from_delete(urls), + Some(items) = bulk_rename_rx.next() => { + Pubsub::pub_from_bulk(items.iter().map(|(from, to)| (from, &to.url)).collect()); + FilesOp::rename(HashMap::from_iter(items)); + } else => { CT.cancel(); break; @@ -77,6 +95,7 @@ impl Pump { drop(MOVE_TX.lock().take()); drop(TRASH_TX.lock().take()); drop(DELETE_TX.lock().take()); + drop(BULK_RENAME_TX.lock().take()); CT.cancelled().await; } } diff --git a/yazi-fm/src/app/commands/render.rs b/yazi-fm/src/app/commands/render.rs index b0382de7d..da5ba3ac6 100644 --- a/yazi-fm/src/app/commands/render.rs +++ b/yazi-fm/src/app/commands/render.rs @@ -79,12 +79,11 @@ impl App { fn routine(push: bool, cursor: Option<(Position, SetCursorStyle)>) { static COUNT: AtomicU8 = AtomicU8::new(0); - if push && COUNT.fetch_add(1, Ordering::Relaxed) != 0 { - return; - } else if !push && COUNT.fetch_sub(1, Ordering::Relaxed) != 1 { + if (push && COUNT.fetch_add(1, Ordering::Relaxed) != 0) + || (!push && COUNT.fetch_sub(1, Ordering::Relaxed) != 1) + { return; } - _ = if push { queue!(TTY.writer(), BeginSynchronizedUpdate) } else if let Some((Position { x, y }, shape)) = cursor { diff --git a/yazi-fm/src/executor.rs b/yazi-fm/src/executor.rs index 981193423..3714e6ab1 100644 --- a/yazi-fm/src/executor.rs +++ b/yazi-fm/src/executor.rs @@ -115,7 +115,7 @@ impl<'a> Executor<'a> { on!(MGR, remove, &self.app.cx.tasks); on!(MGR, remove_do, &self.app.cx.tasks); on!(MGR, create); - on!(MGR, rename); + on!(MGR, rename, &self.app.cx.tasks); on!(ACTIVE, copy); on!(ACTIVE, shell); on!(ACTIVE, hidden); diff --git a/yazi-scheduler/src/file/file.rs b/yazi-scheduler/src/file/file.rs index 358998c9f..7c4206bb8 100644 --- a/yazi-scheduler/src/file/file.rs +++ b/yazi-scheduler/src/file/file.rs @@ -4,10 +4,10 @@ use anyhow::{Result, anyhow}; use tokio::{fs::{self, DirEntry}, io::{self, ErrorKind::{AlreadyExists, NotFound}}, sync::mpsc}; use tracing::warn; use yazi_config::YAZI; -use yazi_fs::{SizeCalculator, cha::Cha, copy_with_progress, maybe_exists, ok_or_not_found, path_relative_to, skip_path}; +use yazi_fs::{SizeCalculator, cha::Cha, copy_with_progress, maybe_exists, ok_or_not_found, path_relative_to, paths_to_same_file, skip_path}; use yazi_shared::url::Url; -use super::{FileOp, FileOpDelete, FileOpHardlink, FileOpLink, FileOpPaste, FileOpTrash}; +use super::{FileOp, FileOpDelete, FileOpHardlink, FileOpLink, FileOpPaste, FileOpRename, FileOpTrash}; use crate::{LOW, NORMAL, TaskOp, TaskProg}; pub struct File { @@ -146,6 +146,26 @@ impl File { .await??; self.prog.send(TaskProg::Adv(task.id, 1, task.length))?; } + FileOp::Rename(task) => { + if maybe_exists(&task.to).await && !paths_to_same_file(&task.from, &task.to).await { + let e = anyhow!("Destination already exists"); + self.fail(task.id, format!("An error occurred while renaming: {e:?}"))?; + return Err(e); + } + + if let Err(e) = fs::rename(&task.from, &task.to).await { + self.fail(task.id, format!("An error occurred while renaming: {e}"))?; + return Err(e.into()); + } + + if yazi_fs::File::from(task.to).await.is_err() { + let e = anyhow!("Failed to retrieve file info"); + self.fail(task.id, format!("An error occurred while renaming: {e:?}"))?; + return Err(e); + } + + self.prog.send(TaskProg::Adv(task.id, 1, 0))?; + } } Ok(()) } @@ -325,6 +345,14 @@ impl File { self.succ(id) } + pub async fn rename(&self, task: FileOpRename) -> Result<()> { + let id = task.id; + + self.prog.send(TaskProg::New(id, 0))?; + self.queue(FileOp::Rename(task), NORMAL).await?; + self.succ(id) + } + #[inline] async fn cha(path: &Path, follow: bool) -> io::Result { let meta = fs::symlink_metadata(path).await?; diff --git a/yazi-scheduler/src/file/op.rs b/yazi-scheduler/src/file/op.rs index 0d87fde89..757ac8f22 100644 --- a/yazi-scheduler/src/file/op.rs +++ b/yazi-scheduler/src/file/op.rs @@ -8,6 +8,7 @@ pub enum FileOp { Hardlink(FileOpHardlink), Delete(FileOpDelete), Trash(FileOpTrash), + Rename(FileOpRename), } impl FileOp { @@ -18,6 +19,7 @@ impl FileOp { Self::Hardlink(op) => op.id, Self::Delete(op) => op.id, Self::Trash(op) => op.id, + Self::Rename(op) => op.id, } } } @@ -105,3 +107,10 @@ pub struct FileOpTrash { pub target: Url, pub length: u64, } +// --- Rename +#[derive(Clone, Debug)] +pub struct FileOpRename { + pub id: usize, + pub from: Url, + pub to: Url, +} diff --git a/yazi-scheduler/src/scheduler.rs b/yazi-scheduler/src/scheduler.rs index 9b2754565..5be7db8bc 100644 --- a/yazi-scheduler/src/scheduler.rs +++ b/yazi-scheduler/src/scheduler.rs @@ -1,4 +1,4 @@ -use std::{ffi::OsString, future::Future, sync::Arc, time::Duration}; +use std::{ffi::OsString, future::Future, path::Path, sync::Arc, time::Duration}; use anyhow::Result; use futures::{FutureExt, future::BoxFuture}; @@ -11,7 +11,7 @@ use yazi_proxy::{MgrProxy, options::{PluginOpt, ProcessExecOpt}}; use yazi_shared::{Throttle, url::Url}; use super::{Ongoing, TaskProg, TaskStage}; -use crate::{HIGH, LOW, NORMAL, TaskKind, TaskOp, file::{File, FileOpDelete, FileOpHardlink, FileOpLink, FileOpPaste, FileOpTrash}, plugin::{Plugin, PluginOpEntry}, prework::{Prework, PreworkOpFetch, PreworkOpLoad, PreworkOpSize}, process::{Process, ProcessOpBg, ProcessOpBlock, ProcessOpOrphan}}; +use crate::{HIGH, LOW, NORMAL, TaskKind, TaskOp, file::{File, FileOpDelete, FileOpHardlink, FileOpLink, FileOpPaste, FileOpRename, FileOpTrash}, plugin::{Plugin, PluginOpEntry}, prework::{Prework, PreworkOpFetch, PreworkOpLoad, PreworkOpSize}, process::{Process, ProcessOpBg, ProcessOpBlock, ProcessOpOrphan}}; pub struct Scheduler { pub file: Arc, @@ -209,6 +209,37 @@ impl Scheduler { }) } + pub fn file_rename_at(&self, root: &Path, old: &Path, new: &Path) { + let mut ongoing = self.ongoing.lock(); + let id = ongoing.add( + TaskKind::User, + format!("Rename at {}: {} -> {} ", root.display(), old.display(), new.display()), + ); + + let (from, to): (Url, Url) = (root.join(old).into(), root.join(new).into()); + + ongoing.hooks.insert(id, { + let from = from.clone(); + let to = to.clone(); + let ongoing = self.ongoing.clone(); + + Box::new(move |canceled: bool| { + async move { + if !canceled { + if let Ok(to) = yazi_fs::File::from(to).await { + Pump::push_bulk_rename_pair(from, to); + } + } + ongoing.lock().try_remove(id, TaskStage::Hooked); + } + .boxed() + }) + }); + + let file = self.file.clone(); + self.send_micro(id, LOW, async move { file.rename(FileOpRename { id, from, to }).await }); + } + pub fn plugin_micro(&self, opt: PluginOpt) { let id = self.ongoing.lock().add(TaskKind::User, format!("Run micro plugin `{}`", opt.id));