feat: casefold (#3114)

This commit is contained in:
三咲雅 misaki masa 2025-08-27 15:55:35 +08:00 committed by GitHub
parent 12172b7301
commit c2a94daad6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 109 additions and 136 deletions

View file

@ -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"}
{"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"]}

View file

@ -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(())
}
}

View file

@ -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);

View file

@ -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<Url<'a>>) -> bool {
@ -31,81 +28,6 @@ pub fn ok_or_not_found<T: Default>(result: io::Result<T>) -> io::Result<T> {
}
}
pub async fn realname(url: &UrlBuf) -> Option<OsString> {
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<PathBuf, HashSet<OsString>>) {
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<PathBuf, HashSet<OsString>>,
) -> Result<Cow<'a, OsStr>> {
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::<Result<_>>().unwrap();
let mut comp = urls[0].components();

View file

@ -1,8 +1,15 @@
use std::{io, path::{Path, PathBuf}};
pub async fn valid_name_case(path: impl AsRef<Path>) -> io::Result<bool> {
#[inline]
pub async fn casefold(path: impl AsRef<Path>) -> io::Result<PathBuf> {
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<Path>) -> 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<Path>) -> io::Result<bool> {
target_os = "openbsd",
target_os = "freebsd"
))]
fn valid_name_case_impl(path: PathBuf) -> io::Result<bool> {
use std::{ffi::{CStr, CString, OsStr}, os::{fd::{AsRawFd, FromRawFd, OwnedFd}, unix::ffi::OsStrExt}};
fn casefold_impl(path: PathBuf) -> io::Result<PathBuf> {
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<bool> {
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<bool> {
use std::{ffi::{CString, OsStr}, fs::File, os::{fd::{AsRawFd, FromRawFd}, unix::ffi::OsStrExt}};
fn casefold_impl(path: PathBuf) -> io::Result<PathBuf> {
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<bool> {
fn casefold_impl(path: PathBuf) -> io::Result<PathBuf> {
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<bool> {
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])))
}
}

View file

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

View file

@ -24,6 +24,18 @@ where
}
}
#[inline]
pub async fn casefold<'a, U>(url: U) -> io::Result<UrlBuf>
where
U: Into<Url<'a>>,
{
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<u64>
where

View file

@ -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;