From 53d8012e5e4b2115ade2c15d040223ef97ffb05c Mon Sep 17 00:00:00 2001 From: Folke Lemaitre Date: Mon, 3 Nov 2025 21:50:49 +0100 Subject: [PATCH] fix(picker.diff): parse full diff including diff and hunk headers. Closes #2429 --- lua/snacks/picker/format.lua | 2 +- lua/snacks/picker/source/diff.lua | 108 +++++++++++++++++++++++------- 2 files changed, 83 insertions(+), 27 deletions(-) diff --git a/lua/snacks/picker/format.lua b/lua/snacks/picker/format.lua index c9b10477..2f499305 100644 --- a/lua/snacks/picker/format.lua +++ b/lua/snacks/picker/format.lua @@ -564,7 +564,7 @@ function M.git_status(item, picker) local status = item.status if not status and item.block then local block = item.block ---@type snacks.picker.diff.Block - status = ({ new = "A", delete = "D", rename = "R", copy = "C" })[block.type] or "M" + status = block.new and "A" or block.delete and "D" or block.rename and "R" or block.copy and "C" or "M" status = block.unmerged and (status .. status) or item.staged and (status .. " ") or (" " .. status) elseif not status then return M.filename(item, picker) diff --git a/lua/snacks/picker/source/diff.lua b/lua/snacks/picker/source/diff.lua index 0b0c1c44..b89bf6ea 100644 --- a/lua/snacks/picker/source/diff.lua +++ b/lua/snacks/picker/source/diff.lua @@ -8,15 +8,26 @@ local M = {} ---@class snacks.picker.diff.Hunk ---@field diff string[] ---@field line number +---@field context? string +---@field left { line: number, count: number } old (normal) /ours (merge) +---@field right { line: number, count: number } new (normal) /working (merge) +---@field parents? { line: number, count: number }[] theirs (merge) ---@class snacks.picker.diff.Block ----@field type? "new"|"delete"|"rename"|"copy"|"mode" ---@field unmerged? boolean ---@field file string ---@field left? string ---@field right? string ---@field header string[] ---@field hunks snacks.picker.diff.Hunk[] +---@field mode? {from:string, to:string} +---@field copy? {from:string, to:string} +---@field rename? {from:string, to:string} +---@field delete? string (mode of deleted file) +---@field new? string (mode of new file) +---@field similarity? number +---@field dissimilarity? number +---@field index? {from:string, to:string, mode:string} ---@param opts? snacks.picker.diff.Config ---@type snacks.picker.finder @@ -63,7 +74,7 @@ function M.diff(opts, ctx) diff = table.concat(diff, "\n"), file = file, cwd = cwd, - rename = block.type == "rename" and block.left or nil, + rename = block.rename and block.rename.from or nil, block = block, pos = { line, 0 }, }) @@ -94,14 +105,18 @@ function M.parse(lines) local ret = {} ---@type snacks.picker.diff.Block[] ---@param file? string + ---@param strip_prefix? boolean ---@return string? - local function norm(file) + local function norm(file, strip_prefix) if file then file = file:gsub("\t.*$", "") -- remove tab and after file = file:gsub('^"(.-)"$', "%1") -- remove quotes if file == "/dev/null" then -- no file return end + if strip_prefix == false then + return file + end local prefix = { "a", "b", "i", "w", "c", "o", "old", "new" } for _, s in ipairs(prefix) do -- remove prefixes if file:sub(1, #s + 1) == s .. "/" then @@ -124,23 +139,40 @@ function M.parse(lines) elseif line:find("^%+%+%+ ") then block.right = norm(line:sub(5)) elseif line:find("^rename from") then - block.type = "rename" - block.left = norm(line:match("^rename from (.*)")) + block.rename = block.rename or {} + block.left = norm(line:match("^rename from (.*)"), false) + block.rename.from = block.left elseif line:find("^rename to") then - block.type = "rename" - block.right = norm(line:match("^rename to (.*)")) + block.rename = block.rename or {} + block.right = norm(line:match("^rename to (.*)"), false) + block.rename.to = block.right elseif line:find("^copy from") then - block.type = "copy" - block.left = norm(line:match("^copy from (.*)")) + block.copy = block.copy or {} + block.left = norm(line:match("^copy from (.*)"), false) + block.copy.from = block.left elseif line:find("^copy to") then - block.type = "copy" - block.right = norm(line:match("^copy to (.*)")) + block.copy = block.copy or {} + block.right = norm(line:match("^copy to (.*)"), false) + block.copy.to = block.right elseif line:find("^new file mode") then - block.type = "new" + block.new = line:match("^new file mode (.*)") elseif line:find("^deleted file mode") then - block.type = "delete" - elseif line:find("^old mode") or line:find("^new mode") then - block.type = "mode" + block.delete = line:match("^deleted file mode (.*)") + elseif line:find("^old mode") then + block.mode = block.mode or {} + block.mode.from = line:match("^old mode (.*)") + elseif line:find("^new mode") then + block.mode = block.mode or {} + block.mode.to = line:match("^new mode (.*)") + elseif line:find("^similarity index") then + local sim = line:match("^similarity index (%d+)%%") + block.similarity = tonumber(sim) or 0 + elseif line:find("^dissimilarity index") then + local dis = line:match("^dissimilarity index (%d+)%%") + block.dissimilarity = tonumber(dis) or 0 + elseif line:find("^index ") then + local from, to, mode = line:match("^index (%S+)%.%.(%S+)%s*(%d*)$") + block.index = { from = from, to = to, mode = mode ~= "" and mode or nil } end end local first = block.header[1] or "" @@ -191,29 +223,53 @@ function M.parse(lines) } elseif text:find("@@", 1, true) == 1 and block then -- Hunk header - local line = 1 - if text:find("@@@", 1, true) == 1 then - line = tonumber(text:match("^@@@ %-%d+,?%d* %-%d+,?%d* %+(%d+),?%d* @@@")) or 1 - block.unmerged = true + hunk = M.parse_hunk_header(text) + if hunk then + block.unmerged = block.unmerged or (hunk.parents ~= nil) or nil + block.hunks[#block.hunks + 1] = hunk else - line = tonumber(text:match("^@@ %-%d+,?%d* %+(%d+),?%d* @@")) or 1 + Snacks.notify.error("Invalid hunk header: " .. text, { title = "Snacks Picker Diff" }) end - hunk = { - line = line, - diff = { text }, - } - block.hunks[#block.hunks + 1] = hunk elseif hunk then -- Hunk body hunk.diff[#hunk.diff + 1] = text elseif block then block.header[#block.header + 1] = text else - Snacks.notify.error("unexpected line: " .. text, { title = "Snacks Picker Diff" }) + Snacks.notify.error("Unexpected line: " .. text, { title = "Snacks Picker Diff" }) end end emit() return ret end +---@param line string +function M.parse_hunk_header(line) + local count_start, inner, count_end, context = line:match("^(@+)%s*(.-)%s*(@+)%s*(.*)$") + if not count_start or not count_end or count_start ~= count_end or #count_start < 2 then + return + end + local ret = {} ---@type {line:number, count:number}[] + for _, part in ipairs(vim.split(inner, "%s+")) do + local l, c = part:match("^[%-+](%d+),?(%d*)$") + if not l then + return + end + ret[#ret + 1] = { line = tonumber(l) or 1, count = tonumber(c) or 1 } + end + if #ret ~= #count_start then + return + end + local right = table.remove(ret) + ---@type snacks.picker.diff.Hunk + return { + diff = { line }, + line = right and right.line or 1, + left = table.remove(ret, 1), + right = right, + parents = #ret > 0 and ret or nil, + context = context ~= "" and context or nil, + } +end + return M