From c2a94daad64e55b3543f5913884ebc9ff79ef69d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=89=E5=92=B2=E9=9B=85=20misaki=20masa?= Date: Wed, 27 Aug 2025 15:55:35 +0800 Subject: [PATCH] feat: casefold (#3114) --- cspell.json | 2 +- yazi-actor/src/mgr/create.rs | 27 +++++--- yazi-actor/src/mgr/rename.rs | 29 +++++---- yazi-fs/src/fns.rs | 86 ++------------------------ yazi-fs/src/provider/local/casefold.rs | 79 +++++++++++++++-------- yazi-fs/src/provider/local/local.rs | 8 +-- yazi-fs/src/provider/provider.rs | 12 ++++ yazi-watcher/src/watcher.rs | 2 +- 8 files changed, 109 insertions(+), 136 deletions(-) diff --git a/cspell.json b/cspell.json index f18b3e65..43970748 100644 --- a/cspell.json +++ b/cspell.json @@ -1 +1 @@ -{"flagWords":[],"language":"en","words":["Punct","KEYMAP","splitn","crossterm","YAZI","peekable","ratatui","syntect","pbpaste","pbcopy","oneshot","Posix","Lsar","XADDOS","zoxide","cands","Deque","precache","imageops","IFBLK","IFCHR","IFDIR","IFIFO","IFLNK","IFMT","IFSOCK","IRGRP","IROTH","IRUSR","ISGID","ISUID","ISVTX","IWGRP","IWOTH","IWUSR","IXGRP","IXOTH","IXUSR","libc","winsize","TIOCGWINSZ","xpixel","ypixel","ioerr","appender","Catppuccin","macchiato","gitmodules","Dotfiles","bashprofile","vimrc","flac","webp","exiftool","mediainfo","ripgrep","indexmap","indexmap","unwatch","canonicalize","serde","fsevent","Ueberzug","iterm","wezterm","sixel","chafa","ueberzugpp","Konsole","Überzug","pkgs","pdftoppm","poppler","singlefile","jpegopt","EXIF","rustfmt","mktemp","nanos","xclip","xsel","natord","Mintty","nixos","nixpkgs","SIGTSTP","SIGCONT","SIGCONT","mlua","nonstatic","userdata","metatable","natsort","backstack","luajit","Succ","Succ","cand","fileencoding","foldmethod","lightgreen","darkgray","lightred","lightyellow","lightcyan","nushell","msvc","aarch","linemode","sxyazi","rsplit","ZELLIJ","bitflags","bitflags","USERPROFILE","Neovim","vergen","gitcl","Renderable","preloaders","prec","Upserting","prio","Ghostty","Catmull","Lanczos","cmds","unyank","scrolloff","headsup","unsub","uzers","scopeguard","SPDLOG","globset","filetime","magick","magick","prefetcher","Prework","prefetchers","PREWORKERS","conds","translit","rxvt","Urxvt","realpath","realname","REPARSE","hardlink","hardlinking","nlink","nlink","linemodes","SIGSTOP","sevenzip","rsplitn","replacen","DECSET","DECRQM","repeek","cwds","tcsi","Hyprland","Wayfire","SWAYSOCK","btime","nsec","codegen","gethostname","fchmod","fdfind","Rustc","rustc","ffprobe","vframes","luma","obase","outln","errln","tmtheme","twox","cfgs","fstype","objc","rdev","runloop","exfat","rclone","DECRQSS","DECSCUSR","libvterm","Uninit","lockin","rposition","resvg","foldhash","tilded","futs","chdir","hashbrown","JEMALLOC","RUSTFLAGS","RDONLY","GETPATH","fcntl","casefold"],"version":"0.2"} \ No newline at end of file +{"version":"0.2","language":"en","flagWords":[],"words":["Punct","KEYMAP","splitn","crossterm","YAZI","peekable","ratatui","syntect","pbpaste","pbcopy","oneshot","Posix","Lsar","XADDOS","zoxide","cands","Deque","precache","imageops","IFBLK","IFCHR","IFDIR","IFIFO","IFLNK","IFMT","IFSOCK","IRGRP","IROTH","IRUSR","ISGID","ISUID","ISVTX","IWGRP","IWOTH","IWUSR","IXGRP","IXOTH","IXUSR","libc","winsize","TIOCGWINSZ","xpixel","ypixel","ioerr","appender","Catppuccin","macchiato","gitmodules","Dotfiles","bashprofile","vimrc","flac","webp","exiftool","mediainfo","ripgrep","indexmap","indexmap","unwatch","canonicalize","serde","fsevent","Ueberzug","iterm","wezterm","sixel","chafa","ueberzugpp","Konsole","Überzug","pkgs","pdftoppm","poppler","singlefile","jpegopt","EXIF","rustfmt","mktemp","nanos","xclip","xsel","natord","Mintty","nixos","nixpkgs","SIGTSTP","SIGCONT","SIGCONT","mlua","nonstatic","userdata","metatable","natsort","backstack","luajit","Succ","Succ","cand","fileencoding","foldmethod","lightgreen","darkgray","lightred","lightyellow","lightcyan","nushell","msvc","aarch","linemode","sxyazi","rsplit","ZELLIJ","bitflags","bitflags","USERPROFILE","Neovim","vergen","gitcl","Renderable","preloaders","prec","Upserting","prio","Ghostty","Catmull","Lanczos","cmds","unyank","scrolloff","headsup","unsub","uzers","scopeguard","SPDLOG","globset","filetime","magick","magick","prefetcher","Prework","prefetchers","PREWORKERS","conds","translit","rxvt","Urxvt","realpath","realname","REPARSE","hardlink","hardlinking","nlink","nlink","linemodes","SIGSTOP","sevenzip","rsplitn","replacen","DECSET","DECRQM","repeek","cwds","tcsi","Hyprland","Wayfire","SWAYSOCK","btime","nsec","codegen","gethostname","fchmod","fdfind","Rustc","rustc","ffprobe","vframes","luma","obase","outln","errln","tmtheme","twox","cfgs","fstype","objc","rdev","runloop","exfat","rclone","DECRQSS","DECSCUSR","libvterm","Uninit","lockin","rposition","resvg","foldhash","tilded","futs","chdir","hashbrown","JEMALLOC","RUSTFLAGS","RDONLY","GETPATH","fcntl","casefold","inodes"]} \ No newline at end of file diff --git a/yazi-actor/src/mgr/create.rs b/yazi-actor/src/mgr/create.rs index f6f43fde..786ead16 100644 --- a/yazi-actor/src/mgr/create.rs +++ b/yazi-actor/src/mgr/create.rs @@ -1,10 +1,10 @@ -use anyhow::Result; +use anyhow::{Result, bail}; use yazi_config::popup::{ConfirmCfg, InputCfg}; -use yazi_fs::{File, FilesOp, maybe_exists, ok_or_not_found, provider, realname}; +use yazi_fs::{File, FilesOp, maybe_exists, ok_or_not_found, provider}; use yazi_macro::succ; use yazi_parser::mgr::CreateOpt; use yazi_proxy::{ConfirmProxy, InputProxy, MgrProxy}; -use yazi_shared::{event::Data, url::{UrlBuf, UrnBuf}}; +use yazi_shared::{event::Data, url::UrlBuf}; use yazi_watcher::WATCHER; use crate::{Actor, Ctx}; @@ -42,25 +42,32 @@ impl Actor for Create { impl Create { async fn r#do(new: UrlBuf, dir: bool) -> Result<()> { - let Some(parent) = new.parent_url() else { return Ok(()) }; let _permit = WATCHER.acquire().await.unwrap(); if dir { provider::create_dir_all(&new).await?; - } else if let Some(real) = realname(&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.to_owned(), [UrnBuf::from(real)].into()).emit(); + FilesOp::Deleting(parent.into(), [urn].into()).emit(); provider::create(&new).await?; - } else { + } else if let Some(parent) = new.parent_url() { 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(f) = File::new(&new).await { - FilesOp::Upserting(parent.into(), [(f.urn().to_owned(), f)].into()).emit(); - MgrProxy::reveal(&new) + 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, file)].into()).emit(); + MgrProxy::reveal(&real); } + Ok(()) } } diff --git a/yazi-actor/src/mgr/rename.rs b/yazi-actor/src/mgr/rename.rs index b85da41d..e50887b4 100644 --- a/yazi-actor/src/mgr/rename.rs +++ b/yazi-actor/src/mgr/rename.rs @@ -1,11 +1,11 @@ use anyhow::Result; use yazi_config::popup::{ConfirmCfg, InputCfg}; use yazi_dds::Pubsub; -use yazi_fs::{File, FilesOp, maybe_exists, ok_or_not_found, provider, realname}; +use yazi_fs::{File, FilesOp, maybe_exists, ok_or_not_found, provider}; use yazi_macro::{act, err, succ}; use yazi_parser::mgr::RenameOpt; use yazi_proxy::{ConfirmProxy, InputProxy, MgrProxy}; -use yazi_shared::{Id, event::Data, url::{UrlBuf, UrnBuf}}; +use yazi_shared::{Id, event::Data, url::UrlBuf}; use yazi_watcher::WATCHER; use crate::{Actor, Ctx}; @@ -61,24 +61,29 @@ impl Actor for Rename { impl Rename { async fn r#do(tab: Id, old: UrlBuf, new: UrlBuf) -> Result<()> { - let Some((p_old, n_old)) = old.pair() else { return Ok(()) }; - let Some((p_new, n_new)) = new.pair() else { return Ok(()) }; + let Some((old_p, old_n)) = old.pair() else { return Ok(()) }; + let Some(_) = new.pair() else { return Ok(()) }; let _permit = WATCHER.acquire().await.unwrap(); - let overwritten = realname(&new).await; + let overwritten = provider::casefold(&new).await; provider::rename(&old, &new).await?; - if let Some(o) = overwritten { - ok_or_not_found(provider::rename(&p_new.join(&o), &new).await)?; - FilesOp::Deleting(p_new.to_owned(), [UrnBuf::from(o)].into()).emit(); + if let Ok(u) = overwritten + && let Some((parent, urn)) = u.pair() + { + ok_or_not_found(provider::rename(&u, &new).await)?; + FilesOp::Deleting(parent.to_owned(), [urn].into()).emit(); } + let new = provider::casefold(&new).await?; + let Some((new_p, new_n)) = new.pair() else { return Ok(()) }; + let file = File::new(&new).await?; - if p_new == p_old { - FilesOp::Upserting(p_old.into(), [(n_old, file)].into()).emit(); + if new_p == old_p { + FilesOp::Upserting(old_p.into(), [(old_n, file)].into()).emit(); } else { - FilesOp::Deleting(p_old.into(), [n_old].into()).emit(); - FilesOp::Upserting(p_new.into(), [(n_new, file)].into()).emit(); + FilesOp::Deleting(old_p.into(), [old_n].into()).emit(); + FilesOp::Upserting(new_p.into(), [(new_n, file)].into()).emit(); } MgrProxy::reveal(&new); diff --git a/yazi-fs/src/fns.rs b/yazi-fs/src/fns.rs index 3280a7fc..83ef617a 100644 --- a/yazi-fs/src/fns.rs +++ b/yazi-fs/src/fns.rs @@ -1,13 +1,10 @@ // FIXME: VFS -use std::{borrow::Cow, ffi::{OsStr, OsString}, path::{Path, PathBuf}}; - -use anyhow::{Result, bail}; -use hashbrown::{HashMap, HashSet}; -use tokio::{fs, io, select, sync::{mpsc, oneshot}, time}; +use anyhow::Result; +use tokio::{io, select, sync::{mpsc, oneshot}, time}; use yazi_shared::url::{Component, Url, UrlBuf}; -use crate::{cha::Cha, provider::{self, local::Local}}; +use crate::{cha::Cha, provider}; #[inline] pub async fn maybe_exists<'a>(url: impl Into>) -> bool { @@ -31,81 +28,6 @@ pub fn ok_or_not_found(result: io::Result) -> io::Result { } } -pub async fn realname(url: &UrlBuf) -> Option { - let (path, name) = (url.as_path()?, url.name()?); - if path == Local::canonicalize(path).await.ok()? { - return None; - } - - realname_unchecked(path, &mut HashMap::new()) - .await - .ok() - .filter(|s| s != name) - .map(|s| s.into_owned()) -} - -#[cfg(unix)] -#[tokio::test] -async fn test_realname_unchecked() -> Result<()> { - use crate::provider::local::Local; - - Local::remove_dir_all("/tmp/issue-1173").await.ok(); - Local::create_dir_all("/tmp/issue-1173/real-dir").await?; - Local::create("/tmp/issue-1173/A").await?; - Local::create("/tmp/issue-1173/b").await?; - Local::create("/tmp/issue-1173/real-dir/C").await?; - Local::symlink_file("/tmp/issue-1173/b", "/tmp/issue-1173/D").await?; - Local::symlink_dir("real-dir", "/tmp/issue-1173/link-dir").await?; - - let c = &mut HashMap::new(); - async fn check(a: &str, b: &str, c: &mut HashMap>) { - assert_eq!(realname_unchecked(Path::new(a), c).await.ok(), Some(OsStr::new(b).into())); - } - - check("/tmp/issue-1173/a", "A", c).await; - check("/tmp/issue-1173/A", "A", c).await; - - check("/tmp/issue-1173/b", "b", c).await; - check("/tmp/issue-1173/B", "b", c).await; - - check("/tmp/issue-1173/link-dir/c", "C", c).await; - check("/tmp/issue-1173/link-dir/C", "C", c).await; - - check("/tmp/issue-1173/d", "D", c).await; - check("/tmp/issue-1173/D", "D", c).await; - Ok(()) -} - -// realpath(3) without resolving symlinks. This is useful for case-insensitive -// filesystems. -// -// Make sure the file of the path exists. -pub async fn realname_unchecked<'a>( - path: &'a Path, - cached: &'a mut HashMap>, -) -> Result> { - let Some(name) = path.file_name() else { bail!("no filename") }; - let Some(parent) = path.parent() else { return Ok(Cow::Borrowed(name)) }; - - if !cached.contains_key(parent) { - let mut set = HashSet::new(); - let mut it = fs::read_dir(parent).await?; - while let Some(entry) = it.next_entry().await? { - set.insert(entry.file_name()); - } - cached.insert(parent.to_owned(), set); - } - - let c = &cached[parent]; - if c.contains(name) { - Ok(Cow::Borrowed(name)) - } else if let Some(n) = c.iter().find(|&n| n.eq_ignore_ascii_case(name)) { - Ok(Cow::Borrowed(n)) - } else { - bail!("no such file") - } -} - pub fn copy_with_progress( from: &UrlBuf, to: &UrlBuf, @@ -270,7 +192,7 @@ pub fn max_common_root(urls: &[UrlBuf]) -> usize { #[test] fn test_max_common_root() { fn assert(input: &[&str], expected: &str) { - use std::str::FromStr; + use std::{ffi::OsStr, str::FromStr}; let urls: Vec<_> = input.iter().copied().map(UrlBuf::from_str).collect::>().unwrap(); let mut comp = urls[0].components(); diff --git a/yazi-fs/src/provider/local/casefold.rs b/yazi-fs/src/provider/local/casefold.rs index a86da5d0..c7a1d3d8 100644 --- a/yazi-fs/src/provider/local/casefold.rs +++ b/yazi-fs/src/provider/local/casefold.rs @@ -1,8 +1,15 @@ use std::{io, path::{Path, PathBuf}}; -pub async fn valid_name_case(path: impl AsRef) -> io::Result { +#[inline] +pub async fn casefold(path: impl AsRef) -> io::Result { let path = path.as_ref().to_owned(); - tokio::task::spawn_blocking(move || valid_name_case_impl(path)).await? + tokio::task::spawn_blocking(move || casefold_impl(path)).await? +} + +#[inline] +pub async fn must_case_match(path: impl AsRef) -> bool { + let path = path.as_ref(); + casefold(path).await.is_ok_and(|p| p == path) } #[cfg(any( @@ -11,16 +18,12 @@ pub async fn valid_name_case(path: impl AsRef) -> io::Result { target_os = "openbsd", target_os = "freebsd" ))] -fn valid_name_case_impl(path: PathBuf) -> io::Result { - use std::{ffi::{CStr, CString, OsStr}, os::{fd::{AsRawFd, FromRawFd, OwnedFd}, unix::ffi::OsStrExt}}; +fn casefold_impl(path: PathBuf) -> io::Result { + use std::{ffi::{CStr, CString, OsString}, os::{fd::{AsRawFd, FromRawFd, OwnedFd}, unix::ffi::OsStringExt}}; use libc::{F_GETPATH, O_RDONLY, O_SYMLINK, PATH_MAX}; - let cstr = CString::new(path.into_os_string().into_encoded_bytes())?; - let Some(name) = Path::new(OsStr::from_bytes(cstr.as_bytes())).file_name() else { - return Ok(true); - }; - + let cstr = CString::new(path.into_os_string().into_vec())?; let fd = match unsafe { libc::open(cstr.as_ptr(), O_RDONLY | O_SYMLINK) } { ret if ret < 0 => return Err(io::Error::last_os_error()), ret => unsafe { OwnedFd::from_raw_fd(ret) }, @@ -31,43 +34,67 @@ fn valid_name_case_impl(path: PathBuf) -> io::Result { return Err(io::Error::last_os_error()); } - Ok( - unsafe { CStr::from_ptr(buf.as_ptr() as *const i8) } - .to_bytes() - .ends_with(name.as_encoded_bytes()), - ) + let cstr = unsafe { CStr::from_ptr(buf.as_ptr() as *const i8) }; + Ok(OsString::from_vec(cstr.to_bytes().to_vec()).into()) } #[cfg(any(target_os = "linux", target_os = "android"))] -fn valid_name_case_impl(path: PathBuf) -> io::Result { - use std::{ffi::{CString, OsStr}, fs::File, os::{fd::{AsRawFd, FromRawFd}, unix::ffi::OsStrExt}}; +fn casefold_impl(path: PathBuf) -> io::Result { + use std::{ffi::{CString, OsStr, OsString}, fs::File, os::{fd::{AsRawFd, FromRawFd}, unix::{ffi::{OsStrExt, OsStringExt}, fs::MetadataExt}}}; use libc::{O_NOFOLLOW, O_PATH}; - let cstr = CString::new(path.into_os_string().into_encoded_bytes())?; + let cstr = CString::new(path.into_os_string().into_vec())?; let path = Path::new(OsStr::from_bytes(cstr.as_bytes())); - let Some(name) = path.file_name() else { return Ok(true) }; + let Some(parent) = path.parent() else { + return Ok(PathBuf::from(OsString::from_vec(cstr.into_bytes()))); + }; let file = match unsafe { libc::open(cstr.as_ptr(), O_PATH | O_NOFOLLOW) } { ret if ret < 0 => return Err(io::Error::last_os_error()), ret => unsafe { File::from_raw_fd(ret) }, }; - Ok(if file.metadata()?.is_symlink() { - std::fs::read_link(format!("/proc/self/fd/{}", file.as_raw_fd()))?.starts_with(path) + // Fast path: if the `/proc/self/fd/N` matches + let oss = path.as_os_str(); + if let Ok(p) = std::fs::read_link(format!("/proc/self/fd/{}", file.as_raw_fd())) + && let Some(b) = p.as_os_str().as_bytes().get(..oss.len()) + && b.eq_ignore_ascii_case(oss.as_bytes()) + { + let mut b = p.into_os_string().into_vec(); + b.truncate(oss.len()); + return Ok(PathBuf::from(OsString::from_vec(b))); + } + + // Fallback: scan the directory for matching inodes + let meta = file.metadata()?; + let mut entries: Vec<_> = std::fs::read_dir(parent)? + .filter_map(Result::ok) + .filter_map(|e| e.metadata().ok().map(|m| (e, m))) + .filter(|(_, m)| m.dev() == meta.dev() && m.ino() == meta.ino()) + .map(|(e, _)| e.path()) + .collect(); + + if entries.len() == 1 { + // No hardlink that shares the same inode + Ok(entries.remove(0)) + } else if let Some(i) = entries.iter().position(|p| p == path) { + // Exact match + Ok(entries.swap_remove(i)) + } else if let Some(i) = entries.iter().position(|p| p.as_os_str().eq_ignore_ascii_case(oss)) { + // Case-insensitive match + Ok(entries.swap_remove(i)) } else { - std::fs::canonicalize(path)?.file_name() == Some(name) - }) + Err(io::Error::new(io::ErrorKind::NotFound, "file not found")) + } } #[cfg(target_os = "windows")] -fn valid_name_case_impl(path: PathBuf) -> io::Result { +fn casefold_impl(path: PathBuf) -> io::Result { use std::{ffi::OsString, os::windows::{ffi::OsStringExt, fs::OpenOptionsExt, io::AsRawHandle}}; use windows_sys::Win32::{Foundation::{HANDLE, MAX_PATH}, Storage::FileSystem::{FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, GetFinalPathNameByHandleW, VOLUME_NAME_DOS}}; - let Some(name) = path.file_name() else { return Ok(true) }; - let file = std::fs::OpenOptions::new() .access_mode(0) .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT) @@ -86,6 +113,6 @@ fn valid_name_case_impl(path: PathBuf) -> io::Result { if len == 0 { Err(io::Error::last_os_error()) } else { - Ok(PathBuf::from(OsString::from_wide(&buf[0..len as usize])).file_name() == Some(name)) + Ok(PathBuf::from(OsString::from_wide(&buf[0..len as usize]))) } } diff --git a/yazi-fs/src/provider/local/local.rs b/yazi-fs/src/provider/local/local.rs index 45760647..43c9d1d7 100644 --- a/yazi-fs/src/provider/local/local.rs +++ b/yazi-fs/src/provider/local/local.rs @@ -114,11 +114,11 @@ impl Local { } #[inline] - pub async fn metadata

(url: P) -> io::Result + pub async fn metadata

(path: P) -> io::Result where P: AsRef, { - tokio::fs::metadata(url).await + tokio::fs::metadata(path).await } #[inline] @@ -154,11 +154,11 @@ impl Local { } #[inline] - pub async fn read_link

(url: P) -> io::Result + pub async fn read_link

(path: P) -> io::Result where P: AsRef, { - tokio::fs::read_link(url).await + tokio::fs::read_link(path).await } #[inline] diff --git a/yazi-fs/src/provider/provider.rs b/yazi-fs/src/provider/provider.rs index 9bbf0742..0d110a35 100644 --- a/yazi-fs/src/provider/provider.rs +++ b/yazi-fs/src/provider/provider.rs @@ -24,6 +24,18 @@ where } } +#[inline] +pub async fn casefold<'a, U>(url: U) -> io::Result +where + U: Into>, +{ + if let Some(path) = url.into().as_path() { + local::casefold(path).await.map(Into::into) + } else { + Err(io::Error::new(io::ErrorKind::Unsupported, "Unsupported filesystem")) + } +} + #[inline] pub async fn copy<'a, U, V>(from: U, to: V, cha: Cha) -> io::Result where diff --git a/yazi-watcher/src/watcher.rs b/yazi-watcher/src/watcher.rs index 23524a81..7a586bce 100644 --- a/yazi-watcher/src/watcher.rs +++ b/yazi-watcher/src/watcher.rs @@ -66,7 +66,7 @@ impl Watcher { }; if let Some(p) = file.url.as_path() - && !local::valid_name_case(p).await.unwrap_or(false) + && !local::must_case_match(p).await { ops.push(FilesOp::Deleting(parent.into(), [urn].into())); continue;