feat: tree view for the preset archive previewer (#3525)

Co-authored-by: sxyazi <sxyazi@gmail.com>
This commit is contained in:
itsvyle 2026-01-11 06:20:43 +01:00 committed by GitHub
parent 16d20e2171
commit 233d721e7c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 91 additions and 50 deletions

View file

@ -14,6 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/):
### Added ### Added
- Tree view for the preset archive previewer ([#3525])
- Support compressed tarballs (`.tar.gz`, `.tar.bz2`, etc.) in the preset archive previewer ([#3518]) - Support compressed tarballs (`.tar.gz`, `.tar.bz2`, etc.) in the preset archive previewer ([#3518])
- New `Path.os()` API creates an OS-native `Path` ([#3541]) - New `Path.os()` API creates an OS-native `Path` ([#3541])
@ -1596,6 +1597,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/):
[#3494]: https://github.com/sxyazi/yazi/pull/3494 [#3494]: https://github.com/sxyazi/yazi/pull/3494
[#3514]: https://github.com/sxyazi/yazi/pull/3514 [#3514]: https://github.com/sxyazi/yazi/pull/3514
[#3518]: https://github.com/sxyazi/yazi/pull/3518 [#3518]: https://github.com/sxyazi/yazi/pull/3518
[#3525]: https://github.com/sxyazi/yazi/pull/3525
[#3532]: https://github.com/sxyazi/yazi/pull/3532 [#3532]: https://github.com/sxyazi/yazi/pull/3532
[#3540]: https://github.com/sxyazi/yazi/pull/3540 [#3540]: https://github.com/sxyazi/yazi/pull/3540
[#3541]: https://github.com/sxyazi/yazi/pull/3541 [#3541]: https://github.com/sxyazi/yazi/pull/3541

20
Cargo.lock generated
View file

@ -96,9 +96,9 @@ dependencies = [
[[package]] [[package]]
name = "ansi-to-tui" name = "ansi-to-tui"
version = "8.0.0" version = "8.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdfd3cbf4843347ca072771a797484f1c3434a14d57f39d31c92dfb93a8799a8" checksum = "e42366bb9d958f042bf58f0a85e1b2d091997c1257ca49bddd7e4827aadc65fd"
dependencies = [ dependencies = [
"nom 8.0.0", "nom 8.0.0",
"ratatui-core", "ratatui-core",
@ -541,9 +541,9 @@ dependencies = [
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.2.51" version = "1.2.52"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a0aeaff4ff1a90589618835a598e545176939b97874f7abc7851caa0618f203" checksum = "cd4932aefd12402b36c60956a4fe0035421f544799057659ff86f923657aada3"
dependencies = [ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"jobserver", "jobserver",
@ -1063,9 +1063,9 @@ dependencies = [
[[package]] [[package]]
name = "data-encoding" name = "data-encoding"
version = "2.9.0" version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
[[package]] [[package]]
name = "deadpool" name = "deadpool"
@ -1483,9 +1483,9 @@ dependencies = [
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.6" version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff" checksum = "f449e6c6c08c865631d4890cfacf252b3d396c9bcc83adb6623cdb02a8336c41"
[[package]] [[package]]
name = "finl_unicode" name = "finl_unicode"
@ -4478,9 +4478,9 @@ dependencies = [
[[package]] [[package]]
name = "toml" name = "toml"
version = "0.9.10+spec-1.1.0" version = "0.9.11+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0825052159284a1a8b4d6c0c86cbc801f2da5afd2b225fa548c72f2e74002f48" checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46"
dependencies = [ dependencies = [
"indexmap", "indexmap",
"serde_core", "serde_core",

View file

@ -26,7 +26,7 @@ lto = false
debug = false debug = false
[workspace.dependencies] [workspace.dependencies]
ansi-to-tui = "8.0.0" ansi-to-tui = "8.0.1"
anyhow = "1.0.100" anyhow = "1.0.100"
base64 = "0.22.1" base64 = "0.22.1"
bitflags = { version = "2.10.0", features = [ "serde" ] } bitflags = { version = "2.10.0", features = [ "serde" ] }
@ -59,7 +59,7 @@ thiserror = "2.0.17"
tokio = { version = "1.49.0", features = [ "full" ] } tokio = { version = "1.49.0", features = [ "full" ] }
tokio-stream = "0.1.18" tokio-stream = "0.1.18"
tokio-util = "0.7.18" tokio-util = "0.7.18"
toml = { version = "0.9.10" } toml = { version = "0.9.11" }
tracing = { version = "0.1.44", features = [ "max_level_debug", "release_max_level_debug" ] } tracing = { version = "0.1.44", features = [ "max_level_debug", "release_max_level_debug" ] }
twox-hash = { version = "2.1.2", default-features = false, features = [ "std", "random", "xxhash3_128" ] } twox-hash = { version = "2.1.2", default-features = false, features = [ "std", "random", "xxhash3_128" ] }
typed-path = "0.12.0" typed-path = "0.12.0"

View file

@ -1 +1 @@
{"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"],"version":"0.2","language":"en"} {"language":"en","flagWords":[],"words":["Punct","KEYMAP","splitn","crossterm","YAZI","peekable","ratatui","syntect","pbpaste","pbcopy","oneshot","Posix","Lsar","XADDOS","zoxide","cands","Deque","precache","imageops","IFBLK","IFCHR","IFDIR","IFIFO","IFLNK","IFMT","IFSOCK","IRGRP","IROTH","IRUSR","ISGID","ISUID","ISVTX","IWGRP","IWOTH","IWUSR","IXGRP","IXOTH","IXUSR","libc","winsize","TIOCGWINSZ","xpixel","ypixel","ioerr","appender","Catppuccin","macchiato","gitmodules","Dotfiles","bashprofile","vimrc","flac","webp","exiftool","mediainfo","ripgrep","indexmap","indexmap","unwatch","canonicalize","serde","fsevent","Ueberzug","iterm","wezterm","sixel","chafa","ueberzugpp","Konsole","Überzug","pkgs","pdftoppm","poppler","singlefile","jpegopt","EXIF","rustfmt","mktemp","nanos","xclip","xsel","natord","Mintty","nixos","nixpkgs","SIGTSTP","SIGCONT","SIGCONT","mlua","nonstatic","userdata","metatable","natsort","backstack","luajit","Succ","Succ","cand","fileencoding","foldmethod","lightgreen","darkgray","lightred","lightyellow","lightcyan","nushell","msvc","aarch","linemode","sxyazi","rsplit","ZELLIJ","bitflags","bitflags","USERPROFILE","Neovim","vergen","gitcl","Renderable","preloaders","prec","Upserting","prio","Ghostty","Catmull","Lanczos","cmds","unyank","scrolloff","headsup","unsub","uzers","scopeguard","SPDLOG","globset","filetime","magick","magick","prefetcher","Prework","prefetchers","PREWORKERS","conds","translit","rxvt","Urxvt","realpath","realname","REPARSE","hardlink","hardlinking","nlink","nlink","linemodes","SIGSTOP","sevenzip","rsplitn","replacen","DECSET","DECRQM","repeek","cwds","tcsi","Hyprland","Wayfire","SWAYSOCK","btime","nsec","codegen","gethostname","fchmod","fdfind","Rustc","rustc","ffprobe","vframes","luma","obase","outln","errln","tmtheme","twox","cfgs","fstype","objc","rdev","runloop","exfat","rclone","DECRQSS","DECSCUSR","libvterm","Uninit","lockin","rposition","resvg","foldhash","tilded","futs","chdir","hashbrown","JEMALLOC","RUSTFLAGS","RDONLY","GETPATH","fcntl","casefold","inodes","Splatable","casefied","thiserror","memchr","memmem","russh","deadpool","keepalive","nodelay","publickey","deadpool","initing","treelize"],"version":"0.2"}

View file

@ -15,8 +15,7 @@ pub struct Partition {
impl Partition { impl Partition {
// Match mount types that do not reliably emit change notifications, or do not // Match mount types that do not reliably emit change notifications, or do not
// update directory metadata on changes, and should be refreshed frequently / // update directory metadata on changes, and should be refreshed frequently.
// heuristically.
pub fn heuristic(&self) -> bool { pub fn heuristic(&self) -> bool {
let b: &[u8] = self.fstype.as_ref().map_or(b"", |s| s.as_encoded_bytes()); let b: &[u8] = self.fstype.as_ref().map_or(b"", |s| s.as_encoded_bytes());
matches!(b, b"exfat" | b"fuse.rclone") matches!(b, b"exfat" | b"fuse.rclone")

View file

@ -34,12 +34,11 @@ impl Partitions {
pub fn heuristic(&self, _cha: Cha) -> bool { pub fn heuristic(&self, _cha: Cha) -> bool {
#[cfg(any(target_os = "linux", target_os = "macos"))] #[cfg(any(target_os = "linux", target_os = "macos"))]
{ {
self.by_dev(_cha.dev).is_none_or(|p| p.heuristic()) self.by_dev(_cha.dev).is_some_and(|p| p.heuristic())
} }
#[cfg(not(any(target_os = "linux", target_os = "macos")))] #[cfg(not(any(target_os = "linux", target_os = "macos")))]
{ {
// For now, assume other targets update directory stat data correctly & do not // For now, assume other targets update directory stat data correctly
// need heuristic polling.
false false
} }
} }

View file

@ -2,26 +2,27 @@ local M = {}
function M:peek(job) function M:peek(job)
local limit = job.area.h local limit = job.area.h
local files, bound, err = self.list_archive({ "-p", tostring(job.file.path) }, job.skip, limit) local files, err = self.list_archive({ "-p", tostring(job.file.path) }, job.skip, limit)
local first = (#files == 1 and files[1]) or (#files == 0 and M.list_if_only_one(job.file.path)) local first = (#files == 1 and files[1]) or (#files == 0 and M.list_if_only_one(job.file.path))
if first and M.should_decompress_tar(first) then if first and M.should_decompress_tar(first) then
files, bound, err = self.list_compressed_tar({ "-p", tostring(job.file.path) }, job.skip, limit) files, err = self.list_compressed_tar({ "-p", tostring(job.file.path) }, job.skip, limit)
end end
if err then if err then
return ya.preview_widget(job, err) return ya.preview_widget(job, err)
elseif job.skip > 0 and bound < job.skip + limit then elseif job.skip > 0 and #files < job.skip + limit then
return ya.emit("peek", { math.max(0, bound - limit), only_if = job.file.url, upper_bound = true }) return ya.emit("peek", { math.max(0, #files - limit), only_if = job.file.url, upper_bound = true })
elseif #files == 0 then elseif #files == 0 then
files = { { path = job.file.url.stem, size = 0, packed_size = 0, attr = "" } } files = { M.make_file { path = job.file.url.stem } }
end end
local left, right = {}, {} local left, right = {}, {}
for _, f in ipairs(files) do for i = job.skip + 1, #files do
local f = files[i]
local icon = File({ local icon = File({
url = Url(f.path), url = Url(f.path),
cha = Cha { mode = tonumber(f.attr:sub(1, 1) == "D" and "40700" or "100644", 8) }, cha = Cha { mode = tonumber(f.is_dir and "40700" or "100644", 8) },
}):icon() }):icon()
if f.size > 0 then if f.size > 0 then
@ -37,8 +38,9 @@ function M:peek(job)
end end
left[#left] = ui.Line { left[#left] = ui.Line {
string.rep("", f.depth),
left[#left], left[#left],
ui.truncate(f.path, { ui.truncate(f.path.name, {
rtl = true, rtl = true,
max = math.max(0, job.area.w - ui.width(left[#left]) - ui.width(right[#right])), max = math.max(0, job.area.w - ui.width(left[#left]) - ui.width(right[#right])),
}), }),
@ -104,18 +106,17 @@ end
---@param skip integer ---@param skip integer
---@param limit integer ---@param limit integer
---@return table files ---@return table files
---@return integer bound
---@return Error? err ---@return Error? err
function M.list_archive(args, skip, limit) function M.list_archive(args, skip, limit)
local child = M.spawn_7z { "l", "-ba", "-slt", "-sccUTF-8", table.unpack(args) } local child = M.spawn_7z { "l", "-ba", "-slt", "-sccUTF-8", table.unpack(args) }
if not child then if not child then
return {}, 0, Err("Failed to start either `7zz` or `7z`. Do you have 7-zip installed?") return {}, Err("Failed to start either `7zz` or `7z`. Do you have 7-zip installed?")
end end
local files, bound, err = M.parse_7z_slt(child, skip, limit) local files, err = M.parse_7z_slt(child, skip, limit)
child:start_kill() child:start_kill()
return files, bound, err return files, err
end end
---List files in a compressed tarball ---List files in a compressed tarball
@ -123,7 +124,6 @@ end
---@param skip integer ---@param skip integer
---@param limit integer ---@param limit integer
---@return table files ---@return table files
---@return integer bound
---@return Error? err ---@return Error? err
function M.list_compressed_tar(args, skip, limit) function M.list_compressed_tar(args, skip, limit)
local src, dst = M.spawn_7z_piped( local src, dst = M.spawn_7z_piped(
@ -131,22 +131,24 @@ function M.list_compressed_tar(args, skip, limit)
{ "l", "-ba", "-slt", "-ttar", "-sccUTF-8", "-si" } { "l", "-ba", "-slt", "-ttar", "-sccUTF-8", "-si" }
) )
if not dst then if not dst then
return {}, 0, Err("Failed to start either `7zz` or `7z`. Do you have 7-zip installed?") return {}, Err("Failed to start either `7zz` or `7z`. Do you have 7-zip installed?")
end end
local files, bound, err = M.parse_7z_slt(dst, skip, limit) local files, err = M.parse_7z_slt(dst, skip, limit)
src:start_kill() src:start_kill()
dst:start_kill() dst:start_kill()
return files, bound, err return files, err
end end
---@param path Path
---@return table?
function M.list_if_only_one(path) function M.list_if_only_one(path)
-- For certain compressed tarballs (e.g. .tar.xz), -- For certain compressed tarballs (e.g. .tar.xz),
-- 7-zip doesn't print a .tar file if -slt is specified, so we are not doing that here -- 7-zip doesn't print a .tar file if -slt is specified, so we are not doing that here
local child = M.spawn_7z { "l", "-ba", "-sccUTF-8", "-p", tostring(path) } local child = M.spawn_7z { "l", "-ba", "-sccUTF-8", "-p", tostring(path) }
if not child then if not child then
return false return
end end
local files = {} local files = {}
@ -155,7 +157,7 @@ function M.list_if_only_one(path)
if event == 0 then if event == 0 then
local attr, size, packed_size, path = next:sub(20):match("([^ ]+) +(%d+) +(%d+) +([^\r\n]+)") local attr, size, packed_size, path = next:sub(20):match("([^ ]+) +(%d+) +(%d+) +([^\r\n]+)")
if path then if path then
files[#files + 1] = { path = path, size = tonumber(size), packed_size = tonumber(packed_size), attr = attr } files[#files + 1] = M.make_file { path = path, size = size, packed_size = packed_size, attr = attr }
end end
elseif event ~= 1 then elseif event ~= 1 then
break break
@ -170,7 +172,7 @@ end
---List metadata of an archive ---List metadata of an archive
---@param args table ---@param args table
---@return string|nil type ---@return string? type
---@return integer code ---@return integer code
--- 0: success --- 0: success
--- 1: failed to spawn --- 1: failed to spawn
@ -213,8 +215,18 @@ function M.is_encrypted(s) return s:find(" Wrong password", 1, true) end
function M.is_tar(path) return M.list_meta { "-p", tostring(path) } == "tar" end function M.is_tar(path) return M.list_meta { "-p", tostring(path) } == "tar" end
function M.make_file(t)
t = t or {}
t.path = type(t.path or "") == "string" and Path.os(t.path or "") or t.path
t.size = tonumber(t.size) or 0
t.packed_size = tonumber(t.packed_size) or 0
t.attr = t.attr or ""
t.folder = t.folder or ""
return t
end
function M.should_decompress_tar(file) function M.should_decompress_tar(file)
return file.packed_size <= 1024 * 1024 * 1024 and file.path:lower():find(".+%.tar$") ~= nil return file.packed_size <= 1024 * 1024 * 1024 and (file.path.ext or ""):lower() == "tar"
end end
-- Parse the output of a "7z l -slt" command. -- Parse the output of a "7z l -slt" command.
@ -223,11 +235,10 @@ end
---@param skip integer ---@param skip integer
---@param limit integer ---@param limit integer
---@return table files ---@return table files
---@return integer bound
---@return Error? err ---@return Error? err
function M.parse_7z_slt(child, skip, limit) function M.parse_7z_slt(child, skip, limit)
local i, files, err = 0, { { path = "", size = 0, packed_size = 0, attr = "" } }, nil local files, parents, err = { M.make_file() }, {}, nil
local key, value, stderr = "", "", {} local key, value, empty, stderr = "", "", Path.os(""), {}
repeat repeat
local next, event = child:read_line() local next, event = child:read_line()
if event == 1 and M.is_encrypted(next) then if event == 1 and M.is_encrypted(next) then
@ -241,36 +252,66 @@ function M.parse_7z_slt(child, skip, limit)
end end
if next == "\n" or next == "\r\n" then if next == "\n" or next == "\r\n" then
i = i + 1 if files[#files].path ~= empty then
if files[#files].path ~= "" then M.treelize(files, parents)
files[#files + 1] = { path = "", size = 0, packed_size = 0, attr = "" } files[#files + 1] = M.make_file()
end end
goto continue goto continue
elseif i < skip then
goto continue
end end
key, value = next:match("^(%u[%a ]+) = (.-)[\r\n]+") key, value = next:match("^(%u[%a ]+) = (.-)[\r\n]+")
if key == "Path" then if key == "Path" then
files[#files].path = value files[#files].path = Path.os(value)
elseif key == "Size" then elseif key == "Size" then
files[#files].size = tonumber(value) or 0 files[#files].size = tonumber(value) or 0
elseif key == "Packed Size" then elseif key == "Packed Size" then
files[#files].packed_size = tonumber(value) or 0 files[#files].packed_size = tonumber(value) or 0
elseif key == "Attributes" then elseif key == "Attributes" then
files[#files].attr = value files[#files].attr = value
elseif key == "Folder" then
files[#files].folder = value
end end
::continue:: ::continue::
until i >= skip + limit until #files > skip + limit
if files[#files].path == "" then if files[#files].path == empty then
files[#files] = nil files[#files] = nil
else
M.treelize(files, parents)
end end
if #stderr ~= 0 then if #stderr ~= 0 then
err = Err("7-zip errored out while listing files, stderr: %s", table.concat(stderr, "\n")) err = Err("7-zip errored out while listing files, stderr: %s", table.concat(stderr, "\n"))
end end
return files, i, err return files, err
end
---Convert a flat list of files into a tree structure
---@param files table
---@param parents Path[]
function M.treelize(files, parents)
local f = table.remove(files)
while #parents > 0 and not f.path:starts_with(parents[#parents]) do
parents[#parents] = nil
end
local buf, it = {}, f.path.parent
while it and it ~= parents[#parents] do
buf[#buf + 1], it = it, it.parent
end
for i = #buf, 1, -1 do
files[#files + 1] = M.make_file { path = buf[i], depth = #parents, is_dir = true }
parents[#parents + 1] = buf[i]
end
f.depth = #parents
f.is_dir = f.folder == "+" or f.attr:sub(1, 1) == "D"
files[#files + 1] = f
if f.is_dir then
parents[#parents + 1] = f.path
end
end end
return M return M