diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..67af2fac --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,45 @@ +# AGENTS.md + +## Rules + +- Applies repo-wide. Keep changes scoped; do not create issues or pull requests, or post comments. + +## Project + +- Rust 2024 Cargo workspace containing all `yazi-*` crates; default members are `yazi-fm` (`yazi`) and `yazi-cli` (`ya`). + +## Style + +- Follow nearby code and use idiomatic Rust and Lua. Rust uses `snake_case` for modules, functions, and fields and `PascalCase` for types, traits, and variants. Lua uses PascalCase component tables, `local M` plugin modules, `snake_case` methods/locals, and `_name` private fields. +- Preserve established terms and type families: `Url`/`UrlBuf`/`UrlCow`, `PathDyn`/`PathBufDyn`/`PathCow`, `*Ref`, `*Arc`, `*Opt`, `*State`, `*Job`, `*Prog`, `File`, `Folder`, `Tab`, `Mgr`, and `Task`. Use `Url` for logical locations and `Path` for filesystem paths. +- Reuse established plugin and event names (`fetch`, `preload`, `peek`, `seek`, `spot`, `entry`, `setup`, `yank`, `hover`, and `select`) across Rust, Lua, and configuration. +- Use Rust prefixes (`as_`, `to_`, `into_`, `try_`, `is_`, `has_`) according to their usual semantics; prefer descriptive names. +- Name variables, modules, methods, and other symbols simply, elegantly, and expressively. Be creative while keeping names clear, consistent with established terminology, and idiomatic. +- When passing arguments, use the parameter's conversion traits directly (such as `Into<_>` or `AsRef<_>`); avoid eager conversions like `.to_string()`, `.to_owned()`, and `.as_ref()` unless ownership, type inference, or semantics require them. +- Prefer general-purpose traits and conversion APIs already provided by the codebase or its dependencies over manual construction or adapter closures; for example, use `into_lua()` where applicable. + +## Code Changes + +- Search and reuse first. For new features, extend existing infrastructure or data structures with general, reusable capabilities when that keeps the final code concise. +- For refactors, inspect the whole target module and its callers first. Look for duplicated work, redundant I/O, underpowered return values, one-use wrappers, and reusable cross-platform abstractions; implement high-confidence, behavior-preserving simplifications while preserving error, fallback, and platform semantics. +- Keep diffs minimal and avoid unrelated refactors. Prefer clear code over custom patterns or comments; comment only behavior the code cannot explain. +- Put reusable code in the lowest suitable shared layer; avoid unnecessary dependencies and allocations. Prefer borrowed values and existing wrappers. +- Use stable Rust APIs; nightly is formatting-only. Use only `pub`, `pub(super)`, and `pub(crate)`—never `pub(in ...)`. +- Keep async I/O non-blocking, preserve platform/fork behavior, and follow existing error boundaries with `?`. +- For renames or refactors, update all related variables, functions, parameters, modules, methods, types, derived types, exports, tests, configuration keys, documentation, and Lua bindings. +- Do not add or modify tests unless requested. + +## Validation + +- Prefer targeted debug checks; use multiple `-p` flags for affected crates before the whole workspace. + +```sh +cargo check -p +cargo test -p +cargo clippy -p +find . -name '*.rs' -not -path './target/*' -exec rustfmt +nightly --check {} + +stylua --color always --check . +``` + +- Use `cargo check` instead of `cargo build` unless artifacts are needed. Do not use `--release` unless requested; use `scripts/build.sh ` for release or cross-target packaging. +- Run relevant existing tests when needed, then inspect `git diff` and verify that only intended files changed. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 8b243762..f4e8904f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -145,7 +145,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -156,7 +156,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -319,6 +319,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b25655df2c3cdd83c5e5b293b88acd880332b2ddadd7c30ac43144fdc0033da9" + [[package]] name = "base64ct" version = "1.8.3" @@ -573,9 +579,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.3.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" dependencies = [ "find-msvc-tools", "jobserver", @@ -636,9 +642,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.2" +version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd059f9da4f5c36b3787f65d38ccaab1cc315f07b01f89abc8359ee6a8205011" +checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" dependencies = [ "clap_builder", "clap_derive", @@ -687,14 +693,14 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.1" +version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -921,15 +927,16 @@ dependencies = [ [[package]] name = "curve25519-dalek" -version = "5.0.0-rc.1" +version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c906a87e53a36ff795d72e06e8162a83c5436e3ea89e942a9cb9fc083f0a384f" +checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" dependencies = [ "cfg-if", "cpufeatures", "curve25519-dalek-derive", "digest", "fiat-crypto", + "rand_core 0.10.1", "rustc_version", "subtle", "zeroize", @@ -1076,7 +1083,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1122,9 +1129,9 @@ dependencies = [ [[package]] name = "ed25519-dalek" -version = "3.0.0-rc.1" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1685663e23882cd8517dcbcb1c23a6ebff4433c22dfb681d760219b62cd1b849" +checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" dependencies = [ "curve25519-dalek", "ed25519", @@ -1138,9 +1145,9 @@ dependencies = [ [[package]] name = "either" -version = "1.16.0" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" [[package]] name = "elliptic-curve" @@ -1226,7 +1233,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1660,7 +1667,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.56.0", ] [[package]] @@ -1690,7 +1697,7 @@ dependencies = [ "exr", "gif", "image-webp", - "moxcms", + "moxcms 0.8.1", "num-traits", "png", "qoi", @@ -1931,9 +1938,9 @@ checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libfuzzer-sys" @@ -2099,7 +2106,7 @@ dependencies = [ "module-lattice", "pkcs8", "rand_core 0.10.1", - "sha3", + "sha3 0.11.0", ] [[package]] @@ -2170,6 +2177,16 @@ dependencies = [ "pxfm", ] +[[package]] +name = "moxcms" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aee701c0ffba6cc991c0fefda5ff5fca1bd2f2f4a928ffba9f825b9afceedbc3" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -2245,7 +2262,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2436,9 +2453,9 @@ checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" [[package]] name = "p256" -version = "0.14.0-rc.15" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6bb40a5099e2c38a09dd29321a7a7f045f165a54317679c7cdfb0cbaf8f6b1e" +checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a" dependencies = [ "ecdsa", "elliptic-curve", @@ -2449,9 +2466,9 @@ dependencies = [ [[package]] name = "p384" -version = "0.14.0-rc.15" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "492f329d7eb11d22dadc988626b9ea1f503b4ab043a8b1e4e2cc4ae45dabdd70" +checksum = "d17b851e6b3e378ab4ecb07fa2ed23f4d15f075735f8fec9fa1e7bdce5f8301f" dependencies = [ "ecdsa", "elliptic-curve", @@ -2463,9 +2480,9 @@ dependencies = [ [[package]] name = "p521" -version = "0.14.0-rc.15" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff42e4ace5424e3b6d7cb82514be89866b85af87015f80341e37dcc21a66ce6e" +checksum = "4ad64cc32c2dc466317c12ee5853e61f159f9eab1fe7efade0395dc2e7b43449" dependencies = [ "base16ct", "ecdsa", @@ -2662,7 +2679,7 @@ version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" dependencies = [ - "base64", + "base64 0.22.1", "indexmap 2.14.0", "quick-xml", "serde", @@ -3114,7 +3131,7 @@ checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" dependencies = [ "proc-macro2", "quote", - "syn 3.0.2", + "syn 3.0.3", ] [[package]] @@ -3197,9 +3214,9 @@ dependencies = [ [[package]] name = "russh" -version = "0.62.2" +version = "0.62.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca6c3c1dd0a9ad3a9915a2f6e907d158f9a7e9ac32ddc772b003faafc027d9a8" +checksum = "b8b67b5a0d8068c89dcbe9d95df986af7a851d1f3c604525274c37468e60464f" dependencies = [ "aes", "bitflags", @@ -3253,7 +3270,7 @@ dependencies = [ "sec1", "sha1", "sha2", - "sha3", + "sha3 0.12.0", "signature", "spki", "ssh-encoding", @@ -3321,7 +3338,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3463,7 +3480,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.2", + "syn 3.0.3", ] [[package]] @@ -3494,7 +3511,7 @@ version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" dependencies = [ - "base64", + "base64 0.22.1", "bs58", "chrono", "hex", @@ -3562,6 +3579,17 @@ dependencies = [ "keccak", ] +[[package]] +name = "sha3" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759" +dependencies = [ + "digest", + "keccak", + "sponge-cursor", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -3659,7 +3687,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -3672,6 +3700,12 @@ dependencies = [ "der", ] +[[package]] +name = "sponge-cursor" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" + [[package]] name = "ssh-cipher" version = "0.3.0" @@ -3795,9 +3829,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.2" +version = "3.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a207d6d6a2b7fc470b80443726053f18a2481b7e1eee970597051596567987a3" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" dependencies = [ "proc-macro2", "quote", @@ -3837,10 +3871,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3880,7 +3914,7 @@ checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" dependencies = [ "proc-macro2", "quote", - "syn 3.0.2", + "syn 3.0.3", ] [[package]] @@ -3975,9 +4009,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.53.0" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -4003,9 +4037,9 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" dependencies = [ "futures-core", "pin-project-lite", @@ -4014,9 +4048,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", @@ -4162,7 +4196,7 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8464ec13c3691491391d9fce00f6416c9a48e46972f72d7865688be2080192c9" dependencies = [ - "rand 0.10.2", + "rand 0.9.5", ] [[package]] @@ -4191,7 +4225,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4434,7 +4468,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4850,10 +4884,10 @@ version = "26.5.6" dependencies = [ "ansi-to-tui", "anyhow", - "base64", + "base64 0.23.0", "image", "inventory", - "moxcms", + "moxcms 0.9.0", "palette", "quantette", "ratatui-core", @@ -4956,7 +4990,7 @@ version = "26.5.6" dependencies = [ "proc-macro2", "quote", - "syn 3.0.2", + "syn 3.0.3", ] [[package]] @@ -5134,6 +5168,7 @@ dependencies = [ "anyhow", "arc-swap", "bitflags", + "cfg_aliases", "core-foundation-sys", "data-encoding", "dirs", @@ -5146,6 +5181,7 @@ dependencies = [ "mlua", "objc2", "parking_lot", + "percent-encoding", "rand 0.10.2", "regex", "scopeguard", @@ -5368,7 +5404,6 @@ version = "26.5.6" dependencies = [ "anyhow", "arc-swap", - "base64", "hashbrown 0.17.1", "mlua", "percent-encoding", @@ -5389,7 +5424,7 @@ name = "yazi-term" version = "26.5.6" dependencies = [ "anyhow", - "base64", + "base64 0.23.0", "bitflags", "compact_str 0.10.0", "futures", @@ -5411,7 +5446,7 @@ dependencies = [ name = "yazi-tty" version = "26.5.9" dependencies = [ - "base64", + "base64 0.23.0", "bitflags", "libc", "mlua", diff --git a/Cargo.toml b/Cargo.toml index af8f8145..e5b57182 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,16 +38,16 @@ debug = false ansi-to-tui = "8.0.1" anyhow = "1.0.104" arc-swap = { version = "1.9.2", features = [ "serde" ] } -base64 = "0.22.1" +base64 = "0.23.0" bitflags = { version = "2.13.1", features = [ "serde" ] } chrono = "0.4.45" -clap = { version = "4.6.2", features = [ "derive" ] } +clap = { version = "4.6.4", features = [ "derive" ] } compact_str = { version = "0.10.0", features = [ "serde" ] } core-foundation-sys = "0.8.7" data-encoding = "2.11.0" dirs = "6.0.0" dyn-clone = "1.0.20" -either = { version = "1.16.0" } +either = { version = "1.17.0" } foldhash = "0.2.0" futures = "0.3.33" globset = "0.4.19" @@ -55,7 +55,7 @@ hashbrown = { version = "0.17.1", features = [ "serde" ] } image = { version = "0.25.10", default-features = false, features = [ "avif", "bmp", "dds", "exr", "ff", "gif", "hdr", "ico", "jpeg", "png", "pnm", "qoi", "tga", "tiff", "webp" ] } indexmap = { version = "2.14.0", features = [ "serde" ] } inventory = "0.3.24" -libc = "0.2.186" +libc = "0.2.189" lru = "0.18.1" mlua = { version = "0.12.0", features = [ "anyhow", "async", "error-send", "lua55", "macros", "serde" ] } objc2 = "0.6.4" @@ -67,7 +67,7 @@ rand = { version = "0.10.2", default-features = false, features = ratatui-core = { version = "0.1.2", default-features = false, features = [ "std", "layout-cache", "serde", "underline-color" ] } ratatui-widgets = { version = "0.3.2", default-features = false, features = [ "std", "unstable-rendered-line-info" ] } regex = "1.13.1" -russh = { version = "0.62.2", default-features = false, features = [ "ring", "rsa" ] } +russh = { version = "0.62.4", default-features = false, features = [ "ring", "rsa" ] } scopeguard = "1.2.0" serde = { version = "1.0.229", features = [ "derive" ] } serde_json = "1.0.151" @@ -75,9 +75,9 @@ serde_with = "3.21.0" strum = { version = "0.28.0", features = [ "derive" ] } syntect = { version = "5.3.0", default-features = false, features = [ "parsing", "plist-load", "regex-onig" ] } thiserror = "2.0.19" -tokio = { version = "1.53.0", features = [ "full" ] } -tokio-stream = "0.1.18" -tokio-util = "0.7.18" +tokio = { version = "1.53.1", features = [ "full" ] } +tokio-stream = "0.1.19" +tokio-util = "0.7.19" toml = { version = "1.1.3" } tracing = { version = "0.1.44", features = [ "max_level_debug", "release_max_level_debug" ] } twox-hash = { version = "2.1.3", default-features = false, features = [ "std", "random", "xxhash3_128" ] } diff --git a/cspell.json b/cspell.json index 0685a4f8..4dce9d49 100644 --- a/cspell.json +++ b/cspell.json @@ -1 +1 @@ -{"flagWords":[],"version":"0.2","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","inodes","Splatable","casefied","thiserror","memchr","memmem","russh","deadpool","keepalive","nodelay","publickey","deadpool","initing","treelize","TOCTOU","fellback","watchee","Textlike","sstr","pointee","writef","wakeup","nonblocking","sigwinch","timespec","termios","tcgetattr","tcsetattr","tcgetwinsize","rustix","codepoint","codepoints","Raterm","mimetypes","Mimelist","renderables","redrawer","Padable"]} \ No newline at end of file +{"language":"en","version":"0.2","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","Splatable","casefied","thiserror","memchr","memmem","russh","deadpool","keepalive","nodelay","publickey","deadpool","initing","treelize","TOCTOU","fellback","watchee","Textlike","sstr","pointee","writef","wakeup","nonblocking","sigwinch","timespec","termios","tcgetattr","tcsetattr","tcgetwinsize","rustix","codepoint","codepoints","Raterm","mimetypes","Mimelist","renderables","redrawer","Padable","ents"]} \ No newline at end of file diff --git a/yazi-adapter/Cargo.toml b/yazi-adapter/Cargo.toml index 886c0128..96d1f699 100644 --- a/yazi-adapter/Cargo.toml +++ b/yazi-adapter/Cargo.toml @@ -28,7 +28,7 @@ anyhow = { workspace = true } base64 = { workspace = true } image = { workspace = true } inventory = { workspace = true } -moxcms = "0.8.1" +moxcms = "0.9.0" palette = { version = "0.7.6", default-features = false } quantette = { version = "0.6.0", default-features = false } ratatui-core = { workspace = true } diff --git a/yazi-codegen/Cargo.toml b/yazi-codegen/Cargo.toml index 830012a2..1e65bbab 100644 --- a/yazi-codegen/Cargo.toml +++ b/yazi-codegen/Cargo.toml @@ -19,4 +19,4 @@ proc-macro = true # External dependencies proc-macro2 = "1" quote = "1.0.47" -syn = { version = "3.0.2", features = [ "full" ] } +syn = { version = "3.0.3", features = [ "full" ] } diff --git a/yazi-fs/Cargo.toml b/yazi-fs/Cargo.toml index b6d29768..2213c242 100644 --- a/yazi-fs/Cargo.toml +++ b/yazi-fs/Cargo.toml @@ -12,6 +12,9 @@ rust-version.workspace = true [lints] workspace = true +[build-dependencies] +cfg_aliases = "0.2.2" + [dependencies] yazi-binding = { path = "../yazi-binding", version = "26.5.6" } yazi-ffi = { path = "../yazi-ffi", version = "26.5.6" } @@ -45,8 +48,11 @@ typed-path = { workspace = true } [target."cfg(unix)".dependencies] uzers = { workspace = true } +[target.'cfg(all(unix, not(target_os = "macos"), not(target_os = "android"), not(target_os = "ios")))'.dependencies] +percent-encoding = { workspace = true } + [target.'cfg(windows)'.dependencies] -windows = { version = "0.62.2", features = [ "Win32_Foundation", "Win32_Storage_EnhancedStorage", "Win32_System_Com_StructuredStorage", "Win32_System_SystemServices", "Win32_UI_Shell_PropertiesSystem" ] } +windows = { version = "0.62.2", features = [ "Win32_Foundation", "Win32_Storage_EnhancedStorage", "Win32_System_Com_StructuredStorage", "Win32_System_SystemServices", "Win32_System_Variant", "Win32_UI_Shell_PropertiesSystem" ] } windows-sys = { version = "0.61.2", features = [ "Win32_Storage_FileSystem" ] } [target.'cfg(target_os = "macos")'.dependencies] diff --git a/yazi-fs/build.rs b/yazi-fs/build.rs new file mode 100644 index 00000000..2de9b63d --- /dev/null +++ b/yazi-fs/build.rs @@ -0,0 +1,13 @@ +fn main() { + cfg_aliases::cfg_aliases! { + trash_unsupported: { + any(target_os = "android", target_os = "ios") + }, + trash_unix: { + all(unix, not(trash_unsupported)) + }, + trash_freedesktop: { + all(unix, not(target_os = "macos"), not(trash_unsupported)) + }, + } +} diff --git a/yazi-fs/src/cha/cha.rs b/yazi-fs/src/cha/cha.rs index e5d0d897..2e1356a7 100644 --- a/yazi-fs/src/cha/cha.rs +++ b/yazi-fs/src/cha/cha.rs @@ -54,7 +54,7 @@ impl Cha { where T: AsStrand, { - Self::from_bare(&meta).attach(ChaKind::hidden(name, &meta)) + Self::from_bare(&meta).attach(ChaKind::hidden(name, &meta) | ChaKind::reparse(&meta)) } pub fn from_dummy(_url: U, r#type: Option) -> Self @@ -138,8 +138,8 @@ impl Cha { return self; } - let retain = self.kind & (ChaKind::HIDDEN | ChaKind::SYSTEM) | ChaKind::FOLLOW; - followed.unwrap_or(self).attach(retain) + let retain = self.kind & (ChaKind::HIDDEN | ChaKind::SYSTEM | ChaKind::REPARSE); + followed.unwrap_or(self).attach(retain | ChaKind::FOLLOW) } } @@ -165,6 +165,12 @@ impl Cha { #[inline] pub const fn is_dummy(self) -> bool { self.kind.contains(ChaKind::DUMMY) } + #[inline] + pub const fn is_reparse(self) -> bool { self.kind.contains(ChaKind::REPARSE) } + + #[inline] + pub fn is_indirect(self) -> bool { self.is_link() || self.is_reparse() } + pub fn atime_dur(self) -> anyhow::Result { if let Some(atime) = self.atime { Ok(atime.duration_since(UNIX_EPOCH)?) diff --git a/yazi-fs/src/cha/kind.rs b/yazi-fs/src/cha/kind.rs index 2bb4833c..0e0e1fa6 100644 --- a/yazi-fs/src/cha/kind.rs +++ b/yazi-fs/src/cha/kind.rs @@ -7,10 +7,11 @@ use yazi_shared::strand::AsStrand; bitflags! { #[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq, Serialize)] pub struct ChaKind: u8 { - const FOLLOW = 0b0000_0001; - const HIDDEN = 0b0000_0010; - const SYSTEM = 0b0000_0100; - const DUMMY = 0b0000_1000; + const FOLLOW = 0b0000_0001; + const HIDDEN = 0b0000_0010; + const SYSTEM = 0b0000_0100; + const DUMMY = 0b0000_1000; + const REPARSE = 0b0001_0000; } } @@ -43,4 +44,20 @@ impl ChaKind { me } + + #[inline] + pub(super) fn reparse(_meta: &Metadata) -> Self { + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + + use windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_REPARSE_POINT; + + if _meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Self::REPARSE; + } + } + + Self::empty() + } } diff --git a/yazi-fs/src/cha/lua.rs b/yazi-fs/src/cha/lua.rs index 6a1be252..991c37d4 100644 --- a/yazi-fs/src/cha/lua.rs +++ b/yazi-fs/src/cha/lua.rs @@ -48,6 +48,7 @@ impl UserData for Cha { fields.add_field_method_get("is_link", |_, me| Ok(me.is_link())); fields.add_field_method_get("is_orphan", |_, me| Ok(me.is_orphan())); fields.add_field_method_get("is_dummy", |_, me| Ok(me.is_dummy())); + fields.add_field_method_get("is_indirect", |_, me| Ok(me.is_indirect())); fields.add_field_method_get("is_block", |_, me| Ok(me.is_block())); fields.add_field_method_get("is_char", |_, me| Ok(me.is_char())); fields.add_field_method_get("is_fifo", |_, me| Ok(me.is_fifo())); diff --git a/yazi-fs/src/engine/local/calculator.rs b/yazi-fs/src/engine/local/calculator.rs index 5ad9afbb..3f51120a 100644 --- a/yazi-fs/src/engine/local/calculator.rs +++ b/yazi-fs/src/engine/local/calculator.rs @@ -17,7 +17,7 @@ impl SizeCalculator { let p = path.to_owned(); tokio::task::spawn_blocking(move || { let cha = Cha::new(p.file_name().unwrap_or_default(), std::fs::symlink_metadata(&p)?); - if !cha.is_dir() { + if !cha.is_dir() || cha.is_indirect() { return Ok(Self::Idle((VecDeque::new(), Some(cha.len)), cha)); } @@ -101,11 +101,27 @@ impl SizeCalculator { let Ok(ent) = next else { continue }; let Ok(ft) = ent.file_type() else { continue }; - if ft.is_dir() { - buf.push_back(Either::Left(ent.path())); - } else if let Ok(meta) = ent.metadata() { - size += meta.len(); + + // If the entry is not a directory + if !ft.is_dir() { + size += ent.metadata().map_or(0, |meta| meta.len()); + continue; } + + // The entry is a directory, but it may be a reparse point + #[cfg(windows)] + { + let Ok(cha) = ent.metadata().map(|meta| Cha::new(ent.file_name(), meta)) else { + continue; + }; + if !cha.is_dir() || cha.is_indirect() { + size += cha.len; + continue; + } + } + + // Now, we can safely assume the entry is a regular directory + buf.push_back(Either::Left(ent.path())); } Some(size) } diff --git a/yazi-fs/src/engine/local/clear.rs b/yazi-fs/src/engine/local/clear.rs new file mode 100644 index 00000000..ddea57c3 --- /dev/null +++ b/yazi-fs/src/engine/local/clear.rs @@ -0,0 +1,126 @@ +use std::{io, path::Path}; + +#[cfg(unix)] +pub(super) fn remove_dir_clean_impl(path: &Path) -> io::Result<()> { + use std::{fs::OpenOptions, os::unix::fs::OpenOptionsExt}; + + use libc::{ELOOP, ENOENT, ENOTDIR, O_DIRECTORY, O_NOFOLLOW}; + + let dir = match OpenOptions::new().read(true).custom_flags(O_DIRECTORY | O_NOFOLLOW).open(path) { + Ok(dir) => dir, + Err(e) if e.raw_os_error().is_some_and(|e| matches!(e, ENOENT | ENOTDIR | ELOOP)) => { + return Ok(()); + } + Err(e) => return Err(e), + }; + + clear_no_follow(dir); + std::fs::remove_dir(path) +} + +#[cfg(unix)] +fn clear_no_follow(dir: std::fs::File) { + use std::{ffi::CStr, os::fd::{AsRawFd, FromRawFd, IntoRawFd}}; + + use libc::{AT_REMOVEDIR, DT_DIR, DT_UNKNOWN, O_CLOEXEC, O_DIRECTORY, O_NOFOLLOW, O_RDONLY}; + + struct Dropper(*mut libc::DIR); + + impl Drop for Dropper { + fn drop(&mut self) { _ = unsafe { libc::closedir(self.0) }; } + } + + let stream = unsafe { libc::fdopendir(dir.as_raw_fd()) }; + if stream.is_null() { + return; + } + + let _ = dir.into_raw_fd(); + let stream = Dropper(stream); + let fd = unsafe { libc::dirfd(stream.0) }; + + loop { + let entry = unsafe { libc::readdir(stream.0) }; + if entry.is_null() { + break; + } + + let name = unsafe { CStr::from_ptr((*entry).d_name.as_ptr()) }; + if matches!(name.to_bytes(), b"." | b"..") { + continue; + } + + let ty = unsafe { (*entry).d_type }; + if ty != DT_DIR && ty != DT_UNKNOWN { + continue; + } + + let child = + unsafe { libc::openat(fd, name.as_ptr(), O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC) }; + if child < 0 { + continue; + } + + clear_no_follow(unsafe { std::fs::File::from_raw_fd(child) }); + _ = unsafe { libc::unlinkat(fd, name.as_ptr(), AT_REMOVEDIR) }; + } +} + +// --- Windows +#[cfg(windows)] +pub(super) fn remove_dir_clean_impl(path: &Path) -> io::Result<()> { + match clear_no_reparse(path) { + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()), + result => result, + } +} + +#[cfg(windows)] +fn clear_no_reparse(path: &Path) -> io::Result<()> { + use std::{fs::OpenOptions, os::windows::fs::{MetadataExt, OpenOptionsExt}}; + + use windows_sys::Win32::Storage::FileSystem::{DELETE, FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_REPARSE_POINT, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_READ_ATTRIBUTES, FILE_SHARE_READ, FILE_SHARE_WRITE}; + + let dir = OpenOptions::new() + .access_mode(DELETE | FILE_READ_ATTRIBUTES) + .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT) + .open(path)?; + + let attrs = dir.metadata()?.file_attributes(); + if attrs & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT) != FILE_ATTRIBUTE_DIRECTORY { + return Ok(()); + } + + if let Ok(entries) = std::fs::read_dir(path) { + for entry in entries.flatten() { + if entry.file_type().is_ok_and(|t| t.is_dir()) { + clear_no_reparse(&entry.path()).ok(); + } + } + } + + remove_dir(&dir) +} + +#[cfg(windows)] +fn remove_dir(dir: &std::fs::File) -> io::Result<()> { + use std::{mem, os::windows::io::AsRawHandle}; + + use windows_sys::Win32::{Foundation::HANDLE, Storage::FileSystem::{FILE_DISPOSITION_INFO, FileDispositionInfo, SetFileInformationByHandle}}; + + let info = FILE_DISPOSITION_INFO { DeleteFile: true }; + if unsafe { + SetFileInformationByHandle( + dir.as_raw_handle() as HANDLE, + FileDispositionInfo, + &info as *const FILE_DISPOSITION_INFO as _, + mem::size_of::() as u32, + ) + } == 0 + { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } +} diff --git a/yazi-fs/src/engine/local/local.rs b/yazi-fs/src/engine/local/local.rs index 7f1dcc63..e90ce21a 100644 --- a/yazi-fs/src/engine/local/local.rs +++ b/yazi-fs/src/engine/local/local.rs @@ -119,6 +119,12 @@ impl<'a> Engine for Local<'a> { #[inline] async fn remove_dir_all(&self) -> io::Result<()> { tokio::fs::remove_dir_all(self.path).await } + #[inline] + async fn remove_dir_clean(&self) -> io::Result<()> { + let path = self.path.to_owned(); + tokio::task::spawn_blocking(move || super::remove_dir_clean_impl(&path)).await? + } + #[inline] async fn remove_file(&self) -> io::Result<()> { tokio::fs::remove_file(self.path).await } diff --git a/yazi-fs/src/engine/local/mod.rs b/yazi-fs/src/engine/local/mod.rs index d837d4c3..95a973ef 100644 --- a/yazi-fs/src/engine/local/mod.rs +++ b/yazi-fs/src/engine/local/mod.rs @@ -1 +1 @@ -yazi_macro::mod_flat!(absolute calculator casefold copier dir_entry demand identical local read_dir); +yazi_macro::mod_flat!(absolute calculator casefold clear copier dir_entry demand identical local read_dir); diff --git a/yazi-fs/src/engine/traits.rs b/yazi-fs/src/engine/traits.rs index 4ee100fe..b6b6bd4f 100644 --- a/yazi-fs/src/engine/traits.rs +++ b/yazi-fs/src/engine/traits.rs @@ -128,9 +128,11 @@ pub trait Engine: Sized { { let mut it = ok_or_not_found!(P::new(url).await?.read_dir().await, return Ok(())); while let Some(child) = it.next().await? { - let ft = ok_or_not_found!(child.file_type().await, continue); - let result = if ft.is_dir() { + let cha = ok_or_not_found!(child.metadata().await, continue); + let result = if cha.is_dir() && !cha.is_indirect() { Box::pin(remove_dir_all_impl::

