diff --git a/lua/neogit/integrations/diffview.lua b/lua/neogit/integrations/diffview.lua index c7c3cfe67..ec142141c 100644 --- a/lua/neogit/integrations/diffview.lua +++ b/lua/neogit/integrations/diffview.lua @@ -10,6 +10,15 @@ local Watcher = require("neogit.watcher") local git = require("neogit.lib.git") local a = require("plenary.async") +vim.api.nvim_create_autocmd({ "FileType" }, { + pattern = { "DiffviewFiles", "DiffviewFileHistory" }, + callback = function(event) + vim.keymap.set("n", "q", function() + vim.cmd("DiffviewClose") + end, { buffer = event.buf, desc = "Close diffview" }) + end, +}) + local function get_local_diff_view(section_name, item_name, opts) local left = Rev(RevType.STAGE) local right = Rev(RevType.LOCAL) diff --git a/lua/neogit/lib/finder.lua b/lua/neogit/lib/finder.lua index 95c34c1e3..9c7f612e4 100644 --- a/lua/neogit/lib/finder.lua +++ b/lua/neogit/lib/finder.lua @@ -9,6 +9,41 @@ local function refocus_status_buffer() end end +--- Extract commit hash from formatted commit entry if applicable +---@param item string The selected item +---@return string The processed item (commit hash if it was a formatted commit, otherwise unchanged) +local function extract_commit_hash(item) + if item and item:match("^%x%x%x%x%x%x%x ") then + return item:match("^(%x+)") + end + return item +end + +--- Process selection items to extract commit hashes where applicable +---@param selection any The selection (string or table) +---@param allow_multi boolean Whether multi-selection is allowed +---@return any The processed selection +local function process_selection(selection, allow_multi) + if not selection then + return nil + end + + if allow_multi then + if type(selection) == "table" then + local processed = {} + for _, item in ipairs(selection) do + table.insert(processed, extract_commit_hash(item)) + end + return processed + else + return { extract_commit_hash(selection) } + end + else + local single_item = type(selection) == "table" and selection[1] or selection + return extract_commit_hash(single_item) + end +end + local copy_selection = function() local selection = require("telescope.actions.state").get_selected_entry() if selection ~= nil then @@ -35,7 +70,7 @@ local function telescope_mappings(on_select, allow_multi, refocus_status) local picker = action_state.get_current_picker(prompt_bufnr) if #picker:get_multi_selection() > 0 then for _, item in ipairs(picker:get_multi_selection()) do - table.insert(selection, item[1]) + table.insert(selection, extract_commit_hash(item[1])) end elseif action_state.get_selected_entry() ~= nil then local entry = action_state.get_selected_entry()[1] @@ -50,7 +85,7 @@ local function telescope_mappings(on_select, allow_multi, refocus_status) if navigate_up_level or input_git_refspec then table.insert(selection, prompt) else - table.insert(selection, entry) + table.insert(selection, extract_commit_hash(entry)) end else table.insert(selection, picker:_get_prompt()) @@ -140,16 +175,30 @@ local function fzf_actions(on_select, allow_multi, refocus_status) return { ["default"] = function(selected) - if allow_multi then - on_select(selected) + if not selected then + on_select(nil) + refresh() + return + end + + local processed = process_selection(selected, allow_multi) + if processed and (type(processed) ~= "table" or #processed > 0) and processed ~= "" then + on_select(processed) else - on_select(selected[1]) + on_select(nil) end refresh() end, - ["esc"] = close_action, - ["ctrl-c"] = close_action, - ["ctrl-q"] = close_action, + ["ctrl-c"] = function() + vim.schedule(function() + close_action() + end) + end, + ["ctrl-q"] = function() + vim.schedule(function() + close_action() + end) + end, } end @@ -189,7 +238,7 @@ local function snacks_confirm(on_select, allow_multi, refocus_status) picker:close() elseif #picker_selected > 1 then for _, item in ipairs(picker_selected) do - table.insert(selection, item.text) + table.insert(selection, extract_commit_hash(item.text)) end else local entry = item.text @@ -201,7 +250,11 @@ local function snacks_confirm(on_select, allow_multi, refocus_status) or prompt:match("@") or prompt:match(":") - table.insert(selection, (navigate_up_level or input_git_refspec) and prompt or entry) + if navigate_up_level or input_git_refspec then + table.insert(selection, prompt) + else + table.insert(selection, extract_commit_hash(entry)) + end end if selection and selection[1] and selection[1] ~= "" then @@ -272,6 +325,15 @@ end ---@field layout_strategy string ---@field sorting_strategy string ---@field theme string +---@field item_type string|nil + +---@alias ItemType +---| "branch" +---| "commit" +---| "tag" +---| "any_ref" +---| "stash" +---| "file" ---@class Finder ---@field opts table @@ -304,9 +366,100 @@ function Finder:add_entries(entries) return self end +---Generate entries for any_ref item type (includes symbolic refs like HEAD) +---@return table +local function get_any_ref_entries() + local git = require("neogit.lib.git") + local entries = {} + + -- Add symbolic refs like HEAD, ORIG_HEAD, etc. + local heads = git.refs.heads() + vim.list_extend(entries, heads) + + -- Add branches + local branches = git.refs.list_branches() + vim.list_extend(entries, branches) + + -- Add tags + local tags = git.refs.list_tags() + vim.list_extend(entries, tags) + + -- Add commits with proper formatting (sha + title) for better searchability + local commits = git.log.list() + for _, commit in ipairs(commits) do + table.insert(entries, string.format("%s %s", commit.oid:sub(1, 7), commit.subject or "")) + end + + return entries +end + +---Auto-populate entries based on item_type if no entries are provided +---@param item_type string|nil +---@return table +local function get_entries_for_item_type(item_type) + if not item_type then + return {} + end + + local git = require("neogit.lib.git") + + if item_type == "branch" then + return git.refs.list_branches() + elseif item_type == "commit" then + local commits = git.log.list() + local formatted_commits = {} + for _, commit in ipairs(commits) do + table.insert(formatted_commits, string.format("%s %s", commit.oid:sub(1, 7), commit.subject or "")) + end + return formatted_commits + elseif item_type == "tag" then + return git.refs.list_tags() + elseif item_type == "any_ref" then + return get_any_ref_entries() + elseif item_type == "stash" then + return git.stash.list() + elseif item_type == "file" then + return git.files.all() + end + + return {} +end + +---Try to use specialized picker provider +---@param on_select fun(item: any|nil) +---@return boolean true if specialized picker was used +function Finder:try_specialized_picker(on_select) + if not self.opts.item_type then + return false + end + + -- Try fzf-lua specialized picker + local fzf_provider = require("neogit.lib.finder.providers.fzf_lua") + if fzf_provider.is_available() then + if fzf_provider.try_specialized_picker(self.opts.item_type, self.opts, on_select) then + return true + end + end + + -- TODO: Add other providers (telescope, snacks, etc.) + + return false +end + ---Engages finder and invokes `on_select` with the item or items, or nil if aborted ---@param on_select fun(item: any|nil) function Finder:find(on_select) + -- Auto-populate entries if none provided and item_type is specified + if #self.entries == 0 and self.opts.item_type then + self:add_entries(get_entries_for_item_type(self.opts.item_type)) + end + + -- Try specialized picker first + if self:try_specialized_picker(on_select) then + return + end + + -- Fall back to generic picker if config.check_integration("telescope") then local pickers = require("telescope.pickers") local finders = require("telescope.finders") @@ -345,7 +498,14 @@ function Finder:find(on_select) }) elseif config.check_integration("mini_pick") then local mini_pick = require("mini.pick") - mini_pick.start { source = { items = self.entries, choose = on_select } } + mini_pick.start { + source = { + items = self.entries, + choose = function(item) + on_select(extract_commit_hash(item)) + end, + }, + } elseif config.check_integration("snacks") then local snacks_picker = require("snacks.picker") local confirm, on_close = snacks_confirm(on_select, self.opts.allow_multi, self.opts.refocus_status) @@ -371,7 +531,8 @@ function Finder:find(on_select) end, }, function(item) vim.schedule(function() - on_select(self.opts.allow_multi and { item } or item) + local processed = process_selection(item, self.opts.allow_multi) + on_select(processed) if self.opts.refocus_status then refocus_status_buffer() diff --git a/lua/neogit/lib/finder/providers/fzf_lua.lua b/lua/neogit/lib/finder/providers/fzf_lua.lua new file mode 100644 index 000000000..1db7c5b68 --- /dev/null +++ b/lua/neogit/lib/finder/providers/fzf_lua.lua @@ -0,0 +1,519 @@ +local config = require("neogit.config") +local git = require("neogit.lib.git") + +local M = {} + +---Check if fzf-lua is available +---@return boolean +function M.is_available() + return config.check_integration("fzf_lua") +end + +---Get fzf-lua module if available +---@return table|nil +local function get_fzf_lua() + if M.is_available() then + local fzf_ok, fzf_lua_mod = pcall(require, "fzf-lua") + if fzf_ok then + return fzf_lua_mod + end + end + return nil +end + +---Clean branch name from fzf-lua git_branches output +---@param name string +---@return string|nil +local function clean_branch_name(name) + if not name or name == "" then + return nil + end + if name:match("^%s*%*%s*%(HEAD detached .*%)") then + return "HEAD" + end + name = name:gsub("^%s*%*%s*", "") + name = (name:match("%s*->%s*(.+)$") or name):gsub("^%s+", ""):gsub("%s+$", "") + return name ~= "" and name or nil +end + +---Extract commit SHA from fzf-lua git_commits output +---@param entry_string string +---@return string|nil +local function extract_commit_sha_from_picker_entry(entry_string) + if not entry_string or entry_string == "" then + return nil + end + -- Extract the commit hash from the beginning of the line + local hash = entry_string:match("^%s*([a-f0-9]+)") + if hash and #hash >= 7 then + return hash + end + -- Fallback to the original string if no hash found, but ensure it's not empty + local trimmed = entry_string:gsub("^%s+", ""):gsub("%s+$", "") + return trimmed ~= "" and trimmed or nil +end + +---Process stash entry from fzf-lua git_stash output +---@param selected_entry_text string +---@return string|nil +local function process_stash_entry(selected_entry_text) + if not selected_entry_text or selected_entry_text == "" then + return nil + end + local stash_ref = selected_entry_text:match("^(stash@{%d+})") + if stash_ref then + return stash_ref + end + -- Fallback to the original string if it doesn't match the pattern, but ensure it's not empty + local trimmed = selected_entry_text:gsub("^%s+", ""):gsub("%s+$", "") + return trimmed ~= "" and trimmed or nil +end + +---Sanitize tag name from fzf-lua git_tags output +---@param name string +---@return string|nil +local function sanitize_tag_name_for_picker(name) + if not name or name == "" then + return nil + end + local tag_name = name:match("^%s*([^%s]+)") + if tag_name and tag_name ~= "" then + return tag_name + end + -- Fallback to the original string if no match, but ensure it's not empty + local trimmed = name:gsub("^%s+", ""):gsub("%s+$", "") + return trimmed ~= "" and trimmed or nil +end + +---Use specialized fzf-lua picker for branches +---@param opts table Finder options +---@param on_select fun(item: any|nil) +---@return boolean true if picker was used successfully +function M.pick_branch(opts, on_select) + local fzf_lua = get_fzf_lua() + if not fzf_lua or not fzf_lua.git_branches then + return false + end + + local function handle_selection(selected) + if not selected then + on_select(nil) + return + end + + if opts.allow_multi then + if type(selected) ~= "table" or #selected == 0 then + on_select(nil) + return + end + local processed_items = {} + for _, item in ipairs(selected) do + local processed = clean_branch_name(item) + if processed then + table.insert(processed_items, processed) + end + end + on_select(#processed_items > 0 and processed_items or nil) + else + local single_item = type(selected) == "table" and selected[1] or selected + local processed = clean_branch_name(single_item) + on_select(processed) + end + end + + fzf_lua.git_branches { + prompt = string.format("%s> ", opts.prompt_prefix or "Select branch"), + actions = { + ["default"] = function(selected) + vim.schedule(function() + handle_selection(selected) + end) + end, + ["ctrl-c"] = function() + vim.schedule(function() + on_select(nil) + end) + end, + ["ctrl-q"] = function() + vim.schedule(function() + on_select(nil) + end) + end, + }, + } + + return true +end + +---Use specialized fzf-lua picker for commits +---@param opts table Finder options +---@param on_select fun(item: any|nil) +---@return boolean true if picker was used successfully +function M.pick_commit(opts, on_select) + local fzf_lua = get_fzf_lua() + if not fzf_lua or not fzf_lua.git_commits then + return false + end + + local function handle_selection(selected) + if not selected then + on_select(nil) + return + end + + if opts.allow_multi then + if type(selected) ~= "table" or #selected == 0 then + on_select(nil) + return + end + local processed_items = {} + for _, item in ipairs(selected) do + local processed = extract_commit_sha_from_picker_entry(item) + if processed then + table.insert(processed_items, processed) + end + end + on_select(#processed_items > 0 and processed_items or nil) + else + local single_item = type(selected) == "table" and selected[1] or selected + local processed = extract_commit_sha_from_picker_entry(single_item) + on_select(processed) + end + end + + fzf_lua.git_commits { + prompt = string.format("%s> ", opts.prompt_prefix or "Select commit"), + actions = { + ["default"] = function(selected) + vim.schedule(function() + handle_selection(selected) + end) + end, + ["ctrl-c"] = function() + vim.schedule(function() + on_select(nil) + end) + end, + ["ctrl-q"] = function() + vim.schedule(function() + on_select(nil) + end) + end, + }, + } + + return true +end + +---Use specialized fzf-lua picker for tags +---@param opts table Finder options +---@param on_select fun(item: any|nil) +---@return boolean true if picker was used successfully +function M.pick_tag(opts, on_select) + local fzf_lua = get_fzf_lua() + if not fzf_lua or not fzf_lua.git_tags then + return false + end + + local function handle_selection(selected) + if not selected then + on_select(nil) + return + end + + if opts.allow_multi then + if type(selected) ~= "table" or #selected == 0 then + on_select(nil) + return + end + local processed_items = {} + for _, item in ipairs(selected) do + local processed = sanitize_tag_name_for_picker(item) + if processed then + table.insert(processed_items, processed) + end + end + on_select(#processed_items > 0 and processed_items or nil) + else + local single_item = type(selected) == "table" and selected[1] or selected + local processed = sanitize_tag_name_for_picker(single_item) + on_select(processed) + end + end + + fzf_lua.git_tags { + prompt = string.format("%s> ", opts.prompt_prefix or "Select tag"), + actions = { + ["default"] = function(selected) + vim.schedule(function() + handle_selection(selected) + end) + end, + ["ctrl-c"] = function() + vim.schedule(function() + on_select(nil) + end) + end, + ["ctrl-q"] = function() + vim.schedule(function() + on_select(nil) + end) + end, + }, + } + + return true +end + +---Use specialized fzf-lua picker for stashes +---@param opts table Finder options +---@param on_select fun(item: any|nil) +---@return boolean true if picker was used successfully +function M.pick_stash(opts, on_select) + local fzf_lua = get_fzf_lua() + if not fzf_lua or not fzf_lua.git_stash then + return false + end + + local function handle_selection(selected) + if not selected then + on_select(nil) + return + end + + if opts.allow_multi then + if type(selected) ~= "table" or #selected == 0 then + on_select(nil) + return + end + local processed_items = {} + for _, item in ipairs(selected) do + local processed = process_stash_entry(item) + if processed then + table.insert(processed_items, processed) + end + end + on_select(#processed_items > 0 and processed_items or nil) + else + local single_item = type(selected) == "table" and selected[1] or selected + local processed = process_stash_entry(single_item) + on_select(processed) + end + end + + fzf_lua.git_stash { + prompt = string.format("%s> ", opts.prompt_prefix or "Select stash"), + actions = { + ["default"] = function(selected) + vim.schedule(function() + handle_selection(selected) + end) + end, + ["ctrl-c"] = function() + vim.schedule(function() + on_select(nil) + end) + end, + ["ctrl-q"] = function() + vim.schedule(function() + on_select(nil) + end) + end, + }, + } + + return true +end + +---Use specialized fzf-lua picker for files (changed files only) +---@param opts table Finder options +---@param on_select fun(item: any|nil) +---@return boolean true if picker was used successfully +function M.pick_file(opts, on_select) + local fzf_lua = get_fzf_lua() + if not fzf_lua or not fzf_lua.git_status then + return false + end + + local function handle_selection(selected) + if not selected then + on_select(nil) + return + end + + local function extract_filename_from_status(status_line) + -- git_status output format is typically: "status filename" + -- Extract just the filename part + local filename = status_line:match("%s+(.+)$") or status_line:match("^%S+%s+(.+)$") or status_line + return filename + end + + if opts.allow_multi then + if type(selected) ~= "table" or #selected == 0 then + on_select(nil) + return + end + local processed_items = {} + for _, item in ipairs(selected) do + local processed = extract_filename_from_status(item) + if processed then + table.insert(processed_items, processed) + end + end + on_select(#processed_items > 0 and processed_items or nil) + else + local single_item = type(selected) == "table" and selected[1] or selected + local processed = extract_filename_from_status(single_item) + on_select(processed) + end + end + + fzf_lua.git_status { + prompt = string.format("%s> ", opts.prompt_prefix or "Select changed file"), + actions = { + ["default"] = function(selected) + vim.schedule(function() + handle_selection(selected) + end) + end, + ["ctrl-c"] = function() + vim.schedule(function() + on_select(nil) + end) + end, + ["ctrl-q"] = function() + vim.schedule(function() + on_select(nil) + end) + end, + }, + } + + return true +end + +---Use specialized fzf-lua picker for any_ref (combines branches, tags, commits) +---@param opts table Finder options +---@param on_select fun(item: any|nil) +---@return boolean true if picker was used successfully +function M.pick_any_ref(opts, on_select) + local fzf_lua = get_fzf_lua() + if not fzf_lua then + return false + end + + -- Create a combined list of refs and commits + local function get_any_ref_data() + local entries = {} + + -- Add symbolic refs like HEAD, ORIG_HEAD, etc. + local heads = git.refs.heads() + vim.list_extend(entries, heads) + + -- Add branches + local branches = git.refs.list_branches() + vim.list_extend(entries, branches) + + -- Add tags + local tags = git.refs.list_tags() + vim.list_extend(entries, tags) + + -- Add commits with proper formatting (sha + title) for better searchability + local commits = git.log.list() + for _, commit in ipairs(commits) do + table.insert(entries, string.format("%s %s", commit.oid:sub(1, 7), commit.subject or "")) + end + + return entries + end + + local function handle_selection(selected) + if not selected then + on_select(nil) + return + end + + local function process_any_ref_item(item) + if not item or item == "" then + return nil + end + + -- Check if it looks like a commit entry (starts with hex chars followed by space) + local commit_hash = item:match("^%s*([a-f0-9]+)%s+") + if commit_hash and #commit_hash >= 7 then + return commit_hash + end + + -- Otherwise, it's a branch/tag/symbolic ref, clean it up and use as-is + local trimmed = item:gsub("^%s+", ""):gsub("%s+$", "") + return trimmed ~= "" and trimmed or nil + end + + if opts.allow_multi then + if type(selected) ~= "table" or #selected == 0 then + on_select(nil) + return + end + local processed_items = {} + for _, item in ipairs(selected) do + local processed = process_any_ref_item(item) + if processed then + table.insert(processed_items, processed) + end + end + on_select(#processed_items > 0 and processed_items or nil) + else + local single_item = type(selected) == "table" and selected[1] or selected + local processed = process_any_ref_item(single_item) + on_select(processed) + end + end + + fzf_lua.fzf_exec(get_any_ref_data(), { + prompt = string.format("%s> ", opts.prompt_prefix or "Select any ref"), + actions = { + ["default"] = function(selected) + vim.schedule(function() + handle_selection(selected) + end) + end, + ["ctrl-c"] = function() + vim.schedule(function() + on_select(nil) + end) + end, + ["ctrl-q"] = function() + vim.schedule(function() + on_select(nil) + end) + end, + }, + }) + + return true +end + +---Try to use specialized picker based on item_type +---@param item_type string +---@param opts table Finder options +---@param on_select fun(item: any|nil) +---@return boolean true if specialized picker was used +function M.try_specialized_picker(item_type, opts, on_select) + if item_type == "branch" then + return M.pick_branch(opts, on_select) + elseif item_type == "commit" then + return M.pick_commit(opts, on_select) + elseif item_type == "tag" then + return M.pick_tag(opts, on_select) + elseif item_type == "any_ref" then + return M.pick_any_ref(opts, on_select) + elseif item_type == "stash" then + return M.pick_stash(opts, on_select) + elseif item_type == "file" then + return M.pick_file(opts, on_select) + end + + return false +end + +return M diff --git a/lua/neogit/popups/diff/actions.lua b/lua/neogit/popups/diff/actions.lua index 015b84214..52da846d5 100644 --- a/lua/neogit/popups/diff/actions.lua +++ b/lua/neogit/popups/diff/actions.lua @@ -1,117 +1,270 @@ local M = {} -local diffview = require("neogit.integrations.diffview") -local FuzzyFinderBuffer = require("neogit.buffers.fuzzy_finder") -local util = require("neogit.lib.util") + +local a = require("plenary.async") +local diffview_integration = require("neogit.integrations.diffview") +local Finder = require("neogit.lib.finder") local git = require("neogit.lib.git") local input = require("neogit.lib.input") +local notification = require("neogit.lib.notification") +local util = require("neogit.lib.util") --- aka "dwim" = do what I mean -function M.this(popup) - popup:close() - - local item = popup:get_env("item") - local section = popup:get_env("section") - - if section and section.name and item and item.name then - diffview.open(section.name, item.name, { only = true }) - elseif section.name then - diffview.open(section.name, nil, { only = true }) - elseif item.name then - diffview.open("range", item.name .. "..HEAD") +---@param popup table +local function close_popup_if_open(popup) + if popup and type(popup.close) == "function" then + popup:close() end end -function M.this_to_HEAD(popup) - popup:close() - - local item = popup:get_env("item") - if item then - if item.name then - diffview.open("range", item.name .. "..HEAD") - end - end +---@param popup table +---@param ... any diffview arguments +local function do_close_popup_and_open_diffview(popup, ...) + close_popup_if_open(popup) + diffview_integration.open(...) end -function M.range(popup) - local commit - local item = popup:get_env("item") - local section = popup:get_env("section") - if section and (section.name == "log" or section.name == "recent") then - commit = item and item.name - end - - local options = util.deduplicate( - util.merge( - { commit, git.branch.current() or "HEAD" }, - git.branch.get_all_branches(false), - git.tag.list(), - git.refs.heads() - ) - ) - - local range_from = FuzzyFinderBuffer.new(options):open_async { - prompt_prefix = "Diff for range from", +---@param item_type string +---@param prompt_prefix string +---@param allow_multi? boolean +---@return table +local function create_finder(item_type, prompt_prefix, allow_multi) + return Finder.create { + item_type = item_type, + prompt_prefix = prompt_prefix, + allow_multi = allow_multi or false, refocus_status = false, } +end - if not range_from then +---@param popup table +---@param item_type1 string +---@param prompt1 string +---@param item_type2 string +---@param prompt2 string +---@param on_both_selected function +local function prompt_for_item_pair_async(popup, item_type1, prompt1, item_type2, prompt2, on_both_selected) + local finder1 = create_finder(item_type1, prompt1) + + local item1 = finder1:find_async() + if not item1 or item1 == "" then + close_popup_if_open(popup) return end - local range_to = FuzzyFinderBuffer.new(options) - :open_async { prompt_prefix = "Diff from " .. range_from .. " to", refocus_status = false } - if not range_to then + local finder2 = create_finder(item_type2, prompt2) + local item2 = finder2:find_async() + if not item2 or item2 == "" then + close_popup_if_open(popup) return end - local choices = { - "&1. Range (a..b)", - "&2. Symmetric Difference (a...b)", - "&3. Cancel", - } - local choice = input.get_choice("Select type", { values = choices, default = #choices }) + on_both_selected(item1, item2) +end - popup:close() - if choice == "1" then - diffview.open("range", range_from .. ".." .. range_to) - elseif choice == "2" then - diffview.open("range", range_from .. "..." .. range_to) +M.this = function(popup) + if popup.state.env.section and popup.state.env.item then + do_close_popup_and_open_diffview(popup, popup.state.env.section.name, popup.state.env.item.name, { + only = true, + }) + elseif popup.state.env.section then + do_close_popup_and_open_diffview(popup, popup.state.env.section.name, nil, { only = true }) + else + vim.notify("Neogit: No context for 'this' diff.", vim.log.levels.WARN) + close_popup_if_open(popup) end end function M.worktree(popup) popup:close() - diffview.open("worktree") + diffview_integration.open("worktree") end function M.staged(popup) popup:close() - diffview.open("staged", nil, { only = true }) + diffview_integration.open("staged", nil, { only = true }) end -function M.unstaged(popup) - popup:close() - diffview.open("unstaged", nil, { only = true }) -end +M.branch_range = a.void(function(popup) + prompt_for_item_pair_async( + popup, + "branch", + "Diff range FROM branch", + "branch", + "Diff range TO branch", + function(branch1, branch2) + local choices = { "&1. Cumulative (..)", "&2. Distinct (...)", "&3. Cancel" } + local choice_num = + input.get_choice("Select diff type for selected branches:", { values = choices, default = 1 }) -function M.stash(popup) - popup:close() + if choice_num == "1" then + do_close_popup_and_open_diffview(popup, "range", branch1 .. ".." .. branch2) + elseif choice_num == "2" then + do_close_popup_and_open_diffview(popup, "range", branch1 .. "..." .. branch2) + else + close_popup_if_open(popup) + end + end + ) +end) - local selected = FuzzyFinderBuffer.new(git.stash.list()):open_async { refocus_status = false } - if selected then - diffview.open("stashes", selected) +M.commit_range = a.void(function(popup) + prompt_for_item_pair_async( + popup, + "commit", + "Diff range FROM commit/ref", + "commit", + "Diff range TO commit/ref", + function(commit1, commit2) + do_close_popup_and_open_diffview(popup, "range", commit1 .. ".." .. commit2) + end + ) +end) + +M.tag_range = a.void(function(popup) + prompt_for_item_pair_async( + popup, + "tag", + "Diff range FROM tag", + "tag", + "Diff range TO tag", + function(tag1, tag2) + do_close_popup_and_open_diffview(popup, "range", tag1 .. ".." .. tag2) + end + ) +end) + +M.head_to_commit_ref = a.void(function(popup) + local finder = create_finder("commit", "Diff HEAD to commit/ref") + local commit_or_ref = finder:find_async() + + if commit_or_ref then + do_close_popup_and_open_diffview(popup, "range", "HEAD.." .. commit_or_ref) + else + close_popup_if_open(popup) end -end +end) -function M.commit(popup) - popup:close() +M.stash = a.void(function(popup) + local finder = create_finder("stash", "Select stash to diff") + local stash_ref = finder:find_async() - local options = util.merge(git.refs.list_branches(), git.refs.list_tags(), git.refs.heads()) + if stash_ref then + do_close_popup_and_open_diffview(popup, "stashes", stash_ref) + else + close_popup_if_open(popup) + end +end) + +M.files = a.void(function(popup) + local finder = create_finder("file", "Select files to diff", false) + local file_to_diff = finder:find_async() + + if file_to_diff then + local staged_files = git.repo.state.staged.items + local unstaged_files = git.repo.state.unstaged.items - local selected = FuzzyFinderBuffer.new(options):open_async { refocus_status = false } - if selected then - diffview.open("commit", selected) + local is_staged = false + local is_unstaged = false + + for _, file in ipairs(staged_files) do + if file.name == file_to_diff then + is_staged = true + break + end + end + + for _, file in ipairs(unstaged_files) do + if file.name == file_to_diff then + is_unstaged = true + break + end + end + + -- Prefer unstaged if file is in both + if is_unstaged then + do_close_popup_and_open_diffview(popup, "unstaged", file_to_diff, { only = true }) + elseif is_staged then + do_close_popup_and_open_diffview(popup, "staged", file_to_diff, { only = true }) + else + -- Fallback to worktree + do_close_popup_and_open_diffview(popup, "worktree", nil, { only = true }) + end + else + close_popup_if_open(popup) + end +end) + +M.custom_range = a.void(function(popup) + prompt_for_item_pair_async( + popup, + "any_ref", + "Diff range FROM (any ref)", + "any_ref", + "Diff range TO (any ref)", + function(ref1, ref2) + local choices = { "&1. Cumulative (..)", "&2. Distinct (...)", "&3. Cancel" } + local choice_num = + input.get_choice("Select diff type for selected refs:", { values = choices, default = 1 }) + + if choice_num == "1" then + do_close_popup_and_open_diffview(popup, "range", ref1 .. ".." .. ref2) + elseif choice_num == "2" then + do_close_popup_and_open_diffview(popup, "range", ref1 .. "..." .. ref2) + else + close_popup_if_open(popup) + end + end + ) +end) + +M.paths = a.void(function(popup) + local path_input_str = input.get_user_input("Enter path(s) to diff (space-separated, globs supported)", { + completion = "dir", + default = "./", + }) + + if not path_input_str or path_input_str == "" then + close_popup_if_open(popup) + return end + + local path_patterns = vim.split(path_input_str, "%s+") + local all_files_to_diff = {} + local found_any_files = false + + for _, pattern in ipairs(path_patterns) do + if pattern ~= "" then + local files_under_path_result = + git.cli["ls-files"].args(pattern).call { hidden = true, ignore_error = true } + + if files_under_path_result.code == 0 and #files_under_path_result.stdout > 0 then + found_any_files = true + vim.list_extend(all_files_to_diff, files_under_path_result.stdout) + end + end + end + + if not found_any_files then + notification.warn("No tracked files found matching: " .. path_input_str) + close_popup_if_open(popup) + return + end + + all_files_to_diff = util.deduplicate(all_files_to_diff) + + if #all_files_to_diff == 0 then + notification.warn("No tracked files found matching: " .. path_input_str) + close_popup_if_open(popup) + return + end + + local diff_args = { "HEAD", "--" } + vim.list_extend(diff_args, all_files_to_diff) + do_close_popup_and_open_diffview(popup, diff_args) +end) + +function M.unstaged(popup) + popup:close() + diffview_integration.open("unstaged", nil, { only = true }) end return M diff --git a/lua/neogit/popups/diff/init.lua b/lua/neogit/popups/diff/init.lua index 34b32ef83..c7d486e7a 100644 --- a/lua/neogit/popups/diff/init.lua +++ b/lua/neogit/popups/diff/init.lua @@ -6,23 +6,25 @@ local actions = require("neogit.popups.diff.actions") function M.create(env) local diffview = config.check_integration("diffview") - local commit_selected = (env.section and env.section.name == "log") and type(env.item.name) == "string" local p = popup .builder() :name("NeogitDiffPopup") - :group_heading("Diff") - :action_if(diffview and env.item, "d", "this", actions.this) - :action_if(diffview and commit_selected, "h", "this..HEAD", actions.this_to_HEAD) - :action_if(diffview, "r", "range", actions.range) - :action("p", "paths") - :new_action_group() - :action_if(diffview, "u", "unstaged", actions.unstaged) - :action_if(diffview, "s", "staged", actions.staged) - :action_if(diffview, "w", "worktree", actions.worktree) - :new_action_group("Show") - :action_if(diffview, "c", "Commit", actions.commit) - :action_if(diffview, "t", "Stash", actions.stash) + :group_heading("Diff Working Tree/Index") + :action_if(diffview, "d", "Current File/Selection", actions.this) + :action_if(diffview, "w", "Worktree", actions.worktree) + :action_if(diffview, "s", "Staged Changes (Index)", actions.staged) + :action_if(diffview, "u", "Unstaged Changes (HEAD vs Worktree)", actions.unstaged) + :new_action_group("Diff Ranges") + :action_if(diffview, "b", "Branch Range", actions.branch_range) + :action_if(diffview, "c", "Commit/Ref Range", actions.commit_range) + :action_if(diffview, "t", "Tag Range", actions.tag_range) + :action_if(diffview, "h", "HEAD to Commit/Ref", actions.head_to_commit_ref) + :action_if(diffview, "r", "Custom Range (any ref)", actions.custom_range) + :new_action_group("Diff Specific Types") + :action_if(diffview, "S", "Stash", actions.stash) + :action_if(diffview, "p", "Paths", actions.paths) + :action_if(diffview, "f", "Files", actions.files) :env(env) :build()