diff --git a/README.md b/README.md index 9cd013a9..4c4fa84e 100644 --- a/README.md +++ b/README.md @@ -143,12 +143,7 @@ it will automatically use the "Window system protocol" to display images -- this ## TODO -- [x] Add example config for general usage, currently please see my [another repo](https://github.com/sxyazi/dotfiles/tree/main/yazi) instead -- [x] Integration with fzf, zoxide for fast directory navigation -- [x] Integration with fd, rg for fuzzy file searching -- [x] Documentation of commands and options -- [x] Support for Überzug++ for image previews with X11/wayland environment -- [ ] Batch renaming support +See [Feature requests](https://github.com/sxyazi/yazi/issues/51) for more details. ## License diff --git a/config/src/boot/boot.rs b/config/src/boot/boot.rs index 57bcf326..94a938e5 100644 --- a/config/src/boot/boot.rs +++ b/config/src/boot/boot.rs @@ -48,8 +48,8 @@ impl Boot { } #[inline] - pub fn tmpfile(&self) -> PathBuf { + pub fn tmpfile(&self, prefix: &str) -> PathBuf { let nanos = SystemTime::now().duration_since(time::UNIX_EPOCH).unwrap().as_nanos(); - self.cache_dir.join(format!("{:x}", Md5::new_with_prefix(nanos.to_le_bytes()).finalize())) + self.cache_dir.join(format!("{prefix}-{}", nanos / 1000)) } } diff --git a/core/src/manager/folder.rs b/core/src/manager/folder.rs index 177da4fd..15280969 100644 --- a/core/src/manager/folder.rs +++ b/core/src/manager/folder.rs @@ -115,12 +115,11 @@ impl Folder { pub fn select(&mut self, idx: Option, state: Option) -> bool { let len = self.files.len(); let mut apply = |idx: usize, state: Option| -> bool { - if state.is_none() { + let Some(state) = state else { self.files[idx].is_selected = !self.files[idx].is_selected; return true; - } + }; - let state = state.unwrap(); if state != self.files[idx].is_selected { self.files[idx].is_selected = state; return true; diff --git a/core/src/manager/manager.rs b/core/src/manager/manager.rs index a073b461..f6ee59e3 100644 --- a/core/src/manager/manager.rs +++ b/core/src/manager/manager.rs @@ -1,12 +1,12 @@ -use std::{collections::{BTreeMap, BTreeSet, HashMap, HashSet}, env, mem, path::{Path, PathBuf}}; +use std::{collections::{BTreeMap, BTreeSet, HashMap, HashSet}, env, ffi::OsStr, io::{stdout, Write}, mem, os::unix::prelude::OsStrExt, path::{Path, PathBuf}}; -use anyhow::Error; -use config::{open::Opener, OPEN}; -use shared::MIME_DIR; -use tokio::fs; +use anyhow::{bail, Error, Result}; +use config::{open::Opener, BOOT, OPEN}; +use shared::{max_common_root, Defer, Term, MIME_DIR}; +use tokio::{fs::{self, OpenOptions}, io::{AsyncReadExt, AsyncWriteExt}}; use super::{PreviewData, Tab, Tabs, Watcher}; -use crate::{emit, external, files::{File, FilesOp}, input::InputOpt, manager::Folder, select::SelectOpt, tasks::Tasks}; +use crate::{emit, external::{self, ShellOpt}, files::{File, FilesOp}, input::InputOpt, manager::Folder, select::SelectOpt, tasks::Tasks, Event, BLOCKER}; pub struct Manager { tabs: Tabs, @@ -222,7 +222,97 @@ impl Manager { false } - pub fn bulk_rename(&self) -> bool { false } + pub fn bulk_rename(&self) -> bool { + let mut old: Vec<_> = self.selected().iter().map(|&f| f.path()).collect(); + + let root = max_common_root(&old); + old = old.into_iter().map(|p| p.strip_prefix(&root).unwrap().to_owned()).collect(); + + let tmp = BOOT.tmpfile("bulk"); + tokio::spawn(async move { + let Some(opener) = OPEN.block_opener("bulk.txt", "text/plain") else { + bail!("No opener for bulk rename"); + }; + + { + let b = old.iter().map(|o| o.as_os_str()).collect::>().join(OsStr::new("\n")); + let mut f = OpenOptions::new().write(true).create_new(true).open(&tmp).await?; + f.write_all(b.as_bytes()).await?; + } + + let _guard = BLOCKER.acquire().await.unwrap(); + let _defer = Defer::new(|| { + Event::Stop(false, None).emit(); + tokio::spawn(fs::remove_file(tmp.clone())) + }); + emit!(Stop(true)).await; + + let mut child = external::shell(ShellOpt { + cmd: (*opener.exec).into(), + args: vec![tmp.to_owned().into()], + piped: false, + })?; + child.wait().await?; + + let new: Vec<_> = fs::read_to_string(&tmp).await?.lines().map(|l| l.into()).collect(); + Self::bulk_rename_do(root, old, new).await + }); + + false + } + + async fn bulk_rename_do(root: PathBuf, old: Vec, new: Vec) -> Result<()> { + Term::clear()?; + if old.len() != new.len() { + println!("Number of old and new differ, press ENTER to exit"); + tokio::io::stdin().read_exact(&mut [0]).await?; + return Ok(()); + } + + let mut todo = Vec::with_capacity(old.len()); + for (o, n) in old.into_iter().zip(new) { + if n != o { + stdout().write_all(o.as_os_str().as_bytes())?; + stdout().write_all(b" -> ")?; + stdout().write_all(n.as_os_str().as_bytes())?; + stdout().write_all(b"\n")?; + todo.push((root.join(o), root.join(n))); + } + } + if todo.is_empty() { + return Ok(()); + } else { + print!("Continue to rename? (y/N): "); + stdout().flush()?; + } + + let mut buf = [0]; + tokio::io::stdin().read_exact(&mut buf).await?; + if buf[0] != b'y' && buf[0] != b'Y' { + return Ok(()); + } + + let mut failed = Vec::new(); + for (o, n) in todo { + if let Err(e) = fs::rename(&o, &n).await { + failed.push((o, n, e)); + } + } + + if !failed.is_empty() { + Term::clear()?; + println!("Failed to rename:"); + for (o, n, e) in failed { + stdout().write_all(o.as_os_str().as_bytes())?; + stdout().write_all(b" -> ")?; + stdout().write_all(n.as_os_str().as_bytes())?; + stdout().write_fmt(format_args!(": {e}\n"))?; + } + println!("\nPress ENTER to exit"); + tokio::io::stdin().read_exact(&mut [0]).await?; + } + Ok(()) + } pub fn shell(&self, exec: &str, block: bool, confirm: bool) -> bool { let mut exec = exec.to_owned(); diff --git a/core/src/tasks/tasks.rs b/core/src/tasks/tasks.rs index 6e3ac6b5..836e13c4 100644 --- a/core/src/tasks/tasks.rs +++ b/core/src/tasks/tasks.rs @@ -82,7 +82,7 @@ impl Tasks { emit!(Stop(true)).await; let _defer = Defer::new(|| { disable_raw_mode().ok(); - Event::Stop(false, None).emit() + Event::Stop(false, None).emit(); }); stdout().write_all("\n".repeat(tty_size().ws_row as usize).as_bytes()).ok(); diff --git a/shared/src/defer.rs b/shared/src/defer.rs index 3c054bde..d247eef1 100644 --- a/shared/src/defer.rs +++ b/shared/src/defer.rs @@ -1,13 +1,13 @@ -pub struct Defer(Option); +pub struct Defer T, T>(Option); -impl Defer { +impl T, T> Defer { pub fn new(f: F) -> Self { Defer(Some(f)) } } -impl Drop for Defer { +impl T, T> Drop for Defer { fn drop(&mut self) { if let Some(f) = self.0.take() { - f(); + let _ = f(); } } } diff --git a/shared/src/fs.rs b/shared/src/fs.rs index 75f9ce24..bc62d97a 100644 --- a/shared/src/fs.rs +++ b/shared/src/fs.rs @@ -1,4 +1,4 @@ -use std::{collections::VecDeque, path::Path}; +use std::{collections::VecDeque, path::{Path, PathBuf}}; use anyhow::Result; use tokio::{fs, io, select, sync::{mpsc, oneshot}, time}; @@ -149,3 +149,40 @@ pub fn file_mode(mode: u32) -> String { s } + +// Find the max common root of a list of files +// e.g. /a/b/c, /a/b/d -> /a/b +// /aa/bb/cc, /aa/dd/ee -> /aa +pub fn max_common_root(files: &[PathBuf]) -> PathBuf { + if files.is_empty() { + return PathBuf::new(); + } + + let mut it = files.iter().map(|p| p.components()); + let mut root = it.next().unwrap().collect::(); + for components in it { + let mut new_root = PathBuf::new(); + for (a, b) in root.components().zip(components) { + if a != b { + break; + } + new_root.push(a); + } + root = new_root; + } + root +} + +#[test] +fn test_max_common_root() { + assert_eq!(max_common_root(&[]).as_os_str(), ""); + assert_eq!(max_common_root(&["".into()]).as_os_str(), ""); + assert_eq!(max_common_root(&["/a/b".into()]).as_os_str(), "/a/b"); + assert_eq!(max_common_root(&["/a/b/c".into(), "/a/b/d".into()]).as_os_str(), "/a/b"); + assert_eq!(max_common_root(&["/aa/bb/cc".into(), "/aa/dd/ee".into()]).as_os_str(), "/aa"); + assert_eq!( + max_common_root(&["/aa/bb/cc".into(), "/aa/bb/cc/dd/ee".into(), "/aa/bb/cc/ff".into()]) + .as_os_str(), + "/aa/bb/cc" + ); +} diff --git a/shared/src/term.rs b/shared/src/term.rs index c140627b..01de6dde 100644 --- a/shared/src/term.rs +++ b/shared/src/term.rs @@ -1,7 +1,7 @@ -use std::{io::{stdout, Stdout}, ops::{Deref, DerefMut}}; +use std::{io::{stdout, Stdout, Write}, ops::{Deref, DerefMut}}; use anyhow::Result; -use crossterm::{cursor::{MoveTo, SetCursorStyle}, event::{DisableBracketedPaste, DisableFocusChange, EnableBracketedPaste, EnableFocusChange, KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags}, execute, queue, terminal::{disable_raw_mode, enable_raw_mode, supports_keyboard_enhancement, EnterAlternateScreen, LeaveAlternateScreen}}; +use crossterm::{cursor::{MoveTo, SetCursorStyle}, event::{DisableBracketedPaste, DisableFocusChange, EnableBracketedPaste, EnableFocusChange, KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags}, execute, queue, terminal::{disable_raw_mode, enable_raw_mode, supports_keyboard_enhancement, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen}}; use ratatui::{backend::CrosstermBackend, Terminal}; pub struct Term { @@ -32,6 +32,13 @@ impl Term { Ok(term) } + pub fn clear() -> Result<()> { + execute!(stdout(), Clear(ClearType::All))?; + println!(); + stdout().flush()?; + Ok(()) + } + pub fn move_to(x: u16, y: u16) -> Result<()> { Ok(execute!(stdout(), MoveTo(x, y))?) } pub fn set_cursor_block() -> Result<()> { Ok(execute!(stdout(), SetCursorStyle::BlinkingBlock)?) }