mirror of
https://github.com/folke/snacks.nvim
synced 2025-12-23 08:47:57 +00:00
290 lines
9.2 KiB
Lua
290 lines
9.2 KiB
Lua
local M = {}
|
|
|
|
---@class snacks.picker.diff.Config: snacks.picker.proc.Config
|
|
---@field cmd? string optional since diff can be passed as string
|
|
---@field group? boolean Group hunks by file
|
|
---@field diff? string|number diff string or buffer number
|
|
---@field annotations? snacks.diff.Annotation[]
|
|
|
|
---@class snacks.picker.diff.hunk.Pos
|
|
---@field line number
|
|
---@field count number
|
|
|
|
---@class snacks.picker.Diff
|
|
---@field header string[]
|
|
---@field blocks snacks.picker.diff.Block[]
|
|
|
|
---@class snacks.picker.diff.Hunk
|
|
---@field diff string[]
|
|
---@field line number
|
|
---@field context? string
|
|
---@field left snacks.picker.diff.hunk.Pos old (normal) /ours (merge)
|
|
---@field right snacks.picker.diff.hunk.Pos new (normal) /working (merge)
|
|
---@field parents? snacks.picker.diff.hunk.Pos[] theirs (merge)
|
|
|
|
---@class snacks.picker.diff.Block
|
|
---@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
|
|
function M.diff(opts, ctx)
|
|
opts = opts or {}
|
|
local lines = {} ---@type string[]
|
|
local finder ---@type snacks.picker.finder.result?
|
|
|
|
do
|
|
if opts.cmd then
|
|
finder = require("snacks.picker.source.proc").proc(opts, ctx)
|
|
else
|
|
local diff = opts.diff
|
|
if not diff and vim.bo.filetype == "diff" then
|
|
diff = 0
|
|
end
|
|
if type(diff) == "number" then
|
|
lines = vim.api.nvim_buf_get_lines(diff, 0, -1, false)
|
|
elseif type(diff) == "string" then
|
|
lines = vim.split(diff, "\n", { plain = true })
|
|
else
|
|
Snacks.notify.error("snacks.picker.diff: opts.diff must be a string or buffer number")
|
|
return {}
|
|
end
|
|
end
|
|
end
|
|
|
|
local cwd = opts.cwd or ctx.filter.cwd
|
|
return function(cb)
|
|
if finder then
|
|
finder(function(proc_item)
|
|
lines[#lines + 1] = proc_item.text
|
|
end)
|
|
end
|
|
|
|
---@param file string
|
|
---@param line? number
|
|
---@param diff string[]
|
|
---@param block snacks.picker.diff.Block
|
|
local function add(file, line, diff, block)
|
|
line = line or 1
|
|
cb({
|
|
text = file .. ":" .. line,
|
|
diff = table.concat(diff, "\n"),
|
|
file = file,
|
|
cwd = cwd,
|
|
rename = block.rename and block.rename.from or nil,
|
|
annotations = opts.annotations,
|
|
block = block,
|
|
pos = { line, 0 },
|
|
})
|
|
end
|
|
|
|
local diff = M.parse(lines)
|
|
for _, block in ipairs(diff.blocks) do
|
|
local diffs = {} ---@type string[]
|
|
for _, h in ipairs(block.hunks) do
|
|
if opts.group then
|
|
vim.list_extend(diffs, h.diff)
|
|
else
|
|
add(block.file, h.line, vim.list_extend(vim.deepcopy(block.header), h.diff), block)
|
|
end
|
|
end
|
|
if opts.group or #block.hunks == 0 then
|
|
local line = block.hunks[1] and block.hunks[1].line or 1
|
|
add(block.file, line, vim.list_extend(vim.deepcopy(block.header), diffs), block)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
---@param lines string[]
|
|
function M.parse(lines)
|
|
local hunk ---@type snacks.picker.diff.Hunk?
|
|
local block ---@type snacks.picker.diff.Block?
|
|
local ret = {} ---@type snacks.picker.diff.Block[]
|
|
local header = {} ---@type string[]
|
|
|
|
---@param file? string
|
|
---@param strip_prefix? boolean
|
|
---@return string?
|
|
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
|
|
return file:sub(#s + 2)
|
|
end
|
|
end
|
|
return file
|
|
end
|
|
end
|
|
|
|
local function emit()
|
|
if block and hunk then
|
|
hunk = nil
|
|
elseif not block then
|
|
return
|
|
end
|
|
for _, line in ipairs(block.header) do
|
|
if line:find("^%-%-%- ") then
|
|
block.left = norm(line:sub(5))
|
|
elseif line:find("^%+%+%+ ") then
|
|
block.right = norm(line:sub(5))
|
|
elseif line:find("^rename from") then
|
|
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.rename = block.rename or {}
|
|
block.right = norm(line:match("^rename to (.*)"), false)
|
|
block.rename.to = block.right
|
|
elseif line:find("^copy from") then
|
|
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.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.new = line:match("^new file mode (.*)")
|
|
elseif line:find("^deleted file mode") then
|
|
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 ""
|
|
if not block.right and not block.left and first:find("^diff") then
|
|
-- no left/right so for sure no rename.
|
|
-- this means the diff header is for the same file
|
|
if first:find("^diff %-%-cc") then
|
|
block.left = norm(first:match("^diff %-%-cc (.+)$"))
|
|
block.right = block.left
|
|
else
|
|
first = first:gsub("^diff ", ""):gsub("^%s*%-%S+%s*", "") --[[@as string]]
|
|
local idx = 1
|
|
while idx <= #first do
|
|
local s = first:find(" ", idx, true)
|
|
if not s then
|
|
break
|
|
end
|
|
idx = s + 1
|
|
local l = norm(first:sub(1, s - 1))
|
|
local r = norm(first:sub(s + 1))
|
|
if l == r then
|
|
block.left = l
|
|
block.right = r
|
|
break
|
|
end
|
|
end
|
|
end
|
|
end
|
|
block.file = block.right or block.left or block.file
|
|
table.sort(block.hunks, function(a, b)
|
|
return a.line < b.line
|
|
end)
|
|
ret[#ret + 1] = block
|
|
block = nil
|
|
end
|
|
|
|
local with_diff_header = false
|
|
|
|
for _, text in ipairs(lines) do
|
|
if not block and text:find("^%s*$") then
|
|
-- Ignore empty lines before a diff block
|
|
elseif text:find("^diff") or (not with_diff_header and text:find("^%-%-%- ") and (not block or hunk)) then
|
|
with_diff_header = with_diff_header or text:find("^diff") == 1
|
|
emit()
|
|
block = {
|
|
file = "", --file or "unknown",
|
|
header = { text },
|
|
hunks = {},
|
|
}
|
|
elseif text:find("@@", 1, true) == 1 and block then
|
|
-- Hunk header
|
|
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
|
|
Snacks.notify.error("Invalid hunk header: " .. text, { title = "Snacks Picker Diff" })
|
|
end
|
|
elseif hunk then
|
|
-- Hunk body
|
|
hunk.diff[#hunk.diff + 1] = text
|
|
elseif block then
|
|
block.header[#block.header + 1] = text
|
|
elseif #ret == 0 then
|
|
header[#header + 1] = text
|
|
else
|
|
Snacks.notify.error("Unexpected line: " .. text, { title = "Snacks Picker Diff" })
|
|
end
|
|
end
|
|
emit()
|
|
---@type snacks.picker.Diff
|
|
return { blocks = ret, header = header }
|
|
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
|