diff --git a/lua/neogit/integrations/diffview.lua b/lua/neogit/integrations/diffview.lua index 42cf8f92b..97d491ebd 100644 --- a/lua/neogit/integrations/diffview.lua +++ b/lua/neogit/integrations/diffview.lua @@ -93,7 +93,7 @@ local function get_local_diff_view(section_name, item_name, opts) return view end ----@param section_name string +---@param section_name string | string[] ---@param item_name string|nil ---@param opts table|nil function M.open(section_name, item_name, opts) @@ -109,8 +109,10 @@ function M.open(section_name, item_name, opts) end local view - -- selene: allow(if_same_then_else) - if section_name == "recent" or section_name:match("unmerged$") or section_name == "log" then + + if type(section_name) == "table" then + view = dv_lib.diffview_open(dv_utils.tbl_pack(unpack(section_name))) + elseif section_name == "recent" or section_name:match("unmerged$") or section_name == "log" then local range if type(item_name) == "table" then range = string.format("%s..%s", item_name[1], item_name[#item_name]) @@ -122,24 +124,23 @@ function M.open(section_name, item_name, opts) view = dv_lib.diffview_open(dv_utils.tbl_pack(range)) elseif section_name == "range" then - local range = item_name - view = dv_lib.diffview_open(dv_utils.tbl_pack(range)) + local range_str = item_name + view = dv_lib.diffview_open(dv_utils.tbl_pack(range_str)) elseif section_name == "stashes" then assert(item_name, "No item name for stash!") local stash_id = item_name:match("stash@{%d+}") view = dv_lib.diffview_open(dv_utils.tbl_pack(stash_id .. "^!")) - elseif section_name == "commit" then - view = dv_lib.diffview_open(dv_utils.tbl_pack(item_name .. "^!")) elseif section_name == "conflict" and item_name then view = dv_lib.diffview_open(dv_utils.tbl_pack("--selected-file=" .. item_name)) - elseif (section_name == "conflict" or section_name == "worktree") and not item_name then - view = dv_lib.diffview_open() - elseif section_name ~= nil then - view = get_local_diff_view(section_name, item_name, opts) - elseif section_name == nil and item_name ~= nil then + elseif section_name == "commit" or (section_name == nil and item_name ~= nil) then view = dv_lib.diffview_open(dv_utils.tbl_pack(item_name .. "^!")) - else + elseif + ((section_name == "conflict" or section_name == "worktree") and not item_name) + or (section_name == nil and item_name == nil) + then view = dv_lib.diffview_open() + else + view = get_local_diff_view(section_name, item_name, opts) end if view then diff --git a/lua/neogit/lib/git/cli.lua b/lua/neogit/lib/git/cli.lua index 315246e41..7a8d526db 100644 --- a/lua/neogit/lib/git/cli.lua +++ b/lua/neogit/lib/git/cli.lua @@ -1170,7 +1170,6 @@ local function new_builder(subcommand) { "git", "--no-pager", - "--literal-pathspecs", "--no-optional-locks", "-c", "core.preloadindex=true", "-c", "color.ui=always", diff --git a/lua/neogit/popups/diff/actions.lua b/lua/neogit/popups/diff/actions.lua index af184cbd4..478c2c383 100644 --- a/lua/neogit/popups/diff/actions.lua +++ b/lua/neogit/popups/diff/actions.lua @@ -1,92 +1,403 @@ local M = {} -local diffview = require("neogit.integrations.diffview") + +local config = require("neogit.config") +local diffview_integration = require("neogit.integrations.diffview") local FuzzyFinderBuffer = require("neogit.buffers.fuzzy_finder") -local util = require("neogit.lib.util") local git = require("neogit.lib.git") +local a = require("plenary.async") 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 function get_fzf_lua() + if config.check_integration("fzf_lua") then + local fzf_ok, fzf_lua_mod = pcall(require, "fzf-lua") + if fzf_ok then + return fzf_lua_mod + end + end + return nil +end - if popup.state.env.section and popup.state.env.item then - diffview.open(popup.state.env.section.name, popup.state.env.item.name, { - only = true, - }) - elseif popup.state.env.section then - diffview.open(popup.state.env.section.name, nil, { only = true }) +local function get_picker_selection(picker_raw_output) + if type(picker_raw_output) == "table" and #picker_raw_output > 0 then + return picker_raw_output[1] + elseif type(picker_raw_output) == "string" then + return picker_raw_output + end + return nil +end + +local function clean_branch_name(name) + if not 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 +end + +local function close_popup_if_open(popup) + if popup and type(popup.close) == "function" then + popup:close() end end -function M.range(popup) - local options = util.deduplicate( - util.merge( - { git.branch.current() or "HEAD" }, - git.branch.get_all_branches(false), - git.tag.list(), - git.refs.heads() +local function do_close_popup_and_open_diffview(popup, ...) + close_popup_if_open(popup) + diffview_integration.open(...) +end + +local function get_refs_for_fallback_picker() + local commits = git.log.list { "--max-count=200" } + local formatted_commits = {} + for _, commit_entry in ipairs(commits) do + table.insert( + formatted_commits, + string.format("%s %s", commit_entry.oid:sub(1, 7), commit_entry.subject or "") ) - ) + end + return formatted_commits +end - local range_from = FuzzyFinderBuffer.new(options):open_async { prompt_prefix = "Diff for range from" } - if not range_from then - return +local function extract_commit_sha_from_picker_entry(entry_string) + if not entry_string then + return nil end + return entry_string:match("^%s*([a-f0-9]+)") or entry_string +end - local range_to = FuzzyFinderBuffer.new(options) - :open_async { prompt_prefix = "Diff from " .. range_from .. " to" } - if not range_to then - return +--- Prompts the user to select item(s) using fzf-lua or a fallback FuzzyFinderBuffer. +--- @param popup table The popup object to close on cancel/completion. +--- @param fzf_lua table|nil The fzf-lua module, or nil to force fallback. +--- @param cfg table Configuration for the picker: +local function prompt_for_items_async(popup, fzf_lua, cfg) + local item_processor = cfg.item_processor_fn or function(item) + return item + end + local on_cancel_handler = cfg.on_cancel or function() + close_popup_if_open(popup) end - local choices = { - "&1. " .. range_from .. ".." .. range_to, - "&2. " .. range_from .. "..." .. range_to, - "&3. Cancel", - } - local choice = input.get_choice("Select range", { values = choices, default = #choices }) + local function handle_selection(raw_selected_items) + if not raw_selected_items then + on_cancel_handler() + return + 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) + if cfg.allow_multi then + if type(raw_selected_items) ~= "table" or #raw_selected_items == 0 then + on_cancel_handler() + return + end + local processed_items = {} + for _, item in ipairs(raw_selected_items) do + local processed = item_processor(item) + if processed ~= nil then + table.insert(processed_items, processed) + end + end + if #processed_items > 0 then + cfg.on_select(processed_items) + else + on_cancel_handler() + end + else + local single_item + if type(raw_selected_items) == "table" then + single_item = raw_selected_items[1] + else + single_item = raw_selected_items + end + + local processed_single_item = single_item and item_processor(single_item) or nil + if processed_single_item ~= nil then + cfg.on_select(processed_single_item) + else + on_cancel_handler() + end + end + end + + if fzf_lua and cfg.fzf_method_name then + fzf_lua[cfg.fzf_method_name] { + prompt = cfg.fzf_prompt, + actions = { + ["default"] = function(selected) + handle_selection(selected) + end, + ["esc"] = on_cancel_handler, + }, + } + else + local picker_opts = { + prompt_prefix = cfg.fallback_prompt_prefix, + refocus_status = false, + allow_multi = cfg.allow_multi or false, + } + local raw_selection = FuzzyFinderBuffer.new(cfg.fallback_data_fn()):open_async(picker_opts) + + if cfg.allow_multi then + handle_selection(raw_selection) + else + handle_selection(get_picker_selection(raw_selection)) + end + end +end + +--- Prompts the user to select two items sequentially using `prompt_for_items_async`. +--- @param popup table The popup object. +--- @param fzf_lua table|nil The fzf-lua module. +--- @param cfg1 table Picker configuration for the first item. +--- @param cfg2 table Picker configuration for the second item. +--- @param on_both_selected_fn function(item1, item2): Callback when both (non-nil processed) items are selected. +--- @param on_cancel_fn_outer? function Callback if any selection is cancelled or results in nil. +local function prompt_for_item_pair_async(popup, fzf_lua, cfg1, cfg2, on_both_selected_fn, on_cancel_fn_outer) + local overall_cancel_handler = on_cancel_fn_outer or function() + close_popup_if_open(popup) + end + + cfg1.on_select = function(item1_processed) + if item1_processed == nil then + overall_cancel_handler() + return + end + + cfg2.on_select = function(item2_processed) + if item2_processed == nil then + overall_cancel_handler() + return + end + on_both_selected_fn(item1_processed, item2_processed) + end + cfg2.on_cancel = overall_cancel_handler + prompt_for_items_async(popup, fzf_lua, cfg2) + end + cfg1.on_cancel = overall_cancel_handler + prompt_for_items_async(popup, fzf_lua, cfg1) +end + +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") +M.worktree = function(popup) + do_close_popup_and_open_diffview(popup, "worktree") end -function M.staged(popup) - popup:close() - diffview.open("staged", nil, { only = true }) +M.staged = function(popup) + do_close_popup_and_open_diffview(popup, "staged", nil, { only = true }) end -function M.unstaged(popup) - popup:close() - diffview.open("unstaged", nil, { only = true }) +M.unstaged = function(popup) + do_close_popup_and_open_diffview(popup, "unstaged", nil, { only = true }) end -function M.stash(popup) - popup:close() +M.branch_range = a.void(function(popup) + local fzf = get_fzf_lua() + local branch_picker_config = { + fzf_method_name = "git_branches", + fallback_data_fn = function() + return git.refs.list_branches() + end, + item_processor_fn = clean_branch_name, + } + + local cfg1 = vim.deepcopy(branch_picker_config) + cfg1.fzf_prompt = "Diff range FROM branch> " + cfg1.fallback_prompt_prefix = "Diff range FROM branch" + + local cfg2 = vim.deepcopy(branch_picker_config) + cfg2.fzf_prompt = "Diff range TO branch> " + cfg2.fallback_prompt_prefix = "Diff range TO branch" + + prompt_for_item_pair_async(popup, fzf, cfg1, cfg2, 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 }) + + 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) + +M.commit_range = a.void(function(popup) + local fzf = get_fzf_lua() + local commit_picker_config = { + fzf_method_name = "git_commits", + fallback_data_fn = get_refs_for_fallback_picker, + item_processor_fn = extract_commit_sha_from_picker_entry, + } + + local cfg1 = vim.deepcopy(commit_picker_config) + cfg1.fzf_prompt = "Diff range FROM commit/ref> " + cfg1.fallback_prompt_prefix = "Diff range FROM commit/ref" + + local cfg2 = vim.deepcopy(commit_picker_config) + cfg2.fzf_prompt = "Diff range TO commit/ref> " + cfg2.fallback_prompt_prefix = "Diff range TO commit/ref" + + prompt_for_item_pair_async(popup, fzf, cfg1, cfg2, function(commit_or_ref1, commit_or_ref2) + do_close_popup_and_open_diffview(popup, "range", commit_or_ref1 .. ".." .. commit_or_ref2) + end) +end) + +M.head_to_commit_ref = a.void(function(popup) + local fzf = get_fzf_lua() + prompt_for_items_async(popup, fzf, { + fzf_method_name = "git_commits", + fzf_prompt = "Diff HEAD to commit/ref> ", + fallback_data_fn = get_refs_for_fallback_picker, + fallback_prompt_prefix = "Diff HEAD to commit/ref", + item_processor_fn = extract_commit_sha_from_picker_entry, + on_select = function(commit_or_ref) + do_close_popup_and_open_diffview(popup, "range", "HEAD.." .. commit_or_ref) + end, + }) +end) + +M.stash = a.void(function(popup) + local fzf = get_fzf_lua() - local selected = FuzzyFinderBuffer.new(git.stash.list()):open_async() - if selected then - diffview.open("stashes", selected) + local function process_stash_entry(selected_entry_text) + if selected_entry_text then + return selected_entry_text:match("^(stash@{%d+})") + end + return nil end -end -function M.commit(popup) - popup:close() + prompt_for_items_async(popup, fzf, { + fzf_method_name = "git_stash", + fzf_prompt = "Diff stash> ", + fallback_data_fn = function() + return git.stash.list() + end, + fallback_prompt_prefix = "Select stash to diff", + item_processor_fn = process_stash_entry, + on_select = function(stash_ref) + do_close_popup_and_open_diffview(popup, "stashes", stash_ref) + end, + on_cancel = function() + vim.notify("Invalid stash selected or stash pattern not found.", vim.log.levels.WARN) + close_popup_if_open(popup) + end, + }) +end) - local options = util.merge(git.refs.list_branches(), git.refs.list_tags(), git.refs.heads()) +M.tag_range = a.void(function(popup) + local fzf = get_fzf_lua() - local selected = FuzzyFinderBuffer.new(options):open_async() - if selected then - diffview.open("commit", selected) + local function sanitize_tag_name_for_picker(name) + if not name then + return nil + end + return name:match("^%s*([^%s]+)") or name end -end + + local tag_picker_config = { + fzf_method_name = "git_tags", + fallback_data_fn = function() + return git.refs.list_tags() + end, + item_processor_fn = sanitize_tag_name_for_picker, + } + + local cfg1 = vim.deepcopy(tag_picker_config) + cfg1.fzf_prompt = "Diff range FROM tag> " + cfg1.fallback_prompt_prefix = "Diff range FROM tag" + + local cfg2 = vim.deepcopy(tag_picker_config) + cfg2.fzf_prompt = "Diff range TO tag> " + cfg2.fallback_prompt_prefix = "Diff range TO tag" + + prompt_for_item_pair_async(popup, fzf, cfg1, cfg2, function(tag1, tag2) + do_close_popup_and_open_diffview(popup, "range", tag1 .. ".." .. tag2) + end) +end) + +M.files = a.void(function(popup) + prompt_for_items_async(popup, nil, { + fallback_data_fn = function() + return git.files.all() + end, + fallback_prompt_prefix = "Select files to diff against HEAD", + allow_multi = true, + on_select = function(files_to_diff) + if not files_to_diff or #files_to_diff == 0 then + close_popup_if_open(popup) + return + end + local diff_args = { "HEAD", "--" } + vim.list_extend(diff_args, files_to_diff) + do_close_popup_and_open_diffview(popup, diff_args) + end, + on_cancel = function() + close_popup_if_open(popup) + 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) return M diff --git a/lua/neogit/popups/diff/init.lua b/lua/neogit/popups/diff/init.lua index ed8b849c0..d99a56488 100644 --- a/lua/neogit/popups/diff/init.lua +++ b/lua/neogit/popups/diff/init.lua @@ -10,17 +10,20 @@ function M.create(env) local p = popup .builder() :name("NeogitDiffPopup") - :group_heading("Diff") - :action_if(diffview, "d", "this", actions.this) - :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) + :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()