fix: different filenames should be treated as the same file on case-insensitive file systems (#1151)

This commit is contained in:
三咲雅 · Misaki Masa 2024-06-13 20:38:08 +08:00 committed by GitHub
parent 189cb81db3
commit 794694e2d6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 181 additions and 101 deletions

28
Cargo.lock generated
View File

@ -293,9 +293,9 @@ dependencies = [
[[package]]
name = "clap"
version = "4.5.4"
version = "4.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90bc066a67923782aa8515dbaea16946c5bcc5addbd668bb80af688e53e548a0"
checksum = "5db83dced34638ad474f39f250d7fea9598bdd239eaced1bdf45d597da0f433f"
dependencies = [
"clap_builder",
"clap_derive",
@ -303,9 +303,9 @@ dependencies = [
[[package]]
name = "clap_builder"
version = "4.5.2"
version = "4.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae129e2e766ae0ec03484e609954119f123cc1fe650337e155d03b022f24f7b4"
checksum = "f7e204572485eb3fbf28f871612191521df159bc3e15a9f5064c66dba3a8c05f"
dependencies = [
"anstream",
"anstyle",
@ -315,18 +315,18 @@ dependencies = [
[[package]]
name = "clap_complete"
version = "4.5.2"
version = "4.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd79504325bf38b10165b02e89b4347300f855f273c4cb30c4a3209e6583275e"
checksum = "d2020fa13af48afc65a9a87335bda648309ab3d154cd03c7ff95b378c7ed39c4"
dependencies = [
"clap",
]
[[package]]
name = "clap_complete_fig"
version = "4.5.0"
version = "4.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54b3e65f91fabdd23cac3d57d39d5d938b4daabd070c335c006dccb866a61110"
checksum = "fb4bc503cddc1cd320736fb555d6598309ad07c2ddeaa23891a10ffb759ee612"
dependencies = [
"clap",
"clap_complete",
@ -334,9 +334,9 @@ dependencies = [
[[package]]
name = "clap_complete_nushell"
version = "4.5.1"
version = "4.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80d0e48e026ce7df2040239117d25e4e79714907420c70294a5ce4b6bbe6a7b6"
checksum = "1accf1b463dee0d3ab2be72591dccdab8bef314958340447c882c4c72acfe2a3"
dependencies = [
"clap",
"clap_complete",
@ -344,9 +344,9 @@ dependencies = [
[[package]]
name = "clap_derive"
version = "4.5.4"
version = "4.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "528131438037fd55894f62d6e9f068b8f45ac57ffa77517819645d10aed04f64"
checksum = "c780290ccf4fb26629baa7a1081e68ced113f1d3ec302fa5948f1c381ebf06c6"
dependencies = [
"heck 0.5.0",
"proc-macro2",
@ -1617,9 +1617,9 @@ dependencies = [
[[package]]
name = "regex"
version = "1.10.4"
version = "1.10.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c117dbdfde9c8308975b6a18d71f3f385c89461f7b3fb054288ecf2a2058ba4c"
checksum = "b91213439dad192326a0d7c6ee3955910425f441d7038e0d6933b0aec5c4517f"
dependencies = [
"aho-corasick",
"memchr",

View File

@ -1 +1 @@
{"version":"0.2","words":["Punct","KEYMAP","splitn","crossterm","YAZI","unar","peekable","ratatui","syntect","pbpaste","pbcopy","ffmpegthumbnailer","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","nvim","indexmap","indexmap","unwatch","canonicalize","serde","fsevent","Ueberzug","iterm","wezterm","sixel","chafa","ueberzugpp"," Überzug"," Überzug","Konsole","Alacritty","Überzug","pkgs","paru","unarchiver","pdftoppm","poppler","prebuild","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","imagesize","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"],"language":"en","flagWords":[]}
{"language":"en","version":"0.2","flagWords":[],"words":["Punct","KEYMAP","splitn","crossterm","YAZI","unar","peekable","ratatui","syntect","pbpaste","pbcopy","ffmpegthumbnailer","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","nvim","indexmap","indexmap","unwatch","canonicalize","serde","fsevent","Ueberzug","iterm","wezterm","sixel","chafa","ueberzugpp"," Überzug"," Überzug","Konsole","Alacritty","Überzug","pkgs","paru","unarchiver","pdftoppm","poppler","prebuild","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","imagesize","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"]}

View File

@ -9,18 +9,18 @@ homepage = "https://yazi-rs.github.io"
repository = "https://github.com/sxyazi/yazi"
[dependencies]
regex = "1.10.4"
regex = "1.10.5"
yazi-adapter = { path = "../yazi-adapter", version = "0.2.5" }
yazi-config = { path = "../yazi-config", version = "0.2.5" }
yazi-shared = { path = "../yazi-shared", version = "0.2.5" }
# External dependencies
clap = { version = "4.5.4", features = [ "derive" ] }
clap = { version = "4.5.7", features = [ "derive" ] }
serde = { version = "1.0.203", features = [ "derive" ] }
[build-dependencies]
clap = { version = "4.5.4", features = [ "derive" ] }
clap_complete = "4.5.2"
clap_complete_nushell = "4.5.1"
clap_complete_fig = "4.5.0"
clap = { version = "4.5.7", features = [ "derive" ] }
clap_complete = "4.5.5"
clap_complete_nushell = "4.5.2"
clap_complete_fig = "4.5.1"
vergen = { version = "8.3.1", features = [ "build", "git", "gitcl" ] }

View File

@ -14,7 +14,7 @@ yazi-shared = { path = "../yazi-shared", version = "0.2.5" }
# External dependencies
anyhow = "1.0.86"
clap = { version = "4.5.4", features = [ "derive" ] }
clap = { version = "4.5.7", features = [ "derive" ] }
crossterm = "0.27.0"
md-5 = "0.10.6"
serde_json = "1.0.117"
@ -23,10 +23,10 @@ toml_edit = "0.22.14"
[build-dependencies]
anyhow = "1.0.86"
clap = { version = "4.5.4", features = [ "derive" ] }
clap_complete = "4.5.2"
clap_complete_fig = "4.5.0"
clap_complete_nushell = "4.5.1"
clap = { version = "4.5.7", features = [ "derive" ] }
clap_complete = "4.5.5"
clap_complete_fig = "4.5.1"
clap_complete_nushell = "4.5.2"
serde_json = "1.0.117"
vergen = { version = "8.3.1", features = [ "build", "git", "gitcl" ] }

View File

@ -27,7 +27,7 @@ futures = "0.3.30"
notify = { version = "6.1.1", default-features = false, features = [ "macos_fsevent" ] }
parking_lot = "0.12.3"
ratatui = "0.26.3"
regex = "1.10.4"
regex = "1.10.5"
scopeguard = "1.2.0"
serde = "1.0.203"
shell-words = "1.1.0"

View File

@ -1,9 +1,10 @@
use std::path::PathBuf;
use std::collections::HashMap;
use anyhow::Result;
use tokio::fs;
use yazi_config::popup::InputCfg;
use yazi_proxy::{InputProxy, ManagerProxy};
use yazi_shared::{event::Cmd, fs::{maybe_exists, File, FilesOp, Url}};
use yazi_proxy::{InputProxy, TabProxy, WATCHER};
use yazi_shared::{event::Cmd, fs::{maybe_exists, ok_or_not_found, symlink_realpath, File, FilesOp, Url}};
use crate::manager::Manager;
@ -24,29 +25,42 @@ impl Manager {
let Some(Ok(name)) = result.recv().await else {
return Ok(());
};
if name.is_empty() {
return Ok(());
}
let path = cwd.join(&name);
if !opt.force && maybe_exists(&path).await {
let new = cwd.join(&name);
if !opt.force && maybe_exists(&new).await {
match InputProxy::show(InputCfg::overwrite()).recv().await {
Some(Ok(c)) if c == "y" || c == "Y" => (),
_ => return Ok(()),
}
}
if name.ends_with('/') || name.ends_with('\\') {
fs::create_dir_all(&path).await?;
} else {
fs::create_dir_all(&path.parent().unwrap()).await.ok();
fs::File::create(&path).await?;
}
let child =
Url::from(path.components().take(cwd.components().count() + 1).collect::<PathBuf>());
if let Ok(f) = File::from(child.clone()).await {
FilesOp::Creating(cwd, vec![f]).emit();
ManagerProxy::hover(Some(child));
}
Ok::<(), anyhow::Error>(())
Self::create_do(new, name.ends_with('/') || name.ends_with('\\')).await
});
}
async fn create_do(new: Url, dir: bool) -> Result<()> {
let Some(parent) = new.parent_url() else { return Ok(()) };
let _permit = WATCHER.acquire().await.unwrap();
if dir {
fs::create_dir_all(&new).await?;
} else if let Ok(real) = symlink_realpath(&new).await {
ok_or_not_found(fs::remove_file(&new).await)?;
FilesOp::Deleting(parent.clone(), vec![Url::from(real)]).emit();
fs::File::create(&new).await?;
} else {
fs::create_dir_all(&parent).await.ok();
ok_or_not_found(fs::remove_file(&new).await)?;
fs::File::create(&new).await?;
}
if let Ok(f) = File::from(new.clone()).await {
FilesOp::Upserting(parent, HashMap::from_iter([(f.url(), f)])).emit();
TabProxy::reveal(&new)
}
Ok(())
}
}

View File

@ -4,8 +4,8 @@ use anyhow::Result;
use tokio::fs;
use yazi_config::popup::InputCfg;
use yazi_dds::Pubsub;
use yazi_proxy::{InputProxy, ManagerProxy, WATCHER};
use yazi_shared::{event::Cmd, fs::{maybe_exists, File, FilesOp, Url}};
use yazi_proxy::{InputProxy, TabProxy, WATCHER};
use yazi_shared::{event::Cmd, fs::{maybe_exists, ok_or_not_found, symlink_realpath, File, FilesOp, Url}};
use crate::manager::Manager;
@ -77,19 +77,23 @@ impl Manager {
}
async fn rename_do(tab: usize, old: Url, new: Url) -> Result<()> {
let Some(p_old) = old.parent_url() else { return Ok(()) };
let Some(p_new) = new.parent_url() else { return Ok(()) };
let _permit = WATCHER.acquire().await.unwrap();
let overwritten = symlink_realpath(&new).await;
fs::rename(&old, &new).await?;
if old.parent() != new.parent() {
return Ok(());
}
let file = File::from(new.clone()).await?;
if let Ok(p) = overwritten {
ok_or_not_found(fs::rename(&p, &new).await)?;
FilesOp::Deleting(p_new.clone(), vec![Url::from(p)]).emit();
}
Pubsub::pub_from_rename(tab, &old, &new);
FilesOp::Deleting(file.parent().unwrap(), vec![new.clone()]).emit();
FilesOp::Upserting(file.parent().unwrap(), HashMap::from_iter([(old, file)])).emit();
Ok(ManagerProxy::hover(Some(new)))
let file = File::from(new.clone()).await?;
FilesOp::Deleting(p_old, vec![old]).emit();
FilesOp::Upserting(p_new, HashMap::from_iter([(new.clone(), file)])).emit();
Ok(TabProxy::reveal(&new))
}
fn empty_url_part(url: &Url, by: &str) -> String {

View File

@ -1,4 +1,4 @@
use std::{collections::{HashMap, HashSet}, time::{Duration, SystemTime}};
use std::{borrow::Cow, collections::{HashMap, HashSet}, time::{Duration, SystemTime}};
use anyhow::Result;
use notify::{RecommendedWatcher, RecursiveMode, Watcher as _Watcher};
@ -8,7 +8,7 @@ use tokio_stream::{wrappers::UnboundedReceiverStream, StreamExt};
use tracing::error;
use yazi_plugin::isolate;
use yazi_proxy::WATCHER;
use yazi_shared::{fs::{File, FilesOp, Url}, RoCell};
use yazi_shared::{fs::{symlink_realpath_with, File, FilesOp, Url}, RoCell};
use super::Linked;
use crate::folder::{Files, Folder};
@ -35,8 +35,8 @@ impl Watcher {
Default::default(),
);
tokio::spawn(Self::on_in(in_rx, watcher.unwrap()));
tokio::spawn(Self::on_out(out_rx));
tokio::spawn(Self::fan_in(in_rx, watcher.unwrap()));
tokio::spawn(Self::fan_out(out_rx));
Self { tx: in_tx }
}
@ -65,7 +65,7 @@ impl Watcher {
});
}
async fn on_in(mut rx: watch::Receiver<HashSet<Url>>, mut watcher: RecommendedWatcher) {
async fn fan_in(mut rx: watch::Receiver<HashSet<Url>>, mut watcher: RecommendedWatcher) {
loop {
let (mut to_unwatch, mut to_watch): (HashSet<_>, HashSet<_>) = {
let (new, old) = (&*rx.borrow_and_update(), &*WATCHED.read());
@ -91,27 +91,39 @@ impl Watcher {
}
}
async fn on_out(rx: UnboundedReceiver<Url>) {
async fn fan_out(rx: UnboundedReceiver<Url>) {
// TODO: revert this once a new notification is implemented
let rx = UnboundedReceiverStream::new(rx).chunks_timeout(1000, Duration::from_millis(50));
pin!(rx);
while let Some(urls) = rx.next().await {
while let Some(chunk) = rx.next().await {
let urls: HashSet<_> = chunk.into_iter().collect();
let mut cached: HashMap<_, _> = HashMap::new();
let _permit = WATCHER.acquire().await.unwrap();
let mut reload = Vec::with_capacity(urls.len());
for u in urls.into_iter().collect::<HashSet<_>>() {
let Some(parent) = u.parent_url() else { continue };
let Ok(file) = File::from(u.clone()).await else {
FilesOp::Deleting(parent, vec![u]).emit();
for url in urls {
let Some(parent) = url.parent_url() else { continue };
let Ok(file) = File::from(url.clone()).await else {
FilesOp::Deleting(parent, vec![url]).emit();
continue;
};
let real = if file.is_link() {
symlink_realpath_with(&url, &mut cached).await
} else {
fs::canonicalize(&url).await.map(Cow::Owned)
};
if !real.is_ok_and(|p| p == *url) {
FilesOp::Deleting(parent, vec![url]).emit();
continue;
}
if !file.is_dir() {
reload.push(file.clone());
}
FilesOp::Upserting(parent, HashMap::from_iter([(u, file)])).emit();
FilesOp::Upserting(parent, HashMap::from_iter([(url, file)])).emit();
}
if reload.is_empty() {

View File

@ -63,11 +63,15 @@ impl Body<'static> {
if matches!(
kind,
"hi"
| "hey" | "bye"
| "cd" | "hover"
| "hey"
| "bye"
| "cd"
| "hover"
| "rename"
| "bulk" | "yank"
| "move" | "trash"
| "bulk"
| "yank"
| "move"
| "trash"
| "delete"
) {
bail!("Cannot construct system event");

View File

@ -5,7 +5,7 @@ use futures::{future::BoxFuture, FutureExt};
use tokio::{fs, io::{self, ErrorKind::{AlreadyExists, NotFound}}, sync::mpsc};
use tracing::warn;
use yazi_config::TASKS;
use yazi_shared::fs::{calculate_size, copy_with_progress, maybe_exists, path_relative_to, Url};
use yazi_shared::fs::{calculate_size, copy_with_progress, maybe_exists, ok_or_not_found, path_relative_to, Url};
use super::{FileOp, FileOpDelete, FileOpLink, FileOpPaste, FileOpTrash};
use crate::{TaskOp, TaskProg, LOW, NORMAL};
@ -26,12 +26,9 @@ impl File {
pub async fn work(&self, op: FileOp) -> Result<()> {
match op {
FileOp::Paste(mut task) => {
match fs::remove_file(&task.to).await {
Err(e) if e.kind() != NotFound => Err(e)?,
_ => {}
}
ok_or_not_found(fs::remove_file(&task.to).await)?;
let mut it = copy_with_progress(&task.from, &task.to, task.meta.as_ref().unwrap());
while let Some(res) = it.recv().await {
match res {
Ok(0) => {
@ -83,21 +80,17 @@ impl File {
src
};
match fs::remove_file(&task.to).await {
Err(e) if e.kind() != NotFound => Err(e)?,
_ => {
#[cfg(unix)]
{
fs::symlink(src, &task.to).await?
}
#[cfg(windows)]
{
if meta.is_dir() {
fs::symlink_dir(src, &task.to).await?
} else {
fs::symlink_file(src, &task.to).await?
}
}
ok_or_not_found(fs::remove_file(&task.to).await)?;
#[cfg(unix)]
{
fs::symlink(src, &task.to).await?
}
#[cfg(windows)]
{
if meta.is_dir() {
fs::symlink_dir(src, &task.to).await?
} else {
fs::symlink_file(src, &task.to).await?
}
}
@ -134,12 +127,8 @@ impl File {
}
pub async fn paste(&self, mut task: FileOpPaste) -> Result<()> {
if task.cut {
match fs::rename(&task.from, &task.to).await {
Ok(_) => return self.succ(task.id),
Err(e) if e.kind() == NotFound => return self.succ(task.id),
_ => {}
}
if task.cut && ok_or_not_found(fs::rename(&task.from, &task.to).await).is_ok() {
return self.succ(task.id);
}
if task.meta.is_none() {

View File

@ -19,7 +19,7 @@ futures = "0.3.30"
parking_lot = "0.12.3"
percent-encoding = "2.3.1"
ratatui = "0.26.3"
regex = "1.10.4"
regex = "1.10.5"
serde = { version = "1.0.203", features = [ "derive" ] }
shell-words = "1.1.0"
tokio = { version = "1.38.0", features = [ "full" ] }

View File

@ -1,11 +1,13 @@
use std::{collections::VecDeque, fs::Metadata, path::{Path, PathBuf}};
use std::{borrow::Cow, collections::{HashMap, VecDeque}, fs::Metadata, path::{Path, PathBuf}};
use anyhow::Result;
use filetime::{set_file_mtime, FileTime};
use tokio::{fs, io, select, sync::{mpsc, oneshot}, time};
#[inline]
pub async fn must_exists(p: impl AsRef<Path>) -> bool { fs::symlink_metadata(p).await.is_ok() }
#[inline]
pub async fn maybe_exists(p: impl AsRef<Path>) -> bool {
match fs::symlink_metadata(p).await {
Ok(_) => true,
@ -13,6 +15,61 @@ pub async fn maybe_exists(p: impl AsRef<Path>) -> bool {
}
}
#[inline]
pub fn ok_or_not_found(result: io::Result<()>) -> io::Result<()> {
match result {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(_) => result,
}
}
#[inline]
pub async fn symlink_realpath(path: &Path) -> io::Result<PathBuf> {
if fs::symlink_metadata(path).await?.is_symlink() {
symlink_realpath_with(path, &mut HashMap::new()).await.map(|p| p.into_owned())
} else {
fs::canonicalize(path).await
}
}
// realpath(3) without resolving symlinks. This is useful for case-insensitive
// filesystems.
//
// Make sure the file of the path exists and is a symlink.
pub async fn symlink_realpath_with<'a>(
path: &'a Path,
cached: &'a mut HashMap<PathBuf, PathBuf>,
) -> io::Result<Cow<'a, Path>> {
let lowercased: PathBuf = path.as_os_str().to_ascii_lowercase().into();
if lowercased == path {
return Ok(Cow::Borrowed(path));
}
let Some(parent) = path.parent() else {
return Ok(Cow::Borrowed(path));
};
let case = parent.as_os_str().as_encoded_bytes().iter().any(|&b| b.is_ascii_uppercase());
if !cached.contains_key(parent) {
let mut it = fs::read_dir(parent).await?;
while let Some(entry) = it.next_entry().await? {
let p = entry.path();
if case || p.file_name().unwrap().as_encoded_bytes().iter().any(|&b| b.is_ascii_uppercase()) {
cached.insert(p.as_os_str().to_ascii_lowercase().into(), p);
}
}
cached.insert(parent.to_owned(), PathBuf::new());
}
Ok(
cached
.get(&lowercased)
.filter(|p| !p.as_os_str().is_empty())
.map_or_else(|| Cow::Borrowed(path), |p| Cow::Borrowed(p)),
)
}
pub async fn calculate_size(path: &Path) -> u64 {
let mut total = 0;
let mut stack = VecDeque::from([path.to_path_buf()]);