From d7e4819f752c20e9065d09cc74a849f73d80bdd4 Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Sat, 4 Jul 2026 00:06:43 +0200 Subject: [PATCH] feat(commit-log): add `gL` to filter the commit log by the file under the cursor --- doc/diffview.txt | 8 ++ doc/diffview_defaults.txt | 1 + lua/diffview/actions.lua | 2 + lua/diffview/config.lua | 1 + lua/diffview/scene/views/diff/listeners.lua | 19 ++++ .../tests/functional/git_adapter_spec.lua | 89 +++++++++++++++++++ .../tests/functional/hg_adapter_spec.lua | 43 +++++++++ .../tests/functional/jj_adapter_spec.lua | 32 +++++++ lua/diffview/ui/panels/commit_log_panel.lua | 21 ++++- lua/diffview/vcs/adapter.lua | 4 +- lua/diffview/vcs/adapters/git/init.lua | 26 +++++- lua/diffview/vcs/adapters/hg/init.lua | 26 +++++- lua/diffview/vcs/adapters/jj/init.lua | 13 ++- lua/diffview/vcs/adapters/null/init.lua | 4 +- lua/diffview/vcs/adapters/p4/init.lua | 10 ++- 15 files changed, 287 insertions(+), 12 deletions(-) diff --git a/doc/diffview.txt b/doc/diffview.txt index 80aa29de..5ca5f351 100644 --- a/doc/diffview.txt +++ b/doc/diffview.txt @@ -1906,6 +1906,14 @@ open_commit_log *diffview-actions-open_commit_lo Open a panel showing the full commit description of the subject's target commit range. +open_commit_log_file *diffview-actions-open_commit_log_file* + Contexts: `file_panel` + + Like `open_commit_log`, but filter the log to the file under the + cursor. Directory entries are skipped. Renames within the range + silently truncate history at the rename (git `--follow` is not + applied); use |:DiffviewFileHistory| for a rename-aware view. + open_fold *diffview-actions-open_fold* Contexts: `file_panel`, `file_history_panel` diff --git a/doc/diffview_defaults.txt b/doc/diffview_defaults.txt index 5778a664..bca779e1 100644 --- a/doc/diffview_defaults.txt +++ b/doc/diffview_defaults.txt @@ -272,6 +272,7 @@ DEFAULT CONFIG *diffview.defaults* { "n", "U", actions.unstage_all, { desc = "Unstage all entries" } }, { "n", "X", actions.restore_entry, { desc = "Restore entry to the state on the left side" } }, { "n", "L", actions.open_commit_log, { desc = "Open the commit log panel" } }, + { "n", "gL", actions.open_commit_log_file, { desc = "Open the commit log panel filtered to the file under the cursor" } }, { "n", "zo", actions.open_fold, { desc = "Expand fold" } }, { "n", "h", actions.close_fold, { desc = "Collapse fold" } }, { "n", "zc", actions.close_fold, { desc = "Collapse fold" } }, diff --git a/lua/diffview/actions.lua b/lua/diffview/actions.lua index 4d3da742..cd884078 100644 --- a/lua/diffview/actions.lua +++ b/lua/diffview/actions.lua @@ -51,6 +51,7 @@ local pl = lazy.access(utils, "path") --[[@as PathLib ]] ---@field open_all_folds fun() ---@field open_commit_in_browser fun() ---@field open_commit_log fun() +---@field open_commit_log_file fun() ---@field open_fold fun() ---@field open_in_diffview fun() ---@field options fun() @@ -1231,6 +1232,7 @@ local action_names = { "open_all_folds", "open_commit_in_browser", "open_commit_log", + "open_commit_log_file", "open_fold", "open_in_diffview", "options", diff --git a/lua/diffview/config.lua b/lua/diffview/config.lua index 53202a26..09c432d5 100644 --- a/lua/diffview/config.lua +++ b/lua/diffview/config.lua @@ -722,6 +722,7 @@ M.defaults = { { "n", "U", actions.unstage_all, { desc = "Unstage all entries" } }, { "n", "X", actions.restore_entry, { desc = "Restore entry to the state on the left side" } }, { "n", "L", actions.open_commit_log, { desc = "Open the commit log panel" } }, + { "n", "gL", actions.open_commit_log_file, { desc = "Open the commit log panel filtered to the file under the cursor" } }, { "n", "T", actions.open_in_new_tab, { desc = "Open diffview in a new tab" } }, { "n", "i", actions.listing_style, { desc = "Toggle between 'list' and 'tree' views" } }, { "n", "f", actions.toggle_flatten_dirs, { desc = "Flatten empty subdirectories in tree listing style" } }, diff --git a/lua/diffview/scene/views/diff/listeners.lua b/lua/diffview/scene/views/diff/listeners.lua index 27861db2..c194f717 100644 --- a/lua/diffview/scene/views/diff/listeners.lua +++ b/lua/diffview/scene/views/diff/listeners.lua @@ -302,6 +302,25 @@ return function(view) view.commit_log_panel:update(range) end end, + open_commit_log_file = function() + if view.left.type == RevType.STAGE and view.right.type == RevType.LOCAL then + utils.info("Changes not committed yet. No log available for these changes.") + return + end + + -- `infer_cur_file()` (no arg) already skips `DirData` items, so any + -- non-nil return is a real file entry. + local item = view:infer_cur_file() + if not item then + return + end + + local range = view.adapter.Rev.to_range(view.left, view.right) + + if range then + view.commit_log_panel:update(range, { item.path }) + end + end, toggle_stage_entry = function() if not (view.left.type == RevType.STAGE and view.right.type == RevType.LOCAL) then return diff --git a/lua/diffview/tests/functional/git_adapter_spec.lua b/lua/diffview/tests/functional/git_adapter_spec.lua index 04417168..78310900 100644 --- a/lua/diffview/tests/functional/git_adapter_spec.lua +++ b/lua/diffview/tests/functional/git_adapter_spec.lua @@ -151,6 +151,95 @@ describe("diffview.vcs.adapters.git", function() end end) ) + + it( + "appends `-- :(literal)` when path filters are given", + test_utils.async_test(function() + local repo, adapter = make_repo_and_adapter() + + local ok, err = pcall(function() + local args = adapter:get_log_args({ "abc..def" }, { "a.txt", "b.txt" }) + + -- Paths trail after a lone `--` separator (so git treats them as + -- pathspecs, not revs) and are prefixed with `:(literal)` to force + -- exact filename match instead of pathspec globbing. + local sep_idx + for i, arg in ipairs(args) do + if arg == "--" then + sep_idx = i + break + end + end + assert.is_not_nil(sep_idx, "get_log_args must emit `--` before pathspecs") + assert.equals(":(literal)a.txt", args[sep_idx + 1]) + assert.equals(":(literal)b.txt", args[sep_idx + 2]) + assert.is_nil(args[sep_idx + 3]) + end) + + vim.schedule(function() + pcall(vim.fn.delete, repo, "rf") + end) + async.await(async.scheduler()) + + if not ok then + error(err) + end + end) + ) + + it( + "wraps filenames containing pathspec metacharacters as literal", + test_utils.async_test(function() + local repo, adapter = make_repo_and_adapter() + + local ok, err = pcall(function() + -- Filenames legally containing `*`, `[`, `?`, or a leading `:` would + -- otherwise trigger pathspec globbing / magic when passed unquoted. + local args = adapter:get_log_args({ "abc..def" }, { "foo*.txt", "[dir]/x.lua", ":odd" }) + + local last3 = { args[#args - 2], args[#args - 1], args[#args] } + assert.equals(":(literal)foo*.txt", last3[1]) + assert.equals(":(literal)[dir]/x.lua", last3[2]) + assert.equals(":(literal):odd", last3[3]) + end) + + vim.schedule(function() + pcall(vim.fn.delete, repo, "rf") + end) + async.await(async.scheduler()) + + if not ok then + error(err) + end + end) + ) + + it( + "does not append `--` when no path filter is given", + test_utils.async_test(function() + local repo, adapter = make_repo_and_adapter() + + local ok, err = pcall(function() + local args_nil = adapter:get_log_args({ "abc..def" }) + local args_empty = adapter:get_log_args({ "abc..def" }, {}) + + for _, args in ipairs({ args_nil, args_empty }) do + for _, arg in ipairs(args) do + assert.are_not.equal("--", arg) + end + end + end) + + vim.schedule(function() + pcall(vim.fn.delete, repo, "rf") + end) + async.await(async.scheduler()) + + if not ok then + error(err) + end + end) + ) end) describe("show_untracked", function() diff --git a/lua/diffview/tests/functional/hg_adapter_spec.lua b/lua/diffview/tests/functional/hg_adapter_spec.lua index e728abaf..35351657 100644 --- a/lua/diffview/tests/functional/hg_adapter_spec.lua +++ b/lua/diffview/tests/functional/hg_adapter_spec.lua @@ -106,6 +106,49 @@ describe("diffview.vcs.adapters.hg", function() eq("-R", args[log_idx - 2]) eq("/some/path", args[log_idx - 1]) end) + + it("appends `-- path:` when path filters are given", function() + local adapter = HgAdapter({ toplevel = "/tmp", path_args = {} }) + local args = adapter:get_log_args({ "abc..def" }, { "a.txt", "b.txt" }) + + -- Paths trail after a lone `--` separator and are prefixed with + -- `path:` so hg matches the exact repo-relative path instead of + -- interpreting glob metacharacters (`*`, `?`, `[`). + local sep_idx + for i, arg in ipairs(args) do + if arg == "--" then + sep_idx = i + break + end + end + assert.is_not_nil(sep_idx, "get_log_args must emit `--` before pathspecs") + eq("path:a.txt", args[sep_idx + 1]) + eq("path:b.txt", args[sep_idx + 2]) + assert.is_nil(args[sep_idx + 3]) + end) + + it("wraps filenames containing pattern metacharacters as literal", function() + local adapter = HgAdapter({ toplevel = "/tmp", path_args = {} }) + -- Filenames legally containing `*`, `?`, or `[` would otherwise be + -- expanded as globs against the working tree by hg. + local args = adapter:get_log_args({ "abc..def" }, { "foo*.txt", "[dir]/x.lua" }) + + eq("path:foo*.txt", args[#args - 1]) + eq("path:[dir]/x.lua", args[#args]) + end) + + it("does not append `--` when no path filter is given", function() + local adapter = HgAdapter({ toplevel = "/tmp", path_args = {} }) + + for _, args in ipairs({ + adapter:get_log_args({ "abc..def" }), + adapter:get_log_args({ "abc..def" }, {}), + }) do + for _, arg in ipairs(args) do + assert.are_not.equal("--", arg) + end + end + end) end) -- ------------------------------------------------------------------ diff --git a/lua/diffview/tests/functional/jj_adapter_spec.lua b/lua/diffview/tests/functional/jj_adapter_spec.lua index 0bf6a004..e7383a61 100644 --- a/lua/diffview/tests/functional/jj_adapter_spec.lua +++ b/lua/diffview/tests/functional/jj_adapter_spec.lua @@ -292,6 +292,38 @@ describe("diffview.vcs.adapters.jj", function() adapter:get_log_args({ "abc123" }) ) end) + + it("appends fileset-quoted path filters after `--` and revsets", function() + local adapter = new_adapter() + + -- `a.txt` is a literal path with no glob metacharacters, so + -- `quote_path_args` wraps it as a plain fileset string literal. + -- Positional filesets always follow `--`, which itself follows + -- the `-r` revset(s), matching `list_files_at_head` and + -- `file_history_dry_run` in the same adapter. + eq( + { "log", "--no-graph", "-r", "abc123..def456", "--", '"a.txt"' }, + adapter:get_log_args({ "abc123..def456" }, { "a.txt" }) + ) + end) + + it("quotes literal paths containing fileset operators", function() + local adapter = new_adapter() + + -- A colon-bearing filename would otherwise look like a fileset + -- kind prefix (`foo:bar.txt`) to jj. Quoting forces literal match. + eq( + { "log", "--no-graph", "-r", "abc123", "--", '"foo:bar.txt"' }, + adapter:get_log_args({ "abc123" }, { "foo:bar.txt" }) + ) + end) + + it("does not append `--` or any path when `paths` is nil or empty", function() + local adapter = new_adapter() + + eq({ "log", "--no-graph", "-r", "abc123" }, adapter:get_log_args({ "abc123" }, nil)) + eq({ "log", "--no-graph", "-r", "abc123" }, adapter:get_log_args({ "abc123" }, {})) + end) end) describe("_warn_once()", function() diff --git a/lua/diffview/ui/panels/commit_log_panel.lua b/lua/diffview/ui/panels/commit_log_panel.lua index bb91b1b2..99c9018c 100644 --- a/lua/diffview/ui/panels/commit_log_panel.lua +++ b/lua/diffview/ui/panels/commit_log_panel.lua @@ -12,6 +12,7 @@ local M = {} ---@class CommitLogPanel : Panel ---@field adapter VCSAdapter ---@field args string[] +---@field paths? string[] ---@field job_out string[] local CommitLogPanel = oop.create_class("CommitLogPanel", Panel) @@ -89,15 +90,27 @@ end ---@param self CommitLogPanel ---@param args string|string[] -CommitLogPanel.update = async.void(function(self, args) - args = args or self.args +---@param paths? string[] # Optional file paths to filter the log by. +CommitLogPanel.update = async.void(function(self, args, paths) + -- A bare `update()` (no explicit args) is treated as a refresh: replay the + -- last displayed query so the panel keeps whatever range + path filter was + -- set. `paths` is only recorded once we know we're actually rendering new + -- content, so a failed or empty log leaves `self.paths` on the last + -- successful state — a subsequent refresh replays *that*, not the failed + -- filter. + if args == nil then + args = self.args + if paths == nil then + paths = self.paths + end + end if type(args) ~= "table" then args = { args } end local job = Job({ command = self.adapter:bin(), - args = self.adapter:get_log_args(args), + args = self.adapter:get_log_args(args, paths), cwd = self.adapter.ctx.toplevel, }) @@ -116,6 +129,8 @@ CommitLogPanel.update = async.void(function(self, args) return end + self.paths = paths + if not self:is_open() then self:init_buffer() else diff --git a/lua/diffview/vcs/adapter.lua b/lua/diffview/vcs/adapter.lua index 014baee9..b98ba9d7 100644 --- a/lua/diffview/vcs/adapter.lua +++ b/lua/diffview/vcs/adapter.lua @@ -445,8 +445,10 @@ function VCSAdapter:get_show_args(path, rev) end ---@param args string[] +---@param paths? string[] # Optional file paths to filter the log by. ---@return string[] args to show commit log message -function VCSAdapter:get_log_args(args) +---@diagnostic disable-next-line: unused-local +function VCSAdapter:get_log_args(args, paths) oop.abstract_stub() end diff --git a/lua/diffview/vcs/adapters/git/init.lua b/lua/diffview/vcs/adapters/git/init.lua index 404b89f6..ac410e99 100644 --- a/lua/diffview/vcs/adapters/git/init.lua +++ b/lua/diffview/vcs/adapters/git/init.lua @@ -321,8 +321,30 @@ function GitAdapter:get_show_args(path, rev) ) end -function GitAdapter:get_log_args(args) - return utils.vec_join(self:args(), "log", "--no-show-signature", "--first-parent", "--stat", args) +---@param args string[] +---@param paths? string[] # Optional file paths to filter the log by. +---@return string[] +function GitAdapter:get_log_args(args, paths) + -- `git log -- ` interprets the path as a pathspec, so a literal + -- filename with `*`, `?`, `[`, or a leading `:` glob/misfire. `:(literal)` + -- pathspec magic (supported since git 1.9) forces exact match, matching + -- how the jj adapter's `quote_path_args` handles the same concern. + local literal_paths = paths + and #paths > 0 + and vim.tbl_map(function(p) + return ":(literal)" .. p + end, paths) + or nil + return utils.vec_join( + self:args(), + "log", + "--no-show-signature", + "--first-parent", + "--stat", + args, + literal_paths and "--" or nil, + literal_paths + ) end function GitAdapter:get_dir(path) diff --git a/lua/diffview/vcs/adapters/hg/init.lua b/lua/diffview/vcs/adapters/hg/init.lua index a4915019..5d83247e 100644 --- a/lua/diffview/vcs/adapters/hg/init.lua +++ b/lua/diffview/vcs/adapters/hg/init.lua @@ -157,8 +157,30 @@ function HgAdapter:get_show_args(path, rev) return utils.vec_join(self:args(), "cat", "--rev", rev:object_name(), "--", path) end -function HgAdapter:get_log_args(args) - return utils.vec_join(self:args(), "log", "--stat", "--rev", args) +---@param args string[] +---@param paths? string[] # Optional file paths to filter the log by. +---@return string[] +function HgAdapter:get_log_args(args, paths) + -- Mercurial treats bare filename arguments as glob patterns by default + -- (see `hg help patterns`), so a literal filename with `*`, `?`, or `[` + -- would misfire. The `path:` prefix pins the argument to a literal, + -- repo-relative path, matching how the git adapter uses `:(literal)` for + -- the same concern. + local literal_paths = paths + and #paths > 0 + and vim.tbl_map(function(p) + return "path:" .. p + end, paths) + or nil + return utils.vec_join( + self:args(), + "log", + "--stat", + "--rev", + args, + literal_paths and "--" or nil, + literal_paths + ) end function HgAdapter:get_dir(path) diff --git a/lua/diffview/vcs/adapters/jj/init.lua b/lua/diffview/vcs/adapters/jj/init.lua index 39fd815b..8c178ff8 100644 --- a/lua/diffview/vcs/adapters/jj/init.lua +++ b/lua/diffview/vcs/adapters/jj/init.lua @@ -329,8 +329,9 @@ function JjAdapter:get_show_args(path, rev) end ---@param args string[] +---@param paths? string[] # Optional file paths to filter the log by. ---@return string[] -function JjAdapter:get_log_args(args) +function JjAdapter:get_log_args(args, paths) -- `jj log` treats positional arguments as filesets, so non-flag args (the -- revsets `JjRev.to_range` produces) need to go through `-r`. Flags pass -- through unchanged. `self:args()` carries any user-configured global @@ -338,7 +339,11 @@ function JjAdapter:get_log_args(args) -- `get_show_args` builds its invocation. `--no-graph` drops the graph -- glyphs: they add no signal in the `CommitLogPanel` (whose buffer is -- `filetype=git` and can't highlight them) and just prefix every line - -- with `○`/`│`/`~`. + -- with `○`/`│`/`~`. `paths` (if given) are passed through + -- `quote_path_args` so literal filenames with fileset metacharacters + -- (`*`, `~`, `:`, whitespace, ...) match themselves instead of getting + -- interpreted as fileset expressions. The `--` separator before them + -- matches `list_files_at_head` / `file_history_dry_run`. local out = utils.vec_join(self:args(), "log", "--no-graph") for _, arg in ipairs(args) do if vim.startswith(arg, "-") then @@ -347,6 +352,10 @@ function JjAdapter:get_log_args(args) utils.vec_push(out, "-r", arg) end end + if paths and #paths > 0 then + utils.vec_push(out, "--") + utils.vec_extend(out, quote_path_args(paths, self.ctx.toplevel)) + end return out end diff --git a/lua/diffview/vcs/adapters/null/init.lua b/lua/diffview/vcs/adapters/null/init.lua index 962dff0e..ccbafe01 100644 --- a/lua/diffview/vcs/adapters/null/init.lua +++ b/lua/diffview/vcs/adapters/null/init.lua @@ -96,8 +96,10 @@ function NullAdapter:get_show_args(path, rev) end ---@param args table +---@param paths? string[] ---@return string[] -function NullAdapter:get_log_args(args) +---@diagnostic disable-next-line: unused-local +function NullAdapter:get_log_args(args, paths) return {} end diff --git a/lua/diffview/vcs/adapters/p4/init.lua b/lua/diffview/vcs/adapters/p4/init.lua index a9d0d7cf..63299725 100644 --- a/lua/diffview/vcs/adapters/p4/init.lua +++ b/lua/diffview/vcs/adapters/p4/init.lua @@ -225,7 +225,15 @@ end ---Get arguments for `p4 filelog` or `p4 changes`. ---@param args string[] -- This likely contains revision specs or paths -function P4Adapter:get_log_args(args) +---@param paths? string[] # Optional file paths to filter the log by. Not +--- currently honoured; a warning is emitted so `gL` (`open_commit_log_file`) +--- isn't a silent no-op relative to `L` on a Perforce view. +function P4Adapter:get_log_args(args, paths) + if paths and #paths > 0 then + utils.warn( + "Path-filtered commit log is not yet supported for Perforce; showing the range's full log." + ) + end -- This function seems less used directly. History fetching uses specific commands. -- Maybe adapt for `p4 changes`? return utils.vec_join("changes", "-l", args) -- Example: get long description for changes