From 83674f19b42dd2c3c0fb2b49edcdee379e745205 Mon Sep 17 00:00:00 2001 From: itsvyle <65409906+itsvyle@users.noreply.github.com> Date: Thu, 8 Jan 2026 08:35:29 +0100 Subject: [PATCH] feat: support compressed tarballs (`.tar.gz`, `.tar.bz2`, etc.) in the preset archive previewer (#3518) Co-authored-by: sxyazi --- CHANGELOG.md | 7 +- yazi-plugin/preset/plugins/archive.lua | 189 +++++++++++++++++++------ 2 files changed, 152 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e2fec647..9209e189 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/): ## [Unreleased] +### Added + +- Support compressed tarballs (`.tar.gz`, `.tar.bz2`, etc.) in the preset archive previewer ([#3518]) + ### Fixed - Account for URL covariance in `Url:join()` ([#3514]) @@ -367,7 +371,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/): ### Fixed -- Correctly handle CRLF on Windows in preset `archive` and `json` plugins ([#2017]) +- Correctly handle CRLF on Windows in preset archive and JSON plugins ([#2017]) - Failed to parse certain image dimensions for Überzug++ backend ([#2020]) - Disable passthrough when the user launches Yazi in Neovim inside tmux ([#2014]) @@ -1588,3 +1592,4 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/): [#3482]: https://github.com/sxyazi/yazi/pull/3482 [#3494]: https://github.com/sxyazi/yazi/pull/3494 [#3514]: https://github.com/sxyazi/yazi/pull/3514 +[#3518]: https://github.com/sxyazi/yazi/pull/3518 diff --git a/yazi-plugin/preset/plugins/archive.lua b/yazi-plugin/preset/plugins/archive.lua index 9e075c02..34ab1b51 100644 --- a/yazi-plugin/preset/plugins/archive.lua +++ b/yazi-plugin/preset/plugins/archive.lua @@ -2,14 +2,19 @@ local M = {} function M:peek(job) local limit = job.area.h - local files, bound, err = self.list_files({ "-p", tostring(job.file.path) }, job.skip, limit) + local files, bound, 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) + 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 #files == 0 then - files = { { path = job.file.url.stem, size = 0, attr = "" } } + files = { { path = job.file.url.stem, size = 0, packed_size = 0, attr = "" } } end local left, right = {}, {} @@ -66,6 +71,34 @@ function M.spawn_7z(args) return child, last_err end +-- Spawn a 7z instance which pipes a "7z {src_args}" into a "7z {dst_args}" +-- Used for previewing compressed tarballs, by doing "7z x -so .. | 7z l -si .." +function M.spawn_7z_piped(src_args, dst_args) + local last_err = nil + local try = function(name) + local src, err = Command(name):arg(src_args):stdout(Command.PIPED):stderr(Command.PIPED):spawn() + if not src then + last_err = err + return src + end + local dst, err = + Command(name):arg(dst_args):stdin(src:take_stdout()):stdout(Command.PIPED):stderr(Command.PIPED):spawn() + if not dst then + last_err = err + end + return src, dst + end + + local src, dst = try("7zz") + if not src then + src, dst = try("7z") + end + if not dst then + return ya.err("Failed to start either `7zz` or `7z`, error: " .. last_err) + end + return src, dst, last_err +end + ---List files in an archive ---@param args table ---@param skip integer @@ -73,56 +106,66 @@ end ---@return table files ---@return integer bound ---@return Error? err -function M.list_files(args, skip, limit) +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?") end - local i, files, err = 0, { { path = "", size = 0, attr = "" } }, nil - local key, value, stderr = "", "", {} - repeat - local next, event = child:read_line() - if event == 1 and M.is_encrypted(next) then - err = Err("File list in this archive is encrypted") - break - elseif event == 1 then - stderr[#stderr + 1] = next - goto continue - elseif event ~= 0 then - break - end - - if next == "\n" or next == "\r\n" then - i = i + 1 - if files[#files].path ~= "" then - files[#files + 1] = { path = "", size = 0, attr = "" } - end - goto continue - elseif i < skip then - goto continue - end - - key, value = next:match("^(%u%l+) = (.-)[\r\n]+") - if key == "Path" then - files[#files].path = value - elseif key == "Size" then - files[#files].size = tonumber(value) or 0 - elseif key == "Attributes" then - files[#files].attr = value - end - - ::continue:: - until i >= skip + limit + local files, bound, err = M.parse_7z_slt(child, skip, limit) child:start_kill() - if files[#files].path == "" then - files[#files] = nil + return files, bound, 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?") end - if #stderr ~= 0 then - err = Err("7-zip errored out while listing files, stderr: %s", table.concat(stderr, "\n")) + + local files, bound, err = M.parse_7z_slt(dst, skip, limit) + src:start_kill() + dst:start_kill() + + return files, bound, err +end + +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 + end + + local files = {} + while #files < 2 do + local next, event = child:read_line() + 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 } + end + elseif event ~= 1 then + break + end + end + + child:start_kill() + if #files == 1 then + return files[1] end - return files, i, err end ---List metadata of an archive @@ -170,4 +213,64 @@ 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.should_decompress_tar(file) + return file.packed_size <= 1024 * 1024 * 1024 and file.path:lower():find(".+%.tar$") ~= nil +end + +-- Parse the output of a "7z l -slt" command. +-- The caller is responsible for killing the child process right after the execution of this function +---@param child Child +---@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 = "", "", {} + repeat + local next, event = child:read_line() + if event == 1 and M.is_encrypted(next) then + err = Err("File list in this archive is encrypted") + break + elseif event == 1 then + stderr[#stderr + 1] = next + goto continue + elseif event ~= 0 then + break + 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 = "" } + 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 + 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 + end + + ::continue:: + until i >= skip + limit + + if files[#files].path == "" then + files[#files] = nil + 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 +end + return M