Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/):

### Added

- Tree view for the preset archive previewer ([#3525])
- Support compressed tarballs (`.tar.gz`, `.tar.bz2`, etc.) in the preset archive previewer ([#3518])
- New `Path.os()` API creates an OS-native `Path` ([#3541])

Expand Down Expand Up @@ -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
[#3514]: https://github.com/sxyazi/yazi/pull/3514
[#3518]: https://github.com/sxyazi/yazi/pull/3518
[#3525]: https://github.com/sxyazi/yazi/pull/3525
[#3532]: https://github.com/sxyazi/yazi/pull/3532
[#3540]: https://github.com/sxyazi/yazi/pull/3540
[#3541]: https://github.com/sxyazi/yazi/pull/3541
20 changes: 10 additions & 10 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ lto = false
debug = false

[workspace.dependencies]
ansi-to-tui = "8.0.0"
ansi-to-tui = "8.0.1"
anyhow = "1.0.100"
base64 = "0.22.1"
bitflags = { version = "2.10.0", features = [ "serde" ] }
Expand Down Expand Up @@ -59,7 +59,7 @@ thiserror = "2.0.17"
tokio = { version = "1.49.0", features = [ "full" ] }
tokio-stream = "0.1.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" ] }
twox-hash = { version = "2.1.2", default-features = false, features = [ "std", "random", "xxhash3_128" ] }
typed-path = "0.12.0"
Expand Down
2 changes: 1 addition & 1 deletion cspell.json
Original file line number Diff line number Diff line change
@@ -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"}
3 changes: 1 addition & 2 deletions yazi-fs/src/mounts/partition.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,7 @@ pub struct Partition {

impl Partition {
// Match mount types that do not reliably emit change notifications, or do not
// update directory metadata on changes, and should be refreshed frequently /
// heuristically.
// update directory metadata on changes, and should be refreshed frequently.
pub fn heuristic(&self) -> bool {
let b: &[u8] = self.fstype.as_ref().map_or(b"", |s| s.as_encoded_bytes());
matches!(b, b"exfat" | b"fuse.rclone")
Expand Down
5 changes: 2 additions & 3 deletions yazi-fs/src/mounts/partitions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,11 @@ impl Partitions {
pub fn heuristic(&self, _cha: Cha) -> bool {
#[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())
Copy link

Copilot AI Jan 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The logic has been inverted from is_none_or to is_some_and, which changes the behavior. Previously, this returned true if there was no matching partition OR if the partition requires heuristic polling. Now it returns true only if there IS a matching partition AND it requires heuristic polling. This means directories on unmounted/unknown filesystems will no longer use heuristic polling, which could be a breaking behavioral change. Please verify this is the intended behavior.

Suggested change
self.by_dev(_cha.dev).is_some_and(|p| p.heuristic())
self.by_dev(_cha.dev).is_none_or(|p| p.heuristic())

Copilot uses AI. Check for mistakes.
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
// For now, assume other targets update directory stat data correctly & do not
// need heuristic polling.
// For now, assume other targets update directory stat data correctly
false
}
}
Expand Down
105 changes: 73 additions & 32 deletions yazi-plugin/preset/plugins/archive.lua
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,27 @@ local M = {}

function M:peek(job)
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))
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

if err then
return ya.preview_widget(job, err)
elseif job.skip > 0 and bound < job.skip + limit then
return ya.emit("peek", { math.max(0, bound - limit), only_if = job.file.url, upper_bound = true })
elseif job.skip > 0 and #files < job.skip + limit then
return ya.emit("peek", { math.max(0, #files - limit), only_if = job.file.url, upper_bound = true })
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

local left, right = {}, {}
for _, f in ipairs(files) do
for i = job.skip + 1, #files do
local f = files[i]
local icon = File({
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()

if f.size > 0 then
Expand All @@ -37,8 +38,9 @@ function M:peek(job)
end

left[#left] = ui.Line {
string.rep(" │", f.depth),
left[#left],
ui.truncate(f.path, {
ui.truncate(f.path.name, {
rtl = true,
max = math.max(0, job.area.w - ui.width(left[#left]) - ui.width(right[#right])),
}),
Expand Down Expand Up @@ -104,49 +106,49 @@ end
---@param skip integer
---@param limit integer
---@return table files
---@return integer bound
---@return Error? err
function M.list_archive(args, skip, limit)
local child = M.spawn_7z { "l", "-ba", "-slt", "-sccUTF-8", table.unpack(args) }
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

local files, bound, err = M.parse_7z_slt(child, skip, limit)
local files, err = M.parse_7z_slt(child, skip, limit)
child:start_kill()

return files, bound, err
return files, err
end

---List files in a compressed tarball
---@param args table
---@param skip integer
---@param limit integer
---@return table files
---@return integer bound
---@return Error? err
function M.list_compressed_tar(args, skip, limit)
local src, dst = M.spawn_7z_piped(
{ "x", "-so", table.unpack(args) },
{ "l", "-ba", "-slt", "-ttar", "-sccUTF-8", "-si" }
)
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

local files, bound, err = M.parse_7z_slt(dst, skip, limit)
local files, err = M.parse_7z_slt(dst, skip, limit)
src:start_kill()
dst:start_kill()

return files, bound, err
return files, err
end

---@param path Path
---@return table?
function M.list_if_only_one(path)
-- 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
local child = M.spawn_7z { "l", "-ba", "-sccUTF-8", "-p", tostring(path) }
if not child then
return false
return
end

local files = {}
Expand All @@ -155,7 +157,7 @@ function M.list_if_only_one(path)
if event == 0 then
local attr, size, packed_size, path = next:sub(20):match("([^ ]+) +(%d+) +(%d+) +([^\r\n]+)")
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
elseif event ~= 1 then
break
Expand All @@ -170,7 +172,7 @@ end

---List metadata of an archive
---@param args table
---@return string|nil type
---@return string? type
---@return integer code
--- 0: success
--- 1: failed to spawn
Expand Down Expand Up @@ -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.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)
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

-- Parse the output of a "7z l -slt" command.
Expand All @@ -223,11 +235,10 @@ end
---@param skip integer
---@param limit integer
---@return table files
---@return integer bound
---@return Error? err
function M.parse_7z_slt(child, skip, limit)
local i, files, err = 0, { { path = "", size = 0, packed_size = 0, attr = "" } }, nil
local key, value, stderr = "", "", {}
local files, parents, err = { M.make_file() }, {}, nil
local key, value, empty, stderr = "", "", Path.os(""), {}
repeat
local next, event = child:read_line()
if event == 1 and M.is_encrypted(next) then
Expand All @@ -241,36 +252,66 @@ function M.parse_7z_slt(child, skip, limit)
end

if next == "\n" or next == "\r\n" then
i = i + 1
if files[#files].path ~= "" then
files[#files + 1] = { path = "", size = 0, packed_size = 0, attr = "" }
if files[#files].path ~= empty then
M.treelize(files, parents)
files[#files + 1] = M.make_file()
end
goto continue
elseif i < skip then
goto continue
end

key, value = next:match("^(%u[%a ]+) = (.-)[\r\n]+")
if key == "Path" then
files[#files].path = value
files[#files].path = Path.os(value)
elseif key == "Size" then
files[#files].size = tonumber(value) or 0
elseif key == "Packed Size" then
files[#files].packed_size = tonumber(value) or 0
elseif key == "Attributes" then
files[#files].attr = value
elseif key == "Folder" then
files[#files].folder = value
end

::continue::
until i >= skip + limit
until #files > skip + limit

Copy link

Copilot AI Jan 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the last file in the stream doesn't end with a blank line, it won't go through the treelize function (which is only called at line 254 when a blank line is encountered). This means the last file may not have the depth and is_dir fields set, which will cause issues when rendering (line 41 uses f.depth, line 25 uses f.is_dir). Consider calling treelize for the last file if it has a non-empty path before checking at line 276.

Suggested change
-- Ensure the last file is treelized if it represents a real entry
if files[#files].path ~= empty then
M.treelize(files, parents)
end

Copilot uses AI. Check for mistakes.
if files[#files].path == "" then
if files[#files].path == empty then
files[#files] = nil
else
M.treelize(files, parents)
end

if #stderr ~= 0 then
err = Err("7-zip errored out while listing files, stderr: %s", table.concat(stderr, "\n"))
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

return M
Loading