(child.url().as_url())).await + } else if cha.is_dir() { + P::new(child.url().as_url()).await?.remove_dir().await } else { P::new(child.url().as_url()).await?.remove_file().await }; @@ -143,10 +145,12 @@ pub trait Engine: Sized { async move { let cha = ok_or_not_found!(self.symlink_metadata().await, return Ok(())); - if cha.is_link() { - self.remove_file().await - } else { + if !cha.is_indirect() { remove_dir_all_impl::(self.url()).await + } else if cha.is_dir() { + self.remove_dir().await + } else { + self.remove_file().await } } } @@ -159,18 +163,23 @@ pub trait Engine: Sized { let mut result = Ok(()); while let Some((dir, visited)) = stack.pop() { - let Ok(engine) = Self::new(dir.as_url()).await else { - continue; - }; + let Ok(engine) = Self::new(dir.as_url()).await else { continue }; if visited { result = engine.remove_dir().await; - } else if let Ok(mut it) = engine.read_dir().await { - stack.push((dir, true)); - while let Ok(Some(ent)) = it.next().await { - if ent.file_type().await.is_ok_and(|t| t.is_dir()) { - stack.push((ent.url(), false)); - } + continue; + } + + if !engine.symlink_metadata().await.is_ok_and(|c| c.is_dir() && !c.is_indirect()) { + continue; + } + + let Ok(mut it) = engine.read_dir().await else { continue }; + stack.push((dir, true)); + + while let Ok(Some(ent)) = it.next().await { + if ent.metadata().await.is_ok_and(|c| c.is_dir() && !c.is_indirect()) { + stack.push((ent.url(), false)); } } } diff --git a/yazi-fs/src/trash/common.rs b/yazi-fs/src/trash/common.rs new file mode 100644 index 00000000..c5e332c4 --- /dev/null +++ b/yazi-fs/src/trash/common.rs @@ -0,0 +1,18 @@ +#[cfg(trash_unix)] +pub(super) fn restore_item(from: &std::path::Path, to: &std::path::Path) -> std::io::Result<()> { + use std::{fs, io}; + + let is_dir = fs::symlink_metadata(from)?.is_dir(); + if let Some(parent) = to.parent() { + fs::create_dir_all(parent)?; + } + + match if is_dir { fs::create_dir(to) } else { fs::File::create_new(to).map(|_| ()) } { + Ok(()) => fs::rename(from, to), + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => Err(io::Error::new( + io::ErrorKind::AlreadyExists, + format!("restore target already exists: {to:?}"), + )), + Err(e) => Err(e), + } +} diff --git a/yazi-fs/src/trash/entries.rs b/yazi-fs/src/trash/entries.rs new file mode 100644 index 00000000..a945b96d --- /dev/null +++ b/yazi-fs/src/trash/entries.rs @@ -0,0 +1,38 @@ +use std::vec; + +use mlua::{FromLua, Lua, Value}; + +use super::{TrashEntry, TrashId}; + +pub(crate) struct TrashEntries(Vec); + +impl TrashEntries { + fn new(mut entries: Vec) -> Self { + entries.sort_unstable_by_key(|entry| entry.rel().components().count()); + + let mut seen = Vec::::with_capacity(entries.len()); + entries.retain(|entry| { + if seen.iter().any(|id| id.top() == entry.top() && entry.rel().starts_with(id.rel())) { + false + } else { + seen.push(entry.id.clone()); + true + } + }); + Self(entries) + } +} + +impl FromLua for TrashEntries { + fn from_lua(value: Value, lua: &Lua) -> mlua::Result { + let entries = Vec::::from_lua(value, lua)?; + Ok(Self::new(entries)) + } +} + +impl IntoIterator for TrashEntries { + type IntoIter = vec::IntoIter; + type Item = TrashEntry; + + fn into_iter(self) -> Self::IntoIter { self.0.into_iter() } +} diff --git a/yazi-fs/src/trash/entry.rs b/yazi-fs/src/trash/entry.rs index 717f6717..08107964 100644 --- a/yazi-fs/src/trash/entry.rs +++ b/yazi-fs/src/trash/entry.rs @@ -1,31 +1,80 @@ -use std::{ffi::OsString, path::PathBuf}; +use std::{ffi::OsStr, ops::Deref, path::{Path, PathBuf}}; +#[cfg(trash_unix)] +use std::{ffi::OsString, io}; -use mlua::{IntoLua, Lua, Value}; +use mlua::{AnyUserData, FromLua, Lua, UserData, UserDataFields, Value}; use yazi_shared::path::PathBufDyn; +use yazi_shim::mlua::UserDataFieldsExt; +use super::TrashId; use crate::cha::Cha; #[derive(Clone, Debug)] -pub struct TrashEntry { - pub name: OsString, - pub key: OsString, - pub cha: Cha, - pub link_to: Option, - pub backing: PathBuf, +pub(crate) struct TrashEntry { + pub(super) id: TrashId, + pub(super) cha: Cha, + pub(super) lcha: Cha, + pub(super) original: Option, + pub(super) link_to: Option, + pub(super) backing: PathBuf, +} + +impl Deref for TrashEntry { + type Target = TrashId; + + fn deref(&self) -> &Self::Target { &self.id } } impl TrashEntry { - #[cfg(all(unix, not(target_os = "android"), not(target_os = "ios")))] - pub(super) fn new(path: PathBuf, name: OsString, key: OsString) -> std::io::Result { + #[cfg(trash_unix)] + pub(super) fn new(id: TrashId, backing: B, original: Option) -> io::Result + where + B: Into, + { use super::TrashCha; + let backing = backing.into(); - let cha = Cha::from_trash(&path, &name, true)?; - let link_to = if cha.is_link() { std::fs::read_link(&path).ok() } else { None }; + let name = id + .rel() + .file_name() + .or_else(|| original.as_deref().and_then(Path::file_name)) + .or_else(|| backing.file_name()) + .filter(|name| !name.is_empty()) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "invalid trash item path"))?; - Ok(Self { name, key, cha, link_to, backing: path }) + let (lcha, cha) = Cha::from_trash(&backing, name)?; + let link_to = if lcha.is_link() { std::fs::read_link(&backing).ok() } else { None }; + + Ok(Self { id, cha, lcha, original, link_to, backing }) } - #[cfg(all(unix, not(target_os = "android"), not(target_os = "ios")))] + #[cfg(trash_unix)] + pub(super) fn top(top: T, backing: B, original: Option) -> io::Result + where + T: Into, + B: Into, + { + Self::new(TrashId::new(top, PathBuf::new())?, backing, original) + } + + #[cfg(trash_unix)] + pub(super) fn child(&self, name: OsString) -> io::Result { + let original = self.original.as_ref().map(|original| original.join(&name)); + Self::new(self.id.child(&name)?, self.backing.join(&name), original) + } + + pub(super) fn key(&self) -> &OsStr { self.rel().file_name().unwrap_or(self.top().as_os_str()) } + + pub(super) fn name(&self) -> &OsStr { + self + .rel() + .file_name() + .or_else(|| self.original.as_deref().and_then(Path::file_name)) + .or_else(|| self.backing.file_name()) + .expect("trash entry must have a name") + } + + #[cfg(trash_unix)] pub(super) fn into_file(self, url: impl Into) -> crate::file::File { use crate::file::{File, FileExtra}; @@ -37,17 +86,23 @@ impl TrashEntry { } } -impl IntoLua for TrashEntry { - fn into_lua(self, lua: &Lua) -> mlua::Result { - let Self { name, key, cha, link_to, backing } = self; - lua - .create_table_from([ - ("name", lua.create_external_string(name.into_encoded_bytes())?.into_lua(lua)?), - ("key", lua.create_external_string(key.into_encoded_bytes())?.into_lua(lua)?), - ("cha", cha.into_lua(lua)?), - ("link_to", link_to.map(PathBufDyn::Os).into_lua(lua)?), - ("backing", PathBufDyn::Os(backing).into_lua(lua)?), - ])? - .into_lua(lua) +impl FromLua for TrashEntry { + fn from_lua(value: Value, lua: &Lua) -> mlua::Result { + AnyUserData::from_lua(value, lua)?.take() + } +} + +impl UserData for TrashEntry { + fn add_fields>(fields: &mut F) { + fields.add_cached_field("key", |lua, me| lua.create_string(me.key().as_encoded_bytes())); + fields.add_cached_field("top", |lua, me| { + lua.create_string(me.top().as_os_str().as_encoded_bytes()) + }); + fields.add_cached_field("rel", |_, me| Ok(PathBufDyn::from(me.rel()))); + fields.add_cached_field("name", |lua, me| lua.create_string(me.name().as_encoded_bytes())); + fields.add_cached_field("cha", |_, me| Ok(me.cha)); + fields.add_cached_field("original", |_, me| Ok(me.original.clone().map(PathBufDyn::Os))); + fields.add_cached_field("link_to", |_, me| Ok(me.link_to.clone().map(PathBufDyn::Os))); + fields.add_cached_field("backing", |_, me| Ok(PathBufDyn::Os(me.backing.clone()))); } } diff --git a/yazi-fs/src/trash/freedesktop.rs b/yazi-fs/src/trash/freedesktop.rs deleted file mode 100644 index ec6a2945..00000000 --- a/yazi-fs/src/trash/freedesktop.rs +++ /dev/null @@ -1,171 +0,0 @@ -use std::{ffi::OsStr, fs, hash::Hash, io, path::{Path, PathBuf}}; - -use hashbrown::HashMap; -use trash::{TrashItem, os_limited}; -use yazi_macro::ok_or_not_found; -use yazi_shim::Twox128; - -use super::{TrashCha, TrashEntry, TrashNode, TrashNodes}; -use crate::{cha::{Cha, ChaSig}, file::File}; - -pub struct Trash; - -impl Trash { - pub fn new() -> io::Result { Ok(Self) } - - pub fn list(&self, node: Option<&TrashNode>) -> io::Result> { - let Some(node) = node else { - return os_limited::list() - .map_err(io::Error::other)? - .into_iter() - .map(|item| TrashEntry::new(Self::path(&item.id)?, item.name, item.id)) - .collect(); - }; - - fs::read_dir(self.resolve(node)?)? - .map(|entry| { - let entry = entry?; - let name = entry.file_name(); - let path = entry.path(); - TrashEntry::new(path, name.clone(), name) - }) - .collect() - } - - pub fn entry(&self, node: &TrashNode) -> io::Result { - let path = self.resolve(node)?; - let name = path - .file_name() - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "invalid trash item path"))? - .to_owned(); - TrashEntry::new(path, name, node.key.clone()) - } - - pub fn metadata(&self, node: &TrashNode, follow: bool) -> io::Result { - let path = self.resolve(node)?; - if let Some(name) = node.rel.file_name() { - Cha::from_trash(&path, name, follow) - } else { - let item = self.top_item(&node.top)?; - Cha::from_trash(&path, &item.name, follow) - } - } - - pub(super) fn revalidate( - &self, - node: Option<&TrashNode>, - current: &File, - ) -> io::Result> { - let latest = if let Some(node) = node { - self.entry(node)?.into_file(¤t.url) - } else { - let mut roots: Vec<_> = os_limited::trash_folders().unwrap_or_default().into_iter().collect(); - roots.sort_unstable(); - - let mut h = Twox128::default(); - for root in roots { - let meta = ok_or_not_found!(fs::metadata(root.join("info")), continue); - let cha = Cha::new(root.file_name().unwrap_or_default(), meta); - - root.hash(&mut h); - ChaSig(cha).hash(&mut h); - } - - let hash = h.finish_128(); - File { - cha: Cha { len: hash as u64 ^ (hash >> 64) as u64, ..Cha::from_mold(true) }, - ..current.clone() - } - }; - - let changed = !latest.cha.hits(current.cha) - || latest.extra.link_to() != current.extra.link_to() - || latest.extra.backing() != current.extra.backing(); - - Ok(changed.then_some(latest)) - } - - pub fn remove_file(&self, node: &TrashNode) -> io::Result<()> { - if node.rel.as_os_str().is_empty() { - os_limited::purge_all([self.top_item(&node.top)?]).map_err(io::Error::other) - } else { - fs::remove_file(self.resolve(node)?) - } - } - - pub fn remove_dir(&self, node: &TrashNode) -> io::Result<()> { - if node.rel.as_os_str().is_empty() { - os_limited::purge_all([self.top_item(&node.top)?]).map_err(io::Error::other) - } else { - fs::remove_dir(self.resolve(node)?) - } - } - - pub fn restore(&self, nodes: TrashNodes) -> io::Result<()> { - let mut tops = Vec::new(); - let items: HashMap<_, _> = os_limited::list() - .map_err(io::Error::other)? - .into_iter() - .map(|item| (item.id.clone(), item)) - .collect(); - - for node in nodes { - let item = items - .get(&node.top) - .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "trash item no longer exists"))?; - if node.rel.as_os_str().is_empty() { - tops.push(item.clone()); - continue; - } - - let from = self.resolve(&node)?; - let to = item.original_path().join(node.rel); - - let is_dir = fs::symlink_metadata(&from)?.is_dir(); - if let Some(parent) = to.parent() { - fs::create_dir_all(parent)?; - } - - match if is_dir { fs::create_dir(&to) } else { fs::File::create_new(&to).map(|_| ()) } { - Ok(()) => fs::rename(from, to), - Err(e) if e.kind() == io::ErrorKind::AlreadyExists => Err(io::Error::new( - io::ErrorKind::AlreadyExists, - format!("restore target already exists: {to:?}"), - )), - Err(e) => Err(e), - }?; - } - - os_limited::restore_all(tops).map_err(io::Error::other) - } - - pub fn empty(&self) -> io::Result<()> { - os_limited::purge_all(os_limited::list().map_err(io::Error::other)?).map_err(io::Error::other) - } - - fn resolve(&self, node: &TrashNode) -> io::Result { - let path = Self::path(&node.top)?; - Ok(if node.rel.as_os_str().is_empty() { path } else { path.join(&node.rel) }) - } - - fn top_item(&self, key: &OsStr) -> io::Result { - os_limited::list() - .map_err(io::Error::other)? - .into_iter() - .find(|item| item.id == key) - .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "trash item no longer exists")) - } - - fn path(key: &OsStr) -> io::Result { - let info = Path::new(key); // Path of the trash info file, e.g. "~/.local/share/Trash/info/filename.txt.trashinfo" - let parent = info - .parent() - .and_then(|parent| parent.parent()) - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "invalid trash item path"))?; - let stem = info - .file_stem() - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "invalid trash item path"))?; - - Ok(parent.join("files").join(stem)) - } -} diff --git a/yazi-fs/src/trash/freedesktop/mod.rs b/yazi-fs/src/trash/freedesktop/mod.rs new file mode 100644 index 00000000..e69d95e4 --- /dev/null +++ b/yazi-fs/src/trash/freedesktop/mod.rs @@ -0,0 +1 @@ +yazi_macro::mod_flat!(trash trash_info); diff --git a/yazi-fs/src/trash/freedesktop/trash.rs b/yazi-fs/src/trash/freedesktop/trash.rs new file mode 100644 index 00000000..83c00376 --- /dev/null +++ b/yazi-fs/src/trash/freedesktop/trash.rs @@ -0,0 +1,150 @@ +use std::{fs::{self}, hash::Hash, io, path::Path}; + +use trash::os_limited; +use yazi_macro::ok_or_not_found; +use yazi_shim::Twox128; + +use super::{super::{TrashCha, TrashEntries, TrashEntry, TrashId, restore_item}, TrashInfo}; +use crate::{cha::{Cha, ChaSig}, file::File}; + +pub struct Trash; + +impl Trash { + pub(crate) fn new() -> io::Result { Ok(Self) } + + pub(crate) fn list(&self, entry: Option<&TrashEntry>) -> io::Result> { + let Some(entry) = entry else { + return self.tops(); + }; + + // TODO + if !entry.lcha.is_dir() { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "trash item is not a directory")); + } + + fs::read_dir(&entry.backing)? + .map(|dirent| { + let dirent = dirent?; + entry.child(dirent.file_name()) + }) + .collect() + } + + pub(crate) fn entry(&self, id: &TrashId) -> io::Result { + let info = TrashInfo::parse(id.top())?; + if !os_limited::trash_folders() + .map_err(io::Error::other)? + .iter() + .any(|folder| folder == &info.root) + { + return Err(io::Error::new(io::ErrorKind::NotFound, "trash item outside of trash folders")); + } + + if id.has_rel() && !fs::symlink_metadata(&info.backing)?.file_type().is_dir() { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "trash item is not a directory")); + } + + let backing = info.backing.join(id.rel()); + TrashEntry::new(id.clone(), backing, Some(info.original.join(id.rel()))) + } + + pub(crate) fn metadata(&self, entry: &TrashEntry, follow: bool) -> io::Result { + Ok(if follow { entry.cha } else { entry.lcha }) + } + + pub(crate) fn revalidate( + &self, + entry: Option<&TrashEntry>, + current: &File, + ) -> io::Result> { + let latest = if let Some(entry) = entry { + entry.clone().into_file(¤t.url) + } else { + let mut roots: Vec<_> = os_limited::trash_folders().unwrap_or_default().into_iter().collect(); + roots.sort_unstable(); + + let mut h = Twox128::default(); + for root in roots { + let meta = ok_or_not_found!(fs::metadata(root.join("info")), continue); + let cha = Cha::new(root.file_name().unwrap_or_default(), meta); + + root.hash(&mut h); + ChaSig(cha).hash(&mut h); + } + + let hash = h.finish_128(); + File { + cha: Cha { len: hash as u64 ^ (hash >> 64) as u64, ..Cha::from_mold(true) }, + ..current.clone() + } + }; + + let changed = !latest.cha.hits(current.cha) + || latest.extra.link_to() != current.extra.link_to() + || latest.extra.backing() != current.extra.backing(); + + Ok(changed.then_some(latest)) + } + + pub(crate) fn remove_file(&self, entry: &TrashEntry) -> io::Result<()> { + fs::remove_file(&entry.backing)?; + if !entry.has_rel() { + fs::remove_file(entry.top())?; + } + Ok(()) + } + + pub(crate) fn remove_dir(&self, entry: &TrashEntry) -> io::Result<()> { + fs::remove_dir(&entry.backing)?; + if !entry.has_rel() { + fs::remove_file(entry.top())?; + } + Ok(()) + } + + pub(crate) fn restore(&self, entries: TrashEntries) -> io::Result<()> { + for entry in entries { + let to = entry.original.clone().ok_or_else(|| { + io::Error::new(io::ErrorKind::NotFound, "trash item has no put-back location") + })?; + + restore_item(&entry.backing, &to)?; + + if !entry.has_rel() { + fs::remove_file(entry.top())?; + } + } + Ok(()) + } + + // FIXME: also rename or remove the .trashinfo file in the info folder + pub(crate) fn rename(&self, entry: &TrashEntry, path: &Path) -> io::Result<()> { + fs::rename(&entry.backing, path) + } + + pub(crate) fn empty(&self) -> io::Result<()> { + for entry in self.tops()? { + if entry.lcha.is_dir() { + fs::remove_dir_all(&entry.backing)?; + } else { + fs::remove_file(&entry.backing)?; + } + fs::remove_file(entry.top())?; + } + Ok(()) + } + + fn tops(&self) -> io::Result> { + let mut tops = Vec::new(); + for root in os_limited::trash_folders().map_err(io::Error::other)? { + for entry in ok_or_not_found!(fs::read_dir(root.join("info")), continue) { + let entry = entry?; + let info = entry.path(); + if let Ok(parsed) = TrashInfo::parse(&info) { + tops.push(TrashEntry::top(info, parsed.backing, Some(parsed.original))?); + } + } + } + Ok(tops) + } +} diff --git a/yazi-fs/src/trash/freedesktop/trash_info.rs b/yazi-fs/src/trash/freedesktop/trash_info.rs new file mode 100644 index 00000000..c0d5b488 --- /dev/null +++ b/yazi-fs/src/trash/freedesktop/trash_info.rs @@ -0,0 +1,96 @@ +use std::{borrow::Cow, ffi::OsStr, fs::File, io::{self, BufRead, BufReader}, os::unix::ffi::OsStrExt, path::{Path, PathBuf}}; + +use percent_encoding::percent_decode; +use uzers::Users; +use yazi_shared::USERS_CACHE; +use yazi_shim::path::PathExt; + +pub(super) struct TrashInfo { + pub(super) root: PathBuf, + pub(super) backing: PathBuf, + pub(super) original: PathBuf, +} + +impl TrashInfo { + // Parses from a trashinfo path, e.g.: + // /home/alice/.local/share/Trash/info/cat.jpg.trashinfo + pub(super) fn parse(info: &Path) -> io::Result { + if info.extension() != Some(OsStr::new("trashinfo")) { + return Err(io::Error::new(io::ErrorKind::InvalidData, "invalid trash info path")); + } + + // /home/alice/.local/share/Trash + let root = info + .parent() + .filter(|p| p.file_name() == Some(OsStr::new("info"))) + .and_then(Path::parent) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "invalid trash info path"))?; + + // cat.jpg + let stem = info + .file_stem() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "invalid trash info path"))?; + + let original = Self::parse_original(info, root)?; + if original.file_name().is_none() { + return Err(io::Error::new(io::ErrorKind::InvalidData, "invalid original trash path")); + } + + Ok(Self { root: root.to_owned(), backing: root.join("files").join(stem), original }) + } + + fn parse_original(info: &Path, root: &Path) -> io::Result { + let mut reader = BufReader::new(File::open(info)?); + let mut line = Vec::new(); + + reader.read_until(b'\n', &mut line)?; + Self::trim_line(&mut line); + if line != b"[Trash Info]" { + return Err(io::Error::new(io::ErrorKind::InvalidData, "invalid trash info header")); + } + + loop { + line.clear(); + if reader.read_until(b'\n', &mut line)? == 0 { + return Err(io::Error::new(io::ErrorKind::InvalidData, "trash info has no Path")); + } + + Self::trim_line(&mut line); + let Some(value) = line.strip_prefix(b"Path=") else { continue }; + let decoded: Cow<[u8]> = percent_decode(value).into(); + + let path = Path::new(OsStr::from_bytes(decoded.as_ref())); + if path.as_os_str().is_empty() || !path.is_absolute() && path.has_parent_component() { + return Err(io::Error::new(io::ErrorKind::InvalidData, "invalid original trash path")); + } + + return Ok(if path.is_absolute() { + path.to_owned() + } else { + Self::mount_point(root)?.join(path) + }); + } + } + + // /mnt/disk/.Trash/1000 => /mnt/disk + // /home/alice/.local/share/Trash => /home/alice/.local/share + fn mount_point(root: &Path) -> io::Result<&Path> { + let uid = USERS_CACHE.get_current_uid().to_string(); + + if root.file_name() == Some(OsStr::new(&uid)) + && let Some(parent) = root.parent() + && parent.file_name() == Some(OsStr::new(".Trash")) + { + parent.parent() + } else { + root.parent() + } + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "invalid trash mount point")) + } + + fn trim_line(line: &mut Vec) { + while matches!(line.last(), Some(b'\n' | b'\r')) { + line.pop(); + } + } +} diff --git a/yazi-fs/src/trash/lua.rs b/yazi-fs/src/trash/lua.rs index 36058e63..48d5b0d2 100644 --- a/yazi-fs/src/trash/lua.rs +++ b/yazi-fs/src/trash/lua.rs @@ -1,10 +1,11 @@ use std::io; -use mlua::{ExternalError, ExternalResult, IntoLuaMulti, LuaString, UserData, UserDataMethods, Value}; +use mlua::{ExternalError, ExternalResult, FromLua, IntoLuaMulti, LuaString, UserData, UserDataMethods, Value}; use tokio::task::spawn_blocking; use yazi_binding::Error; +use yazi_shared::path::{PathBufDyn, PathLike}; -use super::{Trash, TrashNode, TrashNodes}; +use super::{Trash, TrashEntries, TrashEntry, TrashId}; use crate::file::File; impl UserData for Trash { @@ -16,31 +17,72 @@ impl UserData for Trash { } }); - methods.add_async_function("list", |lua, node: Option| async move { - match spawn_blocking(move || Trash::new()?.list(node.as_ref())).await.into_lua_err()? { - Ok(items) => lua.create_sequence_from(items)?.into_lua_multi(&lua), - Err(e) => (Value::Nil, Error::Io(e)).into_lua_multi(&lua), + methods.add_async_function("entry", |lua, value: Value| async move { + if let Value::UserData(ud) = value { + return ud.borrow::()?.clone().into_lua_multi(&lua); } - }); - methods.add_async_function("entry", |lua, node: TrashNode| async move { - match spawn_blocking(move || Trash::new()?.entry(&node)).await.into_lua_err()? { + let id = TrashId::from_lua(value, &lua)?; + match spawn_blocking(move || Trash::new()?.entry(&id)).await.into_lua_err()? { Ok(entry) => entry.into_lua_multi(&lua), Err(e) => (Value::Nil, Error::Io(e)).into_lua_multi(&lua), } }); - methods.add_async_function("metadata", |lua, (node, follow): (TrashNode, bool)| async move { - match spawn_blocking(move || Trash::new()?.metadata(&node, follow)).await.into_lua_err()? { + methods.add_async_function("list", |lua, entry: Option| async move { + match spawn_blocking(move || Trash::new()?.list(entry.as_ref())).await.into_lua_err()? { + Ok(items) => lua.create_sequence_from(items)?.into_lua_multi(&lua), + Err(e) => (Value::Nil, Error::Io(e)).into_lua_multi(&lua), + } + }); + + methods.add_async_function("metadata", |lua, (entry, follow): (TrashEntry, bool)| async move { + match spawn_blocking(move || Trash::new()?.metadata(&entry, follow)).await.into_lua_err()? { Ok(cha) => cha.into_lua_multi(&lua), Err(e) => (Value::Nil, Error::Io(e)).into_lua_multi(&lua), } }); + methods.add_async_function( + "remove", + |lua, (kind, entry): (LuaString, TrashEntry)| async move { + let f: fn(&Trash, &TrashEntry) -> io::Result<()> = match &*kind.as_bytes() { + b"file" => Trash::remove_file, + b"dir" => Trash::remove_dir, + _ => Err("Removal type must be 'file' or 'dir'".into_lua_err())?, + }; + + match spawn_blocking(move || f(&Trash::new()?, &entry)).await.into_lua_err()? { + Ok(()) => true.into_lua_multi(&lua), + Err(e) => (false, Error::Io(e)).into_lua_multi(&lua), + } + }, + ); + + methods.add_async_function( + "rename", + |lua, (entry, path): (TrashEntry, PathBufDyn)| async move { + match spawn_blocking(move || Trash::new()?.rename(&entry, path.as_os()?)) + .await + .into_lua_err()? + { + Ok(()) => true.into_lua_multi(&lua), + Err(e) => (false, Error::Io(e)).into_lua_multi(&lua), + } + }, + ); + + methods.add_async_function("restore", |lua, entries: TrashEntries| async move { + match spawn_blocking(move || Trash::new()?.restore(entries)).await.into_lua_err()? { + Ok(()) => true.into_lua_multi(&lua), + Err(e) => (false, Error::Io(e)).into_lua_multi(&lua), + } + }); + methods.add_async_function( "revalidate", - |lua, (node, file): (Option, File)| async move { - match spawn_blocking(move || Trash::new()?.revalidate(node.as_ref(), &file)) + |lua, (entry, file): (Option, File)| async move { + match spawn_blocking(move || Trash::new()?.revalidate(entry.as_ref(), &file)) .await .into_lua_err()? { @@ -49,25 +91,5 @@ impl UserData for Trash { } }, ); - - methods.add_async_function("remove", |lua, (kind, node): (LuaString, TrashNode)| async move { - let f: fn(&Trash, &TrashNode) -> io::Result<()> = match &*kind.as_bytes() { - b"file" => Trash::remove_file, - b"dir" => Trash::remove_dir, - _ => Err("Removal type must be 'file' or 'dir'".into_lua_err())?, - }; - - match spawn_blocking(move || f(&Trash::new()?, &node)).await.into_lua_err()? { - Ok(()) => true.into_lua_multi(&lua), - Err(e) => (false, Error::Io(e)).into_lua_multi(&lua), - } - }); - - methods.add_async_function("restore", |lua, nodes: TrashNodes| async move { - match spawn_blocking(move || Trash::new()?.restore(nodes)).await.into_lua_err()? { - Ok(()) => true.into_lua_multi(&lua), - Err(e) => (false, Error::Io(e)).into_lua_multi(&lua), - } - }); } } diff --git a/yazi-fs/src/trash/macos.rs b/yazi-fs/src/trash/macos.rs deleted file mode 100644 index b1a04cf5..00000000 --- a/yazi-fs/src/trash/macos.rs +++ /dev/null @@ -1,188 +0,0 @@ -use std::{ffi::{OsStr, OsString}, fs, io, path::{Component, Path, PathBuf}}; - -use ds_parser::Value; -use hashbrown::HashMap; -use yazi_macro::ok_or_not_found; - -use super::{TrashCha, TrashEntry, TrashNode, TrashNodes}; -use crate::{cha::Cha, file::File}; - -pub struct Trash; - -impl Trash { - pub fn new() -> io::Result { Ok(Self) } - - pub fn list(&self, node: Option<&TrashNode>) -> io::Result> { - let it = match fs::read_dir(self.resolve(node)?) { - Ok(it) => it, - Err(e) if e.kind() == io::ErrorKind::NotFound && node.is_none() => return Ok(vec![]), - Err(e) => return Err(e), - }; - - it.map(|entry| { - let entry = entry?; - let name = entry.file_name(); - let path = entry.path(); - let key = if node.is_some() { name.clone() } else { path.as_os_str().to_owned() }; - TrashEntry::new(path, name, key) - }) - .collect() - } - - pub fn entry(&self, node: &TrashNode) -> io::Result { - let path = self.resolve(Some(node))?; - let name = path - .file_name() - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "invalid trash item path"))? - .to_owned(); - TrashEntry::new(path, name, node.key.clone()) - } - - pub fn metadata(&self, node: &TrashNode, follow: bool) -> io::Result { - let path = self.resolve(Some(node))?; - Cha::from_trash(&path, path.file_name().unwrap_or_default(), follow) - } - - pub(super) fn revalidate( - &self, - node: Option<&TrashNode>, - current: &File, - ) -> io::Result> { - let latest = if let Some(node) = node { - self.entry(node)?.into_file(¤t.url) - } else { - let path = self.resolve(None)?; - let cha = match fs::symlink_metadata(&path) { - Ok(meta) => Cha::new(path.file_name().unwrap_or_default(), meta), - Err(e) if e.kind() == io::ErrorKind::NotFound => Cha::from_mold(true), - Err(e) => return Err(e), - }; - File { cha, ..current.clone() } - }; - - let changed = !latest.cha.hits(current.cha) - || latest.extra.link_to() != current.extra.link_to() - || latest.extra.backing() != current.extra.backing(); - - Ok(changed.then_some(latest)) - } - - pub fn remove_file(&self, node: &TrashNode) -> io::Result<()> { - fs::remove_file(self.resolve(Some(node))?) - } - - pub fn remove_dir(&self, node: &TrashNode) -> io::Result<()> { - fs::remove_dir(self.resolve(Some(node))?) - } - - pub fn restore(&self, nodes: TrashNodes) -> io::Result<()> { - let root = self.resolve(None)?; - let locations = OriginalLocation::parse(&fs::read(root.join(".DS_Store"))?)?; - - for node in nodes { - let name = Path::new(&node.top).file_name().unwrap_or_default(); - let location = locations.get(name).ok_or_else(|| { - io::Error::new( - io::ErrorKind::NotFound, - format!("original location is unavailable for trash item: {:?}", node.top), - ) - })?; - - let from = self.resolve(Some(&node))?; - let to = location.join(&node)?; - - let is_dir = fs::symlink_metadata(&from)?.is_dir(); - if let Some(parent) = to.parent() { - fs::create_dir_all(parent)?; - } - - match if is_dir { fs::create_dir(&to) } else { fs::File::create_new(&to).map(|_| ()) } { - Ok(()) => fs::rename(from, to), - Err(e) if e.kind() == io::ErrorKind::AlreadyExists => Err(io::Error::new( - io::ErrorKind::AlreadyExists, - format!("restore target already exists: {to:?}"), - )), - Err(e) => Err(e), - }?; - } - - Ok(()) - } - - pub fn empty(&self) -> io::Result<()> { - let root = self.resolve(None)?; - for entry in ok_or_not_found!(fs::read_dir(root), return Ok(())) { - let entry = entry?; - if entry.file_type()?.is_dir() { - fs::remove_dir_all(entry.path())?; - } else { - fs::remove_file(entry.path())?; - } - } - Ok(()) - } - - fn resolve(&self, node: Option<&TrashNode>) -> io::Result { - if let Some(node) = node { - let top = Path::new(&node.top); - return Ok(if node.rel.as_os_str().is_empty() { top.into() } else { top.join(&node.rel) }); - } - - Ok( - dirs::home_dir() - .filter(|p| p.is_absolute()) - .ok_or_else(|| { - io::Error::other("cannot determine home directory for trash root resolution") - })? - .join(".Trash"), - ) - } -} - -// --- OriginalLocation -#[derive(Default)] -struct OriginalLocation { - parent: Option, - name: Option, -} - -impl OriginalLocation { - fn parse(bytes: &[u8]) -> io::Result> { - let store = ds_parser::parse(bytes).map_err(io::Error::other)?; - let mut locations = HashMap::::new(); - - for record in store.records { - let Value::Ustr(value) = record.value else { continue }; - if value.is_empty() { - continue; - } - - let location = locations.entry_ref(OsStr::new(&record.name)).or_default(); - match &record.field.fourcc().bytes() { - b"ptbL" => location.parent = Some(value.into()), - b"ptbN" => location.name = Some(value.into()), - _ => {} - } - } - - Ok(locations) - } - - fn join(&self, node: &TrashNode) -> io::Result { - let parent = self.parent.as_deref().ok_or_else(|| { - io::Error::new(io::ErrorKind::InvalidData, "trash item has no put-back location") - })?; - - let name = self.name.as_deref().ok_or_else(|| { - io::Error::new(io::ErrorKind::InvalidData, "trash item has no put-back name") - })?; - - let mut components = Path::new(name).components(); - if !matches!(components.next(), Some(Component::Normal(_))) || components.next().is_some() { - return Err(io::Error::new(io::ErrorKind::InvalidData, "invalid trash put-back name")); - } - - let top_path = Path::new("/").join(parent).join(name); - Ok(if node.rel.as_os_str().is_empty() { top_path } else { top_path.join(&node.rel) }) - } -} diff --git a/yazi-fs/src/trash/macos/ds_store.rs b/yazi-fs/src/trash/macos/ds_store.rs new file mode 100644 index 00000000..fa1327ce --- /dev/null +++ b/yazi-fs/src/trash/macos/ds_store.rs @@ -0,0 +1,57 @@ +use std::{ffi::{OsStr, OsString}, fs, io, path::{Component, Path, PathBuf}}; + +use ds_parser::Value; +use hashbrown::HashMap; +use yazi_shim::path::PathExt; + +#[derive(Default)] +pub(super) struct DsStore { + parent: Option, + name: Option, +} + +impl DsStore { + pub(super) fn parse(path: &Path) -> io::Result> { + let bytes = fs::read(path)?; + let store = ds_parser::parse(&bytes).map_err(io::Error::other)?; + + let mut locations = HashMap::::new(); + for record in store.records { + let Value::Ustr(value) = record.value else { continue }; + if value.is_empty() { + continue; + } + + let location = locations.entry_ref(OsStr::new(&record.name)).or_default(); + match &record.field.fourcc().bytes() { + b"ptbL" => location.parent = Some(value.into()), + b"ptbN" => location.name = Some(value.into()), + _ => {} + } + } + + Ok(locations) + } + + pub(super) fn join(&self, rel: &Path) -> io::Result { + if !rel.is_relative() || rel.has_parent_component() { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "invalid trash entry path")); + } + + let parent = self.parent.as_deref().ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, "trash item has no put-back location") + })?; + + let name = self.name.as_deref().ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, "trash item has no put-back name") + })?; + + let mut components = Path::new(name).components(); + if !matches!(components.next(), Some(Component::Normal(_))) || components.next().is_some() { + return Err(io::Error::new(io::ErrorKind::InvalidData, "invalid trash put-back name")); + } + + let top_path = Path::new("/").join(parent).join(name); + Ok(if rel.as_os_str().is_empty() { top_path } else { top_path.join(rel) }) + } +} diff --git a/yazi-fs/src/trash/macos/mod.rs b/yazi-fs/src/trash/macos/mod.rs new file mode 100644 index 00000000..b2294f43 --- /dev/null +++ b/yazi-fs/src/trash/macos/mod.rs @@ -0,0 +1 @@ +yazi_macro::mod_flat!(trash ds_store); diff --git a/yazi-fs/src/trash/macos/trash.rs b/yazi-fs/src/trash/macos/trash.rs new file mode 100644 index 00000000..74f0ec47 --- /dev/null +++ b/yazi-fs/src/trash/macos/trash.rs @@ -0,0 +1,133 @@ +use std::{fs, io, path::{Path, PathBuf}}; + +use yazi_macro::ok_or_not_found; + +use super::{super::{TrashCha, TrashEntries, TrashEntry, TrashId, restore_item}, DsStore}; +use crate::{cha::Cha, file::File}; + +pub struct Trash; + +impl Trash { + pub(crate) fn new() -> io::Result { Ok(Self) } + + pub(crate) fn list(&self, entry: Option<&TrashEntry>) -> io::Result> { + if entry.is_some_and(|entry| !entry.lcha.is_dir()) { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "trash item is not a directory")); + } + + let root = self.root()?; + let store = if entry.is_none() { + DsStore::parse(&root.join(".DS_Store")).unwrap_or_default() + } else { + Default::default() + }; + + let path = entry.map_or(&root, |entry| &entry.backing); + let it = match fs::read_dir(path) { + Ok(it) => it, + Err(e) if e.kind() == io::ErrorKind::NotFound && entry.is_none() => return Ok(vec![]), + Err(e) => return Err(e), + }; + + it.map(|dirent| { + let dirent = dirent?; + if let Some(entry) = entry { + entry.child(dirent.file_name()) + } else { + let path = dirent.path(); + let original = store + .get(path.file_name().unwrap_or_default()) + .and_then(|ds| ds.join(Path::new("")).ok()); + TrashEntry::top(path.clone(), path, original) + } + }) + .collect() + } + + pub(crate) fn entry(&self, id: &TrashId) -> io::Result { + let root = self.root()?; + if id.top().parent() != Some(&root) { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "item is not in the trash")); + } + if id.has_rel() && !fs::symlink_metadata(id.top())?.file_type().is_dir() { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "trash item is not a directory")); + } + + let store = DsStore::parse(&root.join(".DS_Store")).unwrap_or_default(); + let name = id.top().file_name().unwrap_or_default(); + let original = store.get(name).and_then(|ds| ds.join(id.rel()).ok()); + + TrashEntry::new(id.clone(), id.path(), original) + } + + pub(crate) fn metadata(&self, entry: &TrashEntry, follow: bool) -> io::Result { + Ok(if follow { entry.cha } else { entry.lcha }) + } + + pub(crate) fn revalidate( + &self, + entry: Option<&TrashEntry>, + current: &File, + ) -> io::Result> { + let latest = if let Some(entry) = entry { + entry.clone().into_file(¤t.url) + } else { + let path = self.root()?; + let cha = match fs::symlink_metadata(&path) { + Ok(meta) => Cha::new(path.file_name().unwrap_or_default(), meta), + Err(e) if e.kind() == io::ErrorKind::NotFound => Cha::from_mold(true), + Err(e) => return Err(e), + }; + File { cha, ..current.clone() } + }; + + let changed = !latest.cha.hits(current.cha) + || latest.extra.link_to() != current.extra.link_to() + || latest.extra.backing() != current.extra.backing(); + + Ok(changed.then_some(latest)) + } + + pub(crate) fn remove_file(&self, entry: &TrashEntry) -> io::Result<()> { + fs::remove_file(&entry.backing) + } + + pub(crate) fn remove_dir(&self, entry: &TrashEntry) -> io::Result<()> { + fs::remove_dir(&entry.backing) + } + + pub(crate) fn restore(&self, entries: TrashEntries) -> io::Result<()> { + for entry in entries { + let to = entry.original.as_ref().ok_or_else(|| { + io::Error::new(io::ErrorKind::NotFound, "trash item has no put-back location") + })?; + + restore_item(&entry.backing, &to)?; + } + Ok(()) + } + + pub(crate) fn rename(&self, entry: &TrashEntry, path: &Path) -> io::Result<()> { + fs::rename(&entry.backing, path) + } + + pub(crate) fn empty(&self) -> io::Result<()> { + let root = self.root()?; + for dirent in ok_or_not_found!(fs::read_dir(root), return Ok(())) { + let dirent = dirent?; + if dirent.file_type()?.is_dir() { + fs::remove_dir_all(dirent.path())?; + } else { + fs::remove_file(dirent.path())?; + } + } + Ok(()) + } + + fn root(&self) -> io::Result { + dirs::home_dir() + .filter(|p| p.is_absolute()) + .ok_or_else(|| io::Error::other("cannot determine home directory for trash root resolution")) + .map(|home| home.join(".Trash")) + } +} diff --git a/yazi-fs/src/trash/mod.rs b/yazi-fs/src/trash/mod.rs index e48f2913..9b774b24 100644 --- a/yazi-fs/src/trash/mod.rs +++ b/yazi-fs/src/trash/mod.rs @@ -1,4 +1,7 @@ -yazi_macro::mod_flat!(entry lua node nodes); +yazi_macro::mod_flat!(entries entry lua trash_id); + +#[cfg(trash_unix)] +yazi_macro::mod_flat!(common); #[cfg(target_os = "macos")] yazi_macro::mod_flat!(macos); @@ -6,11 +9,11 @@ yazi_macro::mod_flat!(macos); #[cfg(windows)] yazi_macro::mod_flat!(windows); -#[cfg(all(unix, not(target_os = "macos"), not(target_os = "android"), not(target_os = "ios")))] +#[cfg(trash_freedesktop)] yazi_macro::mod_flat!(freedesktop); -#[cfg(any(target_os = "android", target_os = "ios"))] +#[cfg(trash_unsupported)] yazi_macro::mod_flat!(unsupported); -#[cfg(not(any(target_os = "android", target_os = "ios")))] +#[cfg(not(trash_unsupported))] yazi_macro::mod_flat!(traits); diff --git a/yazi-fs/src/trash/node.rs b/yazi-fs/src/trash/node.rs deleted file mode 100644 index dc97c26f..00000000 --- a/yazi-fs/src/trash/node.rs +++ /dev/null @@ -1,49 +0,0 @@ -use std::{ffi::OsString, io, path::{Component, PathBuf}}; - -use mlua::{BorrowedBytes, FromLua, IntoLua, Lua, Table, Value}; -use yazi_shared::{path::PathBufDyn, strand::AsStrand}; - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct TrashNode { - pub(super) key: OsString, - pub(super) top: OsString, - pub(super) rel: PathBuf, -} - -impl TrashNode { - pub(super) fn validate(&self) -> io::Result<()> { - if self.key.is_empty() || self.top.is_empty() || !self.rel.is_relative() { - return Err(io::Error::new(io::ErrorKind::InvalidInput, "invalid trash node")); - } - if self.rel.components().any(|c| matches!(c, Component::ParentDir)) { - return Err(io::Error::new(io::ErrorKind::InvalidInput, "invalid trash node path")); - } - Ok(()) - } -} - -impl FromLua for TrashNode { - fn from_lua(value: Value, lua: &Lua) -> mlua::Result { - let t = Table::from_lua(value, lua)?; - let node = Self { - key: t.raw_get::("key")?.as_strand().to_os_string()?, - top: t.raw_get::("top")?.as_strand().to_os_string()?, - rel: t.raw_get::("rel")?.into_os()?, - }; - - node.validate().map_err(mlua::Error::external)?; - Ok(node) - } -} - -impl IntoLua for TrashNode { - fn into_lua(self, lua: &Lua) -> mlua::Result { - lua - .create_table_from([ - ("key", lua.create_external_string(self.key.into_encoded_bytes())?.into_lua(lua)?), - ("top", lua.create_external_string(self.top.into_encoded_bytes())?.into_lua(lua)?), - ("rel", PathBufDyn::from(self.rel).into_lua(lua)?), - ])? - .into_lua(lua) - } -} diff --git a/yazi-fs/src/trash/nodes.rs b/yazi-fs/src/trash/nodes.rs deleted file mode 100644 index d6521780..00000000 --- a/yazi-fs/src/trash/nodes.rs +++ /dev/null @@ -1,36 +0,0 @@ -use std::vec; - -use mlua::{FromLua, Lua, Value}; - -use super::TrashNode; - -pub struct TrashNodes(Vec); - -impl TrashNodes { - fn new(mut nodes: Vec) -> Self { - nodes.sort_unstable_by_key(|node| node.rel.components().count()); - - let mut seen = Vec::::with_capacity(nodes.len()); - for node in nodes { - if seen.iter().any(|parent| parent.top == node.top && node.rel.starts_with(&parent.rel)) { - continue; - } - seen.push(node); - } - Self(seen) - } -} - -impl FromLua for TrashNodes { - fn from_lua(value: Value, lua: &Lua) -> mlua::Result { - let nodes = Vec::::from_lua(value, lua)?; - Ok(Self::new(nodes)) - } -} - -impl IntoIterator for TrashNodes { - type IntoIter = vec::IntoIter; - type Item = TrashNode; - - fn into_iter(self) -> Self::IntoIter { self.0.into_iter() } -} diff --git a/yazi-fs/src/trash/traits.rs b/yazi-fs/src/trash/traits.rs index 2c84c766..5c046d0f 100644 --- a/yazi-fs/src/trash/traits.rs +++ b/yazi-fs/src/trash/traits.rs @@ -1,14 +1,11 @@ +use std::fs; + use crate::cha::{Cha, ChaKind, ChaMode}; pub(super) trait TrashCha: Sized { fn from_mold(is_dir: bool) -> Self; - #[cfg(all(unix, not(target_os = "android"), not(target_os = "ios")))] - fn from_trash( - path: &std::path::Path, - name: &std::ffi::OsStr, - follow: bool, - ) -> std::io::Result; + fn from_trash(path: &std::path::Path, name: &std::ffi::OsStr) -> std::io::Result<(Self, Self)>; } impl TrashCha for Cha { @@ -20,17 +17,13 @@ impl TrashCha for Cha { cha } - #[cfg(all(unix, not(target_os = "android"), not(target_os = "ios")))] - fn from_trash( - path: &std::path::Path, - name: &std::ffi::OsStr, - follow: bool, - ) -> std::io::Result { - let cha = Cha::new(name, std::fs::symlink_metadata(path)?); - Ok(if cha.is_link() && follow { - cha.follow(std::fs::metadata(path).ok().map(|meta| Cha::new(name, meta))) + fn from_trash(path: &std::path::Path, name: &std::ffi::OsStr) -> std::io::Result<(Self, Self)> { + let lcha = Cha::new(name, fs::symlink_metadata(path)?); + let cha = if lcha.is_link() { + lcha.follow(fs::metadata(path).ok().map(|meta| Cha::new(name, meta))) } else { - cha - }) + lcha + }; + Ok((lcha, cha)) } } diff --git a/yazi-fs/src/trash/trash_id.rs b/yazi-fs/src/trash/trash_id.rs new file mode 100644 index 00000000..9eb9ea2f --- /dev/null +++ b/yazi-fs/src/trash/trash_id.rs @@ -0,0 +1,59 @@ +use std::{ffi::OsStr, io, path::{Path, PathBuf}}; + +use mlua::{BorrowedBytes, ExternalResult, FromLua, Lua, Table, Value}; +use yazi_shared::{path::PathBufDyn, strand::AsStrand}; +use yazi_shim::path::PathExt; + +#[derive(Clone, Debug)] +pub(crate) struct TrashId { + top: PathBuf, + rel: PathBuf, +} + +impl TrashId { + pub(super) fn new(top: T, rel: R) -> io::Result + where + T: Into, + R: Into, + { + let top = top.into(); + let rel = rel.into(); + + if top.as_os_str().is_empty() || !rel.is_relative() { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "invalid trash entry")); + } + if rel.has_parent_component() { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "invalid trash entry path")); + } + + Ok(Self { top, rel }) + } + + pub(super) fn top(&self) -> &Path { &self.top } + + pub(super) fn rel(&self) -> &Path { &self.rel } + + #[cfg(target_os = "macos")] + pub(super) fn path(&self) -> PathBuf { self.top.join(&self.rel) } + + pub(super) fn child(&self, name: &OsStr) -> io::Result { + let rel = self.rel.join(name); + if !rel.is_relative() || rel.has_parent_component() { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "invalid trash entry path")); + } + + Ok(Self { top: self.top.clone(), rel }) + } + + pub(super) fn has_rel(&self) -> bool { !self.rel.as_os_str().is_empty() } +} + +impl FromLua for TrashId { + fn from_lua(value: Value, lua: &Lua) -> mlua::Result { + let t = Table::from_lua(value, lua)?; + + let top = t.raw_get::("top")?.as_strand().to_os_path()?; + let rel = t.raw_get::("rel")?.into_os()?; + Self::new(top, rel).into_lua_err() + } +} diff --git a/yazi-fs/src/trash/unsupported/mod.rs b/yazi-fs/src/trash/unsupported/mod.rs new file mode 100644 index 00000000..eec35b1b --- /dev/null +++ b/yazi-fs/src/trash/unsupported/mod.rs @@ -0,0 +1 @@ +yazi_macro::mod_flat!(trash); diff --git a/yazi-fs/src/trash/unsupported.rs b/yazi-fs/src/trash/unsupported/trash.rs similarity index 57% rename from yazi-fs/src/trash/unsupported.rs rename to yazi-fs/src/trash/unsupported/trash.rs index 464e4026..038ebb59 100644 --- a/yazi-fs/src/trash/unsupported.rs +++ b/yazi-fs/src/trash/unsupported/trash.rs @@ -1,6 +1,6 @@ -use std::io; +use std::{io, path::Path}; -use super::{TrashEntry, TrashNode, TrashNodes}; +use super::super::{TrashEntries, TrashEntry, TrashId}; use crate::{cha::Cha, file::File}; pub struct Trash; @@ -8,35 +8,39 @@ pub struct Trash; impl Trash { pub fn new() -> io::Result { Ok(Self) } - pub fn list(&self, _node: Option<&TrashNode>) -> io::Result> { + pub fn list(&self, _entry: Option<&TrashEntry>) -> io::Result> { Err(io::Error::new(io::ErrorKind::Unsupported, "trash is not supported on this platform")) } - pub fn entry(&self, _node: &TrashNode) -> io::Result { + pub fn entry(&self, _id: &TrashId) -> io::Result { Err(io::Error::new(io::ErrorKind::Unsupported, "trash is not supported on this platform")) } - pub fn metadata(&self, _node: &TrashNode, _: bool) -> io::Result { + pub fn metadata(&self, _entry: &TrashEntry, _: bool) -> io::Result { Err(io::Error::new(io::ErrorKind::Unsupported, "trash is not supported on this platform")) } - pub(super) fn revalidate( + pub(crate) fn revalidate( &self, - _node: Option<&TrashNode>, + _entry: Option<&TrashEntry>, _current: &File, ) -> io::Result> { Err(io::Error::new(io::ErrorKind::Unsupported, "trash is not supported on this platform")) } - pub fn remove_file(&self, _node: &TrashNode) -> io::Result<()> { + pub fn remove_file(&self, _entry: &TrashEntry) -> io::Result<()> { Err(io::Error::new(io::ErrorKind::Unsupported, "trash is not supported on this platform")) } - pub fn remove_dir(&self, _node: &TrashNode) -> io::Result<()> { + pub fn remove_dir(&self, _entry: &TrashEntry) -> io::Result<()> { Err(io::Error::new(io::ErrorKind::Unsupported, "trash is not supported on this platform")) } - pub fn restore(&self, _nodes: TrashNodes) -> io::Result<()> { + pub fn restore(&self, _entries: TrashEntries) -> io::Result<()> { + Err(io::Error::new(io::ErrorKind::Unsupported, "trash is not supported on this platform")) + } + + pub fn rename(&self, _entry: &TrashEntry, _path: &Path) -> io::Result<()> { Err(io::Error::new(io::ErrorKind::Unsupported, "trash is not supported on this platform")) } diff --git a/yazi-fs/src/trash/windows.rs b/yazi-fs/src/trash/windows.rs deleted file mode 100644 index 167f068d..00000000 --- a/yazi-fs/src/trash/windows.rs +++ /dev/null @@ -1,330 +0,0 @@ -use std::{ffi::{OsStr, OsString, c_void}, fs, hash::Hash, io, mem, os::windows::ffi::{OsStrExt, OsStringExt}, path::{Path, PathBuf}, time::{Duration, SystemTime, UNIX_EPOCH}}; - -use hashbrown::HashMap; -use trash::{TrashItem, os_limited}; -use windows::{Win32::{Foundation::*, Storage::EnhancedStorage::*, System::{Com::*, SystemServices::*}, UI::Shell::*}, core::{Interface, PCWSTR}}; -use yazi_ffi::Com; -use yazi_shim::Twox128; - -use super::{TrashCha, TrashEntry, TrashNode, TrashNodes}; -use crate::{cha::Cha, file::File}; - -thread_local! { - static COM: io::Result = Com::new(); -} - -pub struct Trash; - -impl Trash { - pub fn new() -> io::Result { - COM.with(|result| { - result.as_ref().map(|_| Self).map_err(|e| io::Error::new(e.kind(), e.to_string())) - }) - } - - pub fn list(&self, node: Option<&TrashNode>) -> io::Result> { - let Some(node) = node else { - return os_limited::list() - .map_err(io::Error::other)? - .into_iter() - .map(|item| ShellItem::new(&item.id)?.entry(item.name, item.id)) - .collect(); - }; - - self - .resolve(node)? - .children()? - .into_iter() - .map(|item| { - let name = item.display_name(SIGDN_PARENTRELATIVE)?; - item.entry(name.clone(), name) - }) - .collect() - } - - pub fn entry(&self, node: &TrashNode) -> io::Result { - let item = self.resolve(node)?; - item.entry(item.display_name(SIGDN_PARENTRELATIVE)?, &node.key) - } - - pub fn metadata(&self, node: &TrashNode, _: bool) -> io::Result { self.resolve(node)?.cha() } - - pub(super) fn revalidate( - &self, - node: Option<&TrashNode>, - current: &File, - ) -> io::Result> { - let cha = - if let Some(node) = node { TrashSig::item(&self.resolve(node)?)? } else { TrashSig::root()? }; - - Ok(if cha.hits(current.cha) { None } else { Some(File { cha, ..current.clone() }) }) - } - - pub fn remove_file(&self, node: &TrashNode) -> io::Result<()> { - if node.rel.as_os_str().is_empty() { - os_limited::purge_all([self.top_item(&node.top)?]).map_err(io::Error::other) - } else { - self.resolve(node)?.delete() - } - } - - pub fn remove_dir(&self, node: &TrashNode) -> io::Result<()> { - if node.rel.as_os_str().is_empty() { - return os_limited::purge_all([self.top_item(&node.top)?]).map_err(io::Error::other); - } - - let item = self.resolve(node)?; - if !item.children()?.is_empty() { - return Err(io::Error::new(io::ErrorKind::DirectoryNotEmpty, "trash directory is not empty")); - } - item.delete() - } - - pub fn restore(&self, nodes: TrashNodes) -> io::Result<()> { - let mut tops = Vec::new(); - let items: HashMap<_, _> = os_limited::list() - .map_err(io::Error::other)? - .into_iter() - .map(|item| (item.id.clone(), item)) - .collect(); - - for node in nodes { - let item = items - .get(&node.top) - .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "trash item no longer exists"))?; - if node.rel.as_os_str().is_empty() { - tops.push(item.clone()); - } else { - let to = item.original_path().join(&node.rel); - self.restore_do(&self.resolve(&node)?, &to)?; - } - } - os_limited::restore_all(tops).map_err(io::Error::other) - } - - fn restore_do(&self, item: &ShellItem, to: &Path) -> io::Result<()> { - match fs::symlink_metadata(to) { - Ok(_) => { - return Err(io::Error::new( - io::ErrorKind::AlreadyExists, - format!("restore target already exists: {to:?}"), - )); - } - Err(e) if e.kind() == io::ErrorKind::NotFound => {} - Err(e) => return Err(e), - } - - // Create parent directories - let parent = to - .parent() - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "invalid restore target"))?; - fs::create_dir_all(parent)?; - - let parent = ShellItem::new(parent.as_os_str())?; - let name: Vec = to - .file_name() - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "invalid restore target"))? - .encode_wide() - .chain([0]) - .collect(); - - operate(FOF_NO_UI | FOFX_EARLYFAILURE, |operation| unsafe { - operation.MoveItem(&item.0, &parent.0, PCWSTR(name.as_ptr()), None) - }) - } - - pub fn empty(&self) -> io::Result<()> { - os_limited::purge_all(os_limited::list().map_err(io::Error::other)?).map_err(io::Error::other) - } - - fn resolve(&self, node: &TrashNode) -> io::Result { - let top = ShellItem::new(&node.top)?; - if node.rel.as_os_str().is_empty() { - return Ok(top); - } - - let path = PathBuf::from(top.display_name(SIGDN_FILESYSPATH)?).join(&node.rel); - ShellItem::new(path.as_os_str()) - } - - fn top_item(&self, key: &OsStr) -> io::Result { - os_limited::list() - .map_err(io::Error::other)? - .into_iter() - .find(|item| item.id == key) - .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "trash item no longer exists")) - } -} - -// --- TrashSig -struct TrashSig { - names: Option>, - count_size: Option<(i64, i64)>, -} - -impl TrashSig { - fn root() -> io::Result { - let root = ShellItem::root()?; - let cha = root.cha()?; - - let mut info = - SHQUERYRBINFO { cbSize: mem::size_of::() as u32, ..Default::default() }; - - let sig = if unsafe { SHQueryRecycleBinW(PCWSTR::null(), &mut info) }.is_ok() { - Self { names: None, count_size: Some((info.i64NumItems, info.i64Size)) } - } else if cha.mtime.is_some() { - Self { names: None, count_size: None } - } else { - Self { names: Some(Self::names(&root)?), count_size: None } - }; - - Ok(sig.into_cha(cha)) - } - - fn item(item: &ShellItem) -> io::Result { - let cha = item.cha()?; - let sig = if cha.mtime.is_none() && cha.is_dir() { - Self { names: Some(Self::names(item)?), count_size: None } - } else { - Self { names: None, count_size: None } - }; - - Ok(sig.into_cha(cha)) - } - - fn names(item: &ShellItem) -> io::Result> { - let mut names: Vec<_> = item - .children()? - .into_iter() - .map(|item| item.display_name(SIGDN_DESKTOPABSOLUTEPARSING)) - .collect::>()?; - names.sort_unstable(); - Ok(names) - } - - fn into_cha(self, mut cha: Cha) -> Cha { - let mut h = Twox128::default(); - if let Some((count, size)) = self.count_size { - (size, count).hash(&mut h); - } else if let Some(names) = self.names { - names.hash(&mut h); - } else { - return cha; - } - - let hash = h.finish_128(); - cha.ctime = UNIX_EPOCH.checked_add(Duration::from_nanos(hash as u64 ^ (hash >> 64) as u64)); - cha - } -} - -// --- ShellItem -struct ShellItem(IShellItem); - -impl ShellItem { - fn new(name: &OsStr) -> io::Result { - let name: Vec = name.encode_wide().chain([0]).collect(); - unsafe { SHCreateItemFromParsingName(PCWSTR(name.as_ptr()), None) }.map(Self).map_err(error) - } - - fn root() -> io::Result { - unsafe { SHGetKnownFolderItem(&FOLDERID_RecycleBinFolder, KF_FLAG_DEFAULT, None) } - .map(Self) - .map_err(error) - } - - fn children(&self) -> io::Result> { - let items: IEnumShellItems = - unsafe { self.0.BindToHandler(None, &BHID_EnumItems).map_err(error)? }; - - let mut result = Vec::new(); - loop { - let mut fetched = 0; - let mut next = [None]; - unsafe { items.Next(&mut next, Some(&mut fetched)).map_err(error)? }; - if fetched == 0 { - break; - } else if let Some(item) = next[0].take() { - result.push(Self(item)); - } - } - Ok(result) - } - - fn cha(&self) -> io::Result { - if let Ok(backing) = self.display_name(SIGDN_FILESYSPATH).map(PathBuf::from) { - match fs::metadata(&backing) { - Ok(meta) => return Ok(Cha::new(backing.file_name().unwrap_or_default(), meta)), - Err(e) if e.kind() == io::ErrorKind::NotFound => return Err(e), - Err(_) => {} - } - } - - let item: IShellItem2 = self.0.cast().map_err(error)?; - let is_dir = unsafe { self.0.GetAttributes(SFGAO_FOLDER) }.map_err(error)? == SFGAO_FOLDER; - - let mut cha = Cha::from_mold(is_dir); - cha.len = unsafe { item.GetUInt64(&PKEY_Size) }.unwrap_or_default(); - cha.mtime = unsafe { item.GetFileTime(&PKEY_DateModified) }.ok().and_then(system_time); - Ok(cha) - } - - fn entry(&self, name: N, key: K) -> io::Result - where - N: Into, - K: Into, - { - Ok(TrashEntry { - name: name.into(), - key: key.into(), - cha: self.cha()?, - link_to: None, - backing: self.display_name(SIGDN_FILESYSPATH)?.into(), - }) - } - - fn delete(&self) -> io::Result<()> { - operate(FOF_NO_UI, |operation| unsafe { operation.DeleteItem(&self.0, None) }) - } - - fn display_name(&self, kind: SIGDN) -> io::Result { - unsafe { - let name = self.0.GetDisplayName(kind).map_err(error)?; - let result = OsString::from_wide(name.as_wide()); - CoTaskMemFree(Some(name.0.cast::())); - Ok(result) - } - } -} - -fn operate(flags: FILEOPERATION_FLAGS, f: F) -> io::Result<()> -where - F: FnOnce(&IFileOperation) -> windows::core::Result<()>, -{ - let aborted = unsafe { - let operation: IFileOperation = - CoCreateInstance(&FileOperation, None, CLSCTX_ALL).map_err(error)?; - operation.SetOperationFlags(flags).map_err(error)?; - f(&operation).map_err(error)?; - operation.PerformOperations().map_err(error)?; - operation.GetAnyOperationsAborted().map_err(error)?.as_bool() - }; - - if aborted { Err(io::Error::other("trash operation was aborted")) } else { Ok(()) } -} - -fn error(error: windows::core::Error) -> io::Error { - if error.code() == ERROR_FILE_NOT_FOUND.to_hresult() - || error.code() == ERROR_PATH_NOT_FOUND.to_hresult() - { - io::Error::new(io::ErrorKind::NotFound, error) - } else { - io::Error::other(error) - } -} - -fn system_time(time: FILETIME) -> Option { - let ticks = (u64::from(time.dwHighDateTime) << 32) | u64::from(time.dwLowDateTime); - let ticks = ticks.checked_sub(116_444_736_000_000_000)?; - Some(UNIX_EPOCH + Duration::new(ticks / 10_000_000, (ticks % 10_000_000) as u32 * 100)) -} diff --git a/yazi-fs/src/trash/windows/mod.rs b/yazi-fs/src/trash/windows/mod.rs new file mode 100644 index 00000000..3cfc874b --- /dev/null +++ b/yazi-fs/src/trash/windows/mod.rs @@ -0,0 +1 @@ +yazi_macro::mod_flat!(shell_item trash trash_sig); diff --git a/yazi-fs/src/trash/windows/shell_item.rs b/yazi-fs/src/trash/windows/shell_item.rs new file mode 100644 index 00000000..f87f5c6b --- /dev/null +++ b/yazi-fs/src/trash/windows/shell_item.rs @@ -0,0 +1,96 @@ +use std::{ffi::{OsStr, OsString, c_void}, io, os::windows::ffi::{OsStrExt, OsStringExt}, path::{Path, PathBuf}}; + +use windows::{Win32::{Foundation::*, Storage::EnhancedStorage::*, System::{Com::{StructuredStorage::PropVariantToBSTR, *}, SystemServices::*}, UI::Shell::*}, core::{Interface, PCWSTR}}; + +use super::{super::{TrashCha, TrashEntry, TrashId}, trash::{error, operate, system_time}}; +use crate::cha::Cha; + +pub(super) struct ShellItem(pub(super) IShellItem); + +impl ShellItem { + pub(super) fn new(name: impl AsRef) -> io::Result { + let name: Vec = name.as_ref().encode_wide().chain([0]).collect(); + unsafe { SHCreateItemFromParsingName(PCWSTR(name.as_ptr()), None) }.map(Self).map_err(error) + } + + pub(super) fn root() -> io::Result { + unsafe { SHGetKnownFolderItem(&FOLDERID_RecycleBinFolder, KF_FLAG_DEFAULT, None) } + .map(Self) + .map_err(error) + } + + pub(super) fn top(name: &Path) -> io::Result { + Self::root()? + .children()? + .into_iter() + .find(|item| item.display_name(SIGDN_DESKTOPABSOLUTEPARSING).is_ok_and(|s| s == name)) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "item is not in the recycle bin")) + } + + pub(super) fn children(&self) -> io::Result> { + let items: IEnumShellItems = + unsafe { self.0.BindToHandler(None, &BHID_EnumItems).map_err(error)? }; + + let mut result = Vec::new(); + loop { + let mut fetched = 0; + let mut next = [None]; + unsafe { items.Next(&mut next, Some(&mut fetched)).map_err(error)? }; + if fetched == 0 { + break; + } else if let Some(item) = next[0].take() { + result.push(Self(item)); + } + } + Ok(result) + } + + pub(super) fn cha(&self) -> io::Result { + if let Ok(backing) = self.display_name(SIGDN_FILESYSPATH).map(PathBuf::from) { + let name = backing.file_name().unwrap_or_default(); + match Cha::from_trash(&backing, name) { + Ok((_, cha)) => return Ok(cha), + Err(e) if e.kind() == io::ErrorKind::NotFound => return Err(e), + Err(_) => {} + } + } + + let item: IShellItem2 = self.0.cast().map_err(error)?; + let is_dir = unsafe { self.0.GetAttributes(SFGAO_FOLDER) }.map_err(error)? == SFGAO_FOLDER; + + let mut cha = Cha::from_mold(is_dir); + cha.len = unsafe { item.GetUInt64(&PKEY_Size) }.unwrap_or_default(); + cha.mtime = unsafe { item.GetFileTime(&PKEY_DateModified) }.ok().and_then(system_time); + Ok(cha) + } + + pub(super) fn original(&self) -> io::Result { + let item: IShellItem2 = self.0.cast().map_err(error)?; + let value = unsafe { + item.GetProperty(&PROPERTYKEY { fmtid: PSGUID_DISPLACED, pid: PID_DISPLACED_FROM }) + } + .map_err(error)?; + + let parent = unsafe { PropVariantToBSTR(&value) }.map_err(error)?; + Ok(PathBuf::from(OsString::from_wide(&parent)).join(self.display_name(SIGDN_PARENTRELATIVE)?)) + } + + pub(super) fn entry(&self, id: TrashId, original: Option) -> io::Result { + let backing: PathBuf = self.display_name(SIGDN_FILESYSPATH)?.into(); + let (lcha, cha) = Cha::from_trash(&backing, backing.file_name().unwrap_or_default())?; + Ok(TrashEntry { id, cha, lcha, original, link_to: None, backing }) + } + + pub(super) fn delete(&self) -> io::Result<()> { + operate(FOF_NO_UI, |operation| unsafe { operation.DeleteItem(&self.0, None) }) + } + + pub(super) fn display_name(&self, kind: SIGDN) -> io::Result { + unsafe { + let name = self.0.GetDisplayName(kind).map_err(error)?; + let result = OsString::from_wide(name.as_wide()); + CoTaskMemFree(Some(name.0.cast::())); + Ok(result) + } + } +} diff --git a/yazi-fs/src/trash/windows/trash.rs b/yazi-fs/src/trash/windows/trash.rs new file mode 100644 index 00000000..adf4a498 --- /dev/null +++ b/yazi-fs/src/trash/windows/trash.rs @@ -0,0 +1,199 @@ +use std::{fs, io, os::windows::ffi::OsStrExt, path::{Path, PathBuf}, time::{Duration, SystemTime, UNIX_EPOCH}}; + +use windows::{Win32::{Foundation::*, System::Com::*, UI::Shell::*}, core::PCWSTR}; +use yazi_ffi::Com; + +use super::{super::{TrashEntries, TrashEntry, TrashId}, shell_item::ShellItem, trash_sig::TrashSig}; +use crate::{cha::Cha, file::File}; + +thread_local! { + static COM: io::Result = Com::new(); +} + +pub struct Trash; + +impl Trash { + pub(crate) fn new() -> io::Result { + COM.with(|result| { + result.as_ref().map(|_| Self).map_err(|e| io::Error::new(e.kind(), e.to_string())) + }) + } + + pub(crate) fn list(&self, entry: Option<&TrashEntry>) -> io::Result> { + let Some(entry) = entry else { + return self.tops(); + }; + + if !entry.lcha.is_dir() || entry.lcha.is_indirect() { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "trash item is not a directory")); + } + + let original = entry.original.as_deref().ok_or_else(|| { + io::Error::new(io::ErrorKind::NotFound, "trash item has no put-back location") + })?; + + self + .resolve(entry)? + .children()? + .into_iter() + .map(|item| { + let name = item.display_name(SIGDN_PARENTRELATIVE)?; + item.entry(entry.id.child(&name)?, Some(original.join(&name))) + }) + .collect() + } + + pub(crate) fn entry(&self, id: &TrashId) -> io::Result { + let top = ShellItem::top(id.top())?; + let original = top.original()?.join(id.rel()); + if !id.has_rel() { + return top.entry(id.clone(), Some(original)); + } + + let backing: PathBuf = top.display_name(SIGDN_FILESYSPATH)?.into(); + let cha = Cha::new(backing.file_name().unwrap_or_default(), fs::symlink_metadata(&backing)?); + if cha.is_dir() && !cha.is_indirect() { + ShellItem::new(backing.join(id.rel()))?.entry(id.clone(), Some(original)) + } else { + Err(io::Error::new(io::ErrorKind::InvalidInput, "trash item is not a directory")) + } + } + + pub(crate) fn metadata(&self, entry: &TrashEntry, follow: bool) -> io::Result { + Ok(if follow { entry.cha } else { entry.lcha }) + } + + pub(crate) fn revalidate( + &self, + entry: Option<&TrashEntry>, + current: &File, + ) -> io::Result> { + let cha = if let Some(entry) = entry { + TrashSig::item(&self.resolve(entry)?)? + } else { + TrashSig::root()? + }; + + Ok(if cha.hits(current.cha) { None } else { Some(File { cha, ..current.clone() }) }) + } + + pub(crate) fn remove_file(&self, entry: &TrashEntry) -> io::Result<()> { + self.resolve(entry)?.delete() + } + + pub(crate) fn remove_dir(&self, entry: &TrashEntry) -> io::Result<()> { + let item = self.resolve(entry)?; + if !entry.has_rel() { + item.delete() + } else if !item.children()?.is_empty() { + Err(io::Error::new(io::ErrorKind::DirectoryNotEmpty, "trash directory is not empty")) + } else { + item.delete() + } + } + + pub(crate) fn restore(&self, entries: TrashEntries) -> io::Result<()> { + for entry in entries { + let to = entry.original.as_deref().ok_or_else(|| { + io::Error::new(io::ErrorKind::NotFound, "trash item has no put-back location") + })?; + + self.restore_do(&self.resolve(&entry)?, &to)?; + } + Ok(()) + } + + pub(crate) fn rename(&self, entry: &TrashEntry, path: &Path) -> io::Result<()> { + fs::rename(self.resolve(entry)?.display_name(SIGDN_FILESYSPATH)?, path) + } + + fn restore_do(&self, item: &ShellItem, to: &Path) -> io::Result<()> { + match fs::symlink_metadata(to) { + Ok(_) => { + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + format!("restore target already exists: {to:?}"), + )); + } + Err(e) if e.kind() == io::ErrorKind::NotFound => {} + Err(e) => return Err(e), + } + + // Create parent directories + let parent = to + .parent() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "invalid restore target"))?; + fs::create_dir_all(parent)?; + + let parent = ShellItem::new(parent.as_os_str())?; + let name: Vec = to + .file_name() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "invalid restore target"))? + .encode_wide() + .chain([0]) + .collect(); + + operate(FOF_NO_UI | FOFX_EARLYFAILURE, |operation| unsafe { + operation.MoveItem(&item.0, &parent.0, PCWSTR(name.as_ptr()), None) + }) + } + + pub(crate) fn empty(&self) -> io::Result<()> { + unsafe { + SHEmptyRecycleBinW( + None, + PCWSTR::null(), + SHERB_NOCONFIRMATION | SHERB_NOPROGRESSUI | SHERB_NOSOUND, + ) + } + .map_err(error) + } + + fn resolve(&self, entry: &TrashEntry) -> io::Result { + if entry.has_rel() { ShellItem::new(&entry.backing) } else { ShellItem::top(entry.top()) } + } + + fn tops(&self) -> io::Result> { + ShellItem::root()? + .children()? + .into_iter() + .map(|item| { + let top = item.display_name(SIGDN_DESKTOPABSOLUTEPARSING)?; + let original = item.original()?; + item.entry(TrashId::new(top, PathBuf::new())?, Some(original)) + }) + .collect() + } +} + +pub(super) fn operate(flags: FILEOPERATION_FLAGS, f: F) -> io::Result<()> +where + F: FnOnce(&IFileOperation) -> windows::core::Result<()>, +{ + let aborted = unsafe { + let operation: IFileOperation = + CoCreateInstance(&FileOperation, None, CLSCTX_ALL).map_err(error)?; + operation.SetOperationFlags(flags).map_err(error)?; + f(&operation).map_err(error)?; + operation.PerformOperations().map_err(error)?; + operation.GetAnyOperationsAborted().map_err(error)?.as_bool() + }; + + if aborted { Err(io::Error::other("trash operation was aborted")) } else { Ok(()) } +} + +pub(super) fn error(error: windows::core::Error) -> io::Error { + if error.code() == ERROR_FILE_NOT_FOUND.to_hresult() + || error.code() == ERROR_PATH_NOT_FOUND.to_hresult() + { + io::Error::new(io::ErrorKind::NotFound, error) + } else { + io::Error::other(error) + } +} + +pub(super) fn system_time(time: FILETIME) -> Option { + let ticks = (u64::from(time.dwHighDateTime) << 32) | u64::from(time.dwLowDateTime); + let ticks = ticks.checked_sub(116_444_736_000_000_000)?; + Some(UNIX_EPOCH + Duration::new(ticks / 10_000_000, (ticks % 10_000_000) as u32 * 100)) +} diff --git a/yazi-fs/src/trash/windows/trash_sig.rs b/yazi-fs/src/trash/windows/trash_sig.rs new file mode 100644 index 00000000..e3ebddd0 --- /dev/null +++ b/yazi-fs/src/trash/windows/trash_sig.rs @@ -0,0 +1,68 @@ +use std::{ffi::OsString, hash::Hash, io, mem, time::{Duration, UNIX_EPOCH}}; + +use windows::{Win32::UI::Shell::*, core::PCWSTR}; +use yazi_shim::Twox128; + +use super::shell_item::ShellItem; +use crate::cha::Cha; + +pub(super) struct TrashSig { + names: Option>, + count_size: Option<(i64, i64)>, +} + +impl TrashSig { + pub(super) fn root() -> io::Result { + let root = ShellItem::root()?; + let cha = root.cha()?; + + let mut info = + SHQUERYRBINFO { cbSize: mem::size_of::() as u32, ..Default::default() }; + + let sig = if unsafe { SHQueryRecycleBinW(PCWSTR::null(), &mut info) }.is_ok() { + Self { names: None, count_size: Some((info.i64NumItems, info.i64Size)) } + } else if cha.mtime.is_some() { + Self { names: None, count_size: None } + } else { + Self { names: Some(Self::names(&root)?), count_size: None } + }; + + Ok(sig.into_cha(cha)) + } + + pub(super) fn item(item: &ShellItem) -> io::Result { + let cha = item.cha()?; + let sig = if cha.mtime.is_none() && cha.is_dir() { + Self { names: Some(Self::names(item)?), count_size: None } + } else { + Self { names: None, count_size: None } + }; + + Ok(sig.into_cha(cha)) + } + + fn names(item: &ShellItem) -> io::Result> { + let mut names: Vec<_> = item + .children()? + .into_iter() + .map(|item| item.display_name(SIGDN_DESKTOPABSOLUTEPARSING)) + .collect::>()?; + names.sort_unstable(); + Ok(names) + } + + fn into_cha(self, mut cha: Cha) -> Cha { + let mut h = Twox128::default(); + if let Some((count, size)) = self.count_size { + (size, count).hash(&mut h); + } else if let Some(names) = self.names { + names.hash(&mut h); + } else { + return cha; + } + + let hash = h.finish_128(); + cha.ctime = UNIX_EPOCH.checked_add(Duration::from_nanos(hash as u64 ^ (hash >> 64) as u64)); + cha + } +} diff --git a/yazi-plugin/preset/plugins/trash.lua b/yazi-plugin/preset/plugins/trash.lua index 79c3e4a8..9c713c49 100644 --- a/yazi-plugin/preset/plugins/trash.lua +++ b/yazi-plugin/preset/plugins/trash.lua @@ -7,18 +7,43 @@ local function top(url) return url.name and url or nil end -local function node(url) +local function entry(url) local top = top(url) if not top then - return + return -- We are at the root, no entry to return end - return { - key = url.spec.domain, + return fs.trash.entry { top = top.spec.domain, rel = url:strip_prefix(top) or Path.os(""), } end +local function file(url, ent) + return File { + url = url, + cha = ent.cha, + link_to = ent.link_to, + backing = ent.backing, + } +end + +local function files(parent, ents) + for i, ent in ipairs(ents) do + local url = parent:join(Path.os(ent.name)):into_domain(ent.key) + ents[i] = file(url, ent) + end + return ents +end + +local function notify(action, err) + ya.notify { + title = "Trash", + content = string.format("Failed to %s: %s", action, err), + level = "error", + timeout = 10, + } +end + local function absolute(url) if url.is_absolute then return fs.clean_url(url) @@ -36,44 +61,72 @@ local function absolute(url) return fs.clean_url(url:join(root:join(url.path))) end -local function file(url, entry) - entry.url = url - return File(entry) -end - -local function files(parent, entries) - for i, entry in ipairs(entries) do - local url = parent:join(Path.os(entry.name)):into_domain(entry.key) - entries[i] = file(url, entry) +local restore_recursively +local function restore(ent) + local id = { top = ent.top, rel = ent.rel } + local ok, err = fs.trash.restore { ent } + if ok then + return true, 0 + elseif err.kind ~= "AlreadyExists" then + return false, 1, err end - return entries + return restore_recursively(id, err) end -local function notify(action, err) - ya.notify { - title = "Trash", - content = string.format("Failed to %s: %s", action, err), - level = "error", - timeout = 10, - } +restore_recursively = function(id, collision) + local ent, err = fs.trash.entry(id) + if not ent then + return false, 1, err + elseif not ent.cha.is_dir or ent.cha.is_indirect then + return false, 1, collision + end + + local target = ent.original and fs.cha(Url(ent.original), false) + if not target or not target.is_dir or target.is_indirect then + return false, 1, collision + end + + local ents, err = fs.trash.list(fs.trash.entry(ent)) + if not ents then + return false, 1, err + end + + local failed, changed, first = 0, false, nil + for _, child in ipairs(ents) do + local a, b, c = restore(child) + changed, failed, first = changed or a, failed + b, first or c + end + + if failed ~= 0 then + return changed, failed, first + end + + local ok, err = fs.trash.remove("dir", ent) + return changed or ok, ok and 0 or 1, err end function M:setup() ps.sub_remote("trash-restore", function(args) ya.async(function() - local nodes = {} + local ents, err = {}, nil for i, arg in ipairs(args) do - nodes[i] = node(Url(arg)) - if not nodes[i] then - return notify("restore", "Cannot restore the trash root") + ents[i], err = entry(Url(arg)) + if not ents[i] then + return notify("restore", err) end end - local ok, err = fs.trash.restore(nodes) - if ok then + local changed, failed, first = false, 0, nil + for _, ent in ipairs(ents) do + local a, b, c = restore(ent) + changed, failed, first = changed or a, failed + b, first or c + end + + if changed then ya.emit("refresh", {}) - else - notify("restore", err) + end + if failed ~= 0 then + notify("restore", string.format("%d item(s) could not be restored: %s", failed, first)) end end) end) @@ -115,36 +168,46 @@ function M:provide(job) elseif op == "Casefold" then return job.url elseif op == "Metadata" or op == "SymlinkMetadata" then - local n = node(job.url) - if n then - return fs.trash.metadata(n, op == "Metadata") + local ent, err = entry(job.url) + if ent then + return fs.trash.metadata(ent, op == "Metadata") + elseif err then + return nil, err else return Cha { mode = tonumber("40700", 8) } end elseif op == "ReadDir" then - local entries, err = fs.trash.list(node(job.url)) - if entries then - return files(job.url, entries) + local ent, err = entry(job.url) + if err then + return nil, err + end + local ents, err = fs.trash.list(ent) + if ents then + return files(job.url, ents) else return nil, err end elseif op == "Revalidate" then - return fs.trash.revalidate(node(job.file.url), job.file) - elseif op == "File" then - local n = node(job.url) - if not n then - return nil, Error.custom("Cannot construct a file for the trash root") + local ent, err = entry(job.file.url) + if err then + return nil, err + else + return fs.trash.revalidate(ent, job.file) end - local entry, err = fs.trash.entry(n) - if entry then - return file(job.url, entry) + elseif op == "File" then + local ent, err = entry(job.url) + if ent then + return file(job.url, ent) else return nil, err end - elseif op == "RemoveFile" then - return fs.trash.remove("file", node(job.url)) - elseif op == "RemoveDir" then - return fs.trash.remove("dir", node(job.url)) + elseif op == "RemoveFile" or op == "RemoveDir" then + local ent, err = entry(job.url) + if ent then + return fs.trash.remove(op == "RemoveDir" and "dir" or "file", ent) + else + return false, err + end end return false, Error.custom("Unsupported trash operation: " .. op) diff --git a/yazi-scheduler/src/file/file.rs b/yazi-scheduler/src/file/file.rs index 5b5a59c9..2525274f 100644 --- a/yazi-scheduler/src/file/file.rs +++ b/yazi-scheduler/src/file/file.rs @@ -43,7 +43,7 @@ impl File { _ => Ok(()), }, async |task, cha| { - Ok(if cha.is_orphan() || (cha.is_link() && !task.follow) { + Ok(if cha.is_orphan() || (cha.is_indirect() && !task.follow) { self.ops.out(id, FileOutCopy::New(0)); self.requeue(task.into_link(), NORMAL); } else { @@ -115,7 +115,7 @@ impl File { _ => Ok(()), }, |task, cha| { - let nofollow = cha.is_orphan() || (cha.is_link() && !task.follow); + let nofollow = cha.is_orphan() || (cha.is_indirect() && !task.follow); self.ops.out(id, FileOutCut::New(if nofollow { 0 } else { cha.len })); if nofollow { @@ -196,6 +196,7 @@ impl File { } pub(crate) async fn link_do(&self, task: FileInLink) -> Result<(), FileOutLink> { + let mut cha = task.cha; let mut src: PathCow = if task.resolve { ok_or_not_found!( task, @@ -216,17 +217,29 @@ impl File { ctx!( task, engine::symlink(&task.to, src, async || { - Ok(match task.cha { + Ok(match cha { Some(cha) => cha.is_dir(), - None => Self::cha(&task.from, task.resolve, None).await?.is_dir(), + None => { + cha = Some(Self::cha(&task.from, task.resolve, None).await?); + cha.unwrap().is_dir() + } }) }) .await )?; if task.delete { - engine::remove_file(&task.from).await.ok(); + let cha = match cha { + Some(cha) => cha, + None => ctx!(task, Self::cha(&task.from, task.resolve, None).await)?, + }; + if cha.is_dir() && cha.is_indirect() { + engine::remove_dir(&task.from).await.ok(); + } else { + engine::remove_file(&task.from).await.ok(); + } } + Ok(self.ops.out(task.id, FileOutLink::Succ)) } @@ -291,13 +304,20 @@ impl File { } pub(crate) async fn delete_do(&self, task: FileInDelete) -> Result<(), FileOutDeleteDo> { - match engine::remove_file(&task.target).await { + let cha = task.cha.unwrap(); + let result = if cha.is_dir() && cha.is_indirect() { + engine::remove_dir(&task.target).await + } else { + engine::remove_file(&task.target).await + }; + + match result { Ok(()) => {} Err(e) if e.kind() == NotFound => {} Err(_) if !maybe_exists(&task.target).await => {} Err(e) => ctx!(task, Err(e))?, } - Ok(self.ops.out(task.id, FileOutDeleteDo::Succ(task.cha.unwrap().len))) + Ok(self.ops.out(task.id, FileOutDeleteDo::Succ(cha.len))) } pub(crate) async fn trash(&self, task: FileInTrash) -> Result<(), FileOutTrash> { diff --git a/yazi-scheduler/src/file/transaction.rs b/yazi-scheduler/src/file/transaction.rs index ddf7db60..29d0f3a7 100644 --- a/yazi-scheduler/src/file/transaction.rs +++ b/yazi-scheduler/src/file/transaction.rs @@ -34,7 +34,7 @@ impl Transaction { let url = url.as_url(); let cha = ok_or_not_found!(engine::symlink_metadata(url).await, return Ok(())); - if cha.is_link() { + if cha.is_indirect() { engine::rename(Self::tmp(url).await?, url).await?; } else if !cha.contains(ChaMode::U_WRITE) { engine::set_attrs(url, Attrs::mode(cha.mode | ChaMode::U_WRITE)).await?; diff --git a/yazi-scheduler/src/file/traverse.rs b/yazi-scheduler/src/file/traverse.rs index fab4361c..a99d23c1 100644 --- a/yazi-scheduler/src/file/traverse.rs +++ b/yazi-scheduler/src/file/traverse.rs @@ -159,7 +159,8 @@ where E: Fn(String), { let cha = ctx!(task, task.init().await)?; - if !cha.is_dir() { + let follow_symlink = cha.is_link() && task.follow(); + if !cha.is_dir() || (!follow_symlink && cha.is_indirect()) { return on_file(task, cha).await; } @@ -198,7 +199,8 @@ where "Cannot get metadata for {from:?}" ); - if cha.is_dir() { + let follow_symlink = cha.is_link() && task.follow(); + if cha.is_dir() && (follow_symlink || !cha.is_indirect()) { dirs.push_back(from); continue; } diff --git a/yazi-shared/src/auth/auth.rs b/yazi-shared/src/auth/auth.rs index 00216569..57271a9d 100644 --- a/yazi-shared/src/auth/auth.rs +++ b/yazi-shared/src/auth/auth.rs @@ -1,4 +1,4 @@ -use std::{fmt, hash::{Hash, Hasher}, sync::Arc}; +use std::{fmt, sync::Arc}; use serde::Deserialize; use yazi_shim::cell::RoCell; @@ -7,7 +7,7 @@ use crate::{auth::{AuthInventory, AuthKind, Domain, EncodeAuth, Scheme}, path::{ pub(super) static DEFAULT_ARC: RoCell> = RoCell::new(); -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq)] pub struct Auth { pub kind: AuthKind, pub scheme: Scheme, @@ -16,22 +16,6 @@ pub struct Auth { pub parent: Option>, } -impl PartialEq for Auth { - fn eq(&self, other: &Self) -> bool { - self.kind == other.kind && self.scheme == other.scheme && self.domain == other.domain - } -} - -impl Eq for Auth {} - -impl Hash for Auth { - fn hash(&self, state: &mut H) { - self.kind.hash(state); - self.scheme.hash(state); - self.domain.hash(state); - } -} - impl Default for Auth { fn default() -> Self { Self::DEFAULT } } diff --git a/yazi-shared/src/path/buf.rs b/yazi-shared/src/path/buf.rs index 70815f9f..f23d8265 100644 --- a/yazi-shared/src/path/buf.rs +++ b/yazi-shared/src/path/buf.rs @@ -22,6 +22,10 @@ impl From for PathBufDyn { fn from(value: std::path::PathBuf) -> Self { Self::Os(value) } } +impl From<&std::path::PathBuf> for PathBufDyn { + fn from(value: &std::path::PathBuf) -> Self { Self::Os(value.clone()) } +} + impl From for PathBufDyn { fn from(value: typed_path::UnixPathBuf) -> Self { Self::Unix(value) } } diff --git a/yazi-shared/src/strand/strand.rs b/yazi-shared/src/strand/strand.rs index 16fdf6f1..7d4ce8f9 100644 --- a/yazi-shared/src/strand/strand.rs +++ b/yazi-shared/src/strand/strand.rs @@ -92,14 +92,19 @@ impl<'a> Strand<'a> { } } - #[inline] - pub fn to_os_string(self) -> Result { self.as_os().map(|s| s.to_owned()) } - #[inline] pub fn as_os_path(self) -> Result<&'a std::path::Path, StrandError> { self.as_os().map(std::path::Path::new) } + #[inline] + pub fn to_os_path(self) -> Result { + self.as_os().map(std::path::PathBuf::from) + } + + #[inline] + pub fn to_os_string(self) -> Result { self.as_os().map(|s| s.to_owned()) } + #[inline] pub fn as_unix_path(self) -> &'a typed_path::UnixPath { typed_path::UnixPath::new(self.encoded_bytes()) diff --git a/yazi-shim/Cargo.toml b/yazi-shim/Cargo.toml index e36bd485..a0a021e6 100644 --- a/yazi-shim/Cargo.toml +++ b/yazi-shim/Cargo.toml @@ -22,7 +22,6 @@ yazi-macro = { path = "../yazi-macro", version = "26.5.6" } # External dependencies anyhow = { workspace = true } arc-swap = { workspace = true } -base64 = { workspace = true } hashbrown = { workspace = true } mlua = { workspace = true } percent-encoding = { workspace = true } diff --git a/yazi-shim/src/base64.rs b/yazi-shim/src/base64.rs deleted file mode 100644 index c3f6dae0..00000000 --- a/yazi-shim/src/base64.rs +++ /dev/null @@ -1,8 +0,0 @@ -use base64::{alphabet::STANDARD, engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig}}; - -pub const BASE64_SANE: GeneralPurpose = GeneralPurpose::new( - &STANDARD, - GeneralPurposeConfig::new() - .with_encode_padding(false) - .with_decode_padding_mode(DecodePaddingMode::Indifferent), -); diff --git a/yazi-shim/src/lib.rs b/yazi-shim/src/lib.rs index c5dabb9b..e9e28c5e 100644 --- a/yazi-shim/src/lib.rs +++ b/yazi-shim/src/lib.rs @@ -1,6 +1,6 @@ yazi_macro::mod_pub!(arc_swap cell fs mlua path ratatui serde strum toml vec wtf8); -yazi_macro::mod_flat!(base64 option percent_encoding sstr twox utf8); +yazi_macro::mod_flat!(option percent_encoding sstr twox utf8); #[cfg(windows)] yazi_macro::mod_flat!(win32); diff --git a/yazi-shim/src/path/mod.rs b/yazi-shim/src/path/mod.rs index 2122106c..15b23599 100644 --- a/yazi-shim/src/path/mod.rs +++ b/yazi-shim/src/path/mod.rs @@ -1 +1 @@ -yazi_macro::mod_flat!(separator); +yazi_macro::mod_flat!(separator traits); diff --git a/yazi-shim/src/path/traits.rs b/yazi-shim/src/path/traits.rs new file mode 100644 index 00000000..74378e49 --- /dev/null +++ b/yazi-shim/src/path/traits.rs @@ -0,0 +1,11 @@ +use std::path::{Component, Path}; + +pub trait PathExt { + fn has_parent_component(&self) -> bool; +} + +impl PathExt for Path { + fn has_parent_component(&self) -> bool { + self.components().any(|c| matches!(c, Component::ParentDir)) + } +} diff --git a/yazi-term/src/event/dnd.rs b/yazi-term/src/event/dnd.rs index d3c81b6a..fa82db5e 100644 --- a/yazi-term/src/event/dnd.rs +++ b/yazi-term/src/event/dnd.rs @@ -1,8 +1,7 @@ use std::str::SplitWhitespace; -use base64::Engine; +use base64::{Engine, engine::general_purpose::STANDARD_NO_PAD_INDIFFERENT}; use strum::{FromRepr, IntoStaticStr}; -use yazi_shim::BASE64_SANE; use crate::parser::StateOsc72; @@ -195,7 +194,7 @@ impl DndEvent { }), b'r' => Self::DropArrive(DndDropArrive { idx: s.x?.try_into().ok()?, - data: BASE64_SANE.decode(&s.payload).ok()?, + data: STANDARD_NO_PAD_INDIFFERENT.decode(&s.payload).ok()?, }), b'R' => { let (name, desc) = parse_error(s.payload)?; diff --git a/yazi-tty/src/sequence/dnd.rs b/yazi-tty/src/sequence/dnd.rs index 9591ff17..989bed76 100644 --- a/yazi-tty/src/sequence/dnd.rs +++ b/yazi-tty/src/sequence/dnd.rs @@ -1,7 +1,6 @@ use std::{fmt::{self, Display}, str}; -use base64::Engine; -use yazi_shim::BASE64_SANE; +use base64::{Engine, engine::general_purpose::STANDARD_NO_PAD_INDIFFERENT}; use super::traits::Mimelist; @@ -92,7 +91,7 @@ pub struct PresentDrag<'a>(pub u8, pub &'a [u8]); impl Display for PresentDrag<'_> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let b64 = BASE64_SANE.encode(self.1).into_bytes(); + let b64 = STANDARD_NO_PAD_INDIFFERENT.encode(self.1).into_bytes(); let chunks = b64.len().div_ceil(4096); for (i, chunk) in b64.chunks(4096).enumerate() { @@ -120,7 +119,7 @@ pub struct PresentDragIcon<'a> { impl Display for PresentDragIcon<'_> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let b64 = BASE64_SANE.encode(self.data).into_bytes(); + let b64 = STANDARD_NO_PAD_INDIFFERENT.encode(self.data).into_bytes(); let chunks = b64.len().div_ceil(4096); for (i, chunk) in b64.chunks(4096).enumerate() { diff --git a/yazi-tui/src/raterm.rs b/yazi-tui/src/raterm.rs index 4908d59c..7dd6f566 100644 --- a/yazi-tui/src/raterm.rs +++ b/yazi-tui/src/raterm.rs @@ -48,7 +48,8 @@ impl Raterm { If(TMUX.get(), EnterAlternateScreen), PushKeyboardFlags::DISAMBIGUATE_ESCAPE_CODES | PushKeyboardFlags::REPORT_ALTERNATE_KEYS - | PushKeyboardFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES, + | PushKeyboardFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES + | PushKeyboardFlags::REPORT_ASSOCIATED_TEXT, EnableDrag(""), EnableDrop(&["text/uri-list"]), If(opt.mouse, EnableMouseCapture), diff --git a/yazi-vfs/src/engine/calculator.rs b/yazi-vfs/src/engine/calculator.rs index bf5cfa5d..71d6cfeb 100644 --- a/yazi-vfs/src/engine/calculator.rs +++ b/yazi-vfs/src/engine/calculator.rs @@ -18,7 +18,7 @@ impl SizeCalculator { { let url = url.as_url(); let cha = super::symlink_metadata(url).await?; - Ok(if cha.is_dir() { + Ok(if cha.is_dir() && !cha.is_indirect() { Self::Dir(VecDeque::from([Either::Left(url.to_owned())]), cha) } else { Self::File(Some(cha.len), cha) @@ -77,10 +77,10 @@ impl SizeCalculator { pop_and_continue!(); }; - let Ok(ft) = ent.file_type().await else { continue }; - if ft.is_dir() { + let Ok(cha) = ent.metadata().await else { continue }; + if cha.is_dir() && !cha.is_indirect() { buf.push_back(Either::Left(ent.url())); - } else if let Ok(cha) = ent.metadata().await { + } else { size += cha.len; } } diff --git a/yazi-vfs/src/engine/engines.rs b/yazi-vfs/src/engine/engines.rs index e948ebcf..3974758c 100644 --- a/yazi-vfs/src/engine/engines.rs +++ b/yazi-vfs/src/engine/engines.rs @@ -193,6 +193,14 @@ impl<'a> Engine for Engines<'a> { } } + async fn remove_dir_clean(&self) -> io::Result<()> { + match self { + Self::Local(p) => p.remove_dir_clean().await, + Self::Lua(p) => p.remove_dir_clean().await, + Self::Sftp(p) => p.remove_dir_clean().await, + } + } + async fn remove_file(&self) -> io::Result<()> { match self { Self::Local(p) => p.remove_file().await,