From e144466103acde6ec8a32dc85711ab3ce0530089 Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Sun, 26 Oct 2025 14:06:35 +0100 Subject: [PATCH 01/77] fix: prevent crash due to nil target in sync_scroll (#550) --- lua/diffview/scene/layout.lua | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lua/diffview/scene/layout.lua b/lua/diffview/scene/layout.lua index cba2b129..439833bb 100644 --- a/lua/diffview/scene/layout.lua +++ b/lua/diffview/scene/layout.lua @@ -305,7 +305,9 @@ function Layout:sync_scroll() end -- Cursor will sometimes move +- the value of 'scrolloff' - api.nvim_win_set_cursor(target.id, cursor) + if target ~= nil then + api.nvim_win_set_cursor(target.id, cursor) + end end M.Layout = Layout From f728b1fb768f1dfb8bb40a0866386edde9fa9841 Mon Sep 17 00:00:00 2001 From: jecaro Date: Sun, 26 Oct 2025 15:30:00 +0100 Subject: [PATCH 02/77] fix(actions): copy hash to unnamed register instead of clipboard (#606) Use the unnamed register (") instead of the system clipboard (+) when copying commit hashes. This is more consistent with Vim conventions and works regardless of clipboard support. Fixes #604 --- lua/diffview/scene/views/file_history/listeners.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/diffview/scene/views/file_history/listeners.lua b/lua/diffview/scene/views/file_history/listeners.lua index c10a4ff3..f4db4209 100644 --- a/lua/diffview/scene/views/file_history/listeners.lua +++ b/lua/diffview/scene/views/file_history/listeners.lua @@ -208,7 +208,7 @@ return function(view) if view.panel:is_focused() then local item = view.panel:get_item_at_cursor() if item then - vim.fn.setreg("+", item.commit.hash) + vim.fn.setreg('"', item.commit.hash) utils.info(string.format("Copied '%s' to the clipboard.", item.commit.hash)) end end From 3990835ccbb96ca1c9969938b5cc05a0877bf8e3 Mon Sep 17 00:00:00 2001 From: Naxdy Date: Sat, 1 Nov 2025 10:15:00 +0100 Subject: [PATCH 03/77] fix(git): disable GPG signatures for log/show commands (#540) Add --no-show-signature flag to git log and show commands to prevent GPG signature output from interfering with parsing. This fixes issues when users have log.showSignature enabled in their git config. Fixes #537 --- lua/diffview/vcs/adapters/git/init.lua | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/lua/diffview/vcs/adapters/git/init.lua b/lua/diffview/vcs/adapters/git/init.lua index 3a137bc9..543e610d 100644 --- a/lua/diffview/vcs/adapters/git/init.lua +++ b/lua/diffview/vcs/adapters/git/init.lua @@ -267,11 +267,11 @@ end ---@param path string ---@param rev Rev? function GitAdapter:get_show_args(path, rev) - return utils.vec_join(self:args(), "show", fmt("%s:%s", rev and rev:object_name() or "", path)) + return utils.vec_join(self:args(), "show", "--no-show-signature", fmt("%s:%s", rev and rev:object_name() or "", path)) end function GitAdapter:get_log_args(args) - return utils.vec_join("log", "--first-parent", "--stat", args) + return utils.vec_join("log", "--no-show-signature", "--first-parent", "--stat", args) end function GitAdapter:get_dir(path) @@ -311,14 +311,14 @@ function GitAdapter:get_merge_context() end local ret = {} - local out, code = self:exec_sync({ "show", "-s", "--pretty=format:%H%n%D", "HEAD", "--" }, self.ctx.toplevel) + local out, code = self:exec_sync({ "show", "-s", "--pretty=format:%H%n%D", "HEAD", "--no-show-signature", "--" }, self.ctx.toplevel) ret.ours = code ~= 0 and {} or { hash = out[1], ref_names = out[2], } - out, code = self:exec_sync({ "show", "-s", "--pretty=format:%H%n%D", their_head, "--" }, self.ctx.toplevel) + out, code = self:exec_sync({ "show", "-s", "--pretty=format:%H%n%D", their_head, "--no-show-signature", "--" }, self.ctx.toplevel) ret.theirs = code ~= 0 and {} or { hash = out[1], @@ -330,7 +330,7 @@ function GitAdapter:get_merge_context() ret.base = { hash = out[1], - ref_names = self:exec_sync({ "show", "-s", "--pretty=format:%D", out[1] }, self.ctx.toplevel)[1], + ref_names = self:exec_sync({ "show", "-s", "--pretty=format:%D", out[1], "--no-show-signature" }, self.ctx.toplevel)[1], } return ret @@ -570,6 +570,7 @@ function GitAdapter:stream_fh_data(state) "-c", "core.quotePath=false", "log", + "--no-show-signature", "--pretty=format:%x00%n" .. GitAdapter.COMMIT_PRETTY_FMT, "--numstat", "--raw", @@ -649,6 +650,7 @@ function GitAdapter:stream_line_trace_data(state) "-c", "core.quotePath=false", "log", + "--no-show-signature", "--color=never", "--no-ext-diff", "--pretty=format:%x00%n" .. GitAdapter.COMMIT_PRETTY_FMT, @@ -720,6 +722,7 @@ function GitAdapter:file_history_dry_run(log_opt) else cmd = utils.vec_join( "log", + "--no-show-signature", "--pretty=format:%H", "--name-status", options, @@ -1036,6 +1039,7 @@ GitAdapter.fh_retry_commit = async.wrap(function(self, rev_arg, state, opt, call "--pretty=format:" .. GitAdapter.COMMIT_PRETTY_FMT, "--numstat", "--raw", + "--no-show-signature", "--diff-merges=" .. state.log_options.diff_merges, (state.single_file and state.log_options.follow) and "--follow" or nil, rev_arg, From 75cf1061955d41040fdb3b33b73dfec7787ecfa6 Mon Sep 17 00:00:00 2001 From: bryankenote Date: Sat, 8 Nov 2025 14:20:00 +0100 Subject: [PATCH 04/77] fix(path): only replace $ with env var if value defined (#557) Only expand environment variables in paths when the variable is actually defined. Previously, undefined variables would be replaced with their literal name (e.g., $FOO -> FOO), which could cause unexpected behaviour with paths containing dollar signs. Fixes #556 --- lua/diffview/path.lua | 4 ++-- lua/diffview/tests/functional/pathlib_spec.lua | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lua/diffview/path.lua b/lua/diffview/path.lua index 3fc4b20f..101e123c 100644 --- a/lua/diffview/path.lua +++ b/lua/diffview/path.lua @@ -295,8 +295,8 @@ function PathLib:expand(path) for i = idx, #segments do local env_var = segments[i]:match("^%$(%S+)$") - if env_var then - segments[i] = uv.os_getenv(env_var) or env_var + if env_var and uv.os_getenv(env_var) ~= nil then + segments[i] = uv.os_getenv(env_var) end end diff --git a/lua/diffview/tests/functional/pathlib_spec.lua b/lua/diffview/tests/functional/pathlib_spec.lua index 4ace9661..7c451578 100644 --- a/lua/diffview/tests/functional/pathlib_spec.lua +++ b/lua/diffview/tests/functional/pathlib_spec.lua @@ -248,7 +248,7 @@ describe("diffview.path", function() local pl = PathLib({ os = "unix" }) eq("/lorem/ipsum/dolor/foo", pl:expand("~/foo")) - eq("foo/EXPANDED_FOO/EXPANDED_BAR/baz", pl:expand("foo/$VAR_FOO/$VAR_BAR/baz")) + eq("foo/EXPANDED_FOO/EXPANDED_BAR/$baz", pl:expand("foo/$VAR_FOO/$VAR_BAR/$baz")) end) end) From b5697ded32a43445d43f2be0240977427086bf55 Mon Sep 17 00:00:00 2001 From: Kenta Yamaguchi Date: Sat, 15 Nov 2025 11:45:00 +0100 Subject: [PATCH 05/77] fix(git): show untracked files when comparing working tree (#587) Show untracked files when the right side of the comparison is the working tree (LOCAL), not just when comparing index vs working tree. This allows seeing untracked files in more diff scenarios. Fixes #584 --- lua/diffview/vcs/adapters/git/init.lua | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lua/diffview/vcs/adapters/git/init.lua b/lua/diffview/vcs/adapters/git/init.lua index 543e610d..48599e8a 100644 --- a/lua/diffview/vcs/adapters/git/init.lua +++ b/lua/diffview/vcs/adapters/git/init.lua @@ -1674,9 +1674,8 @@ function GitAdapter:show_untracked(opt) opt = opt or {} if opt.revs then - -- Never show untracked files when comparing against anything other than - -- the index - if not (opt.revs.left.type == RevType.STAGE and opt.revs.right.type == RevType.LOCAL) then + -- Show untracked files when comparing against the working tree (LOCAL) + if opt.revs.right.type ~= RevType.LOCAL then return false end end From bc0896716626078de9caeb3215f7cdf08362ddd4 Mon Sep 17 00:00:00 2001 From: Krivoshapkin Eduard Date: Sat, 22 Nov 2025 16:30:00 +0100 Subject: [PATCH 06/77] fix(git): handle merge-base failure during rebase --root (#577) When running `git rebase -i --root`, the initial commit has no parent, so `git merge-base` fails. Instead of crashing with an assertion error, gracefully handle this by using the empty tree SHA as the base. Fixes #576 --- lua/diffview/vcs/adapters/git/init.lua | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/lua/diffview/vcs/adapters/git/init.lua b/lua/diffview/vcs/adapters/git/init.lua index 48599e8a..a44f36c3 100644 --- a/lua/diffview/vcs/adapters/git/init.lua +++ b/lua/diffview/vcs/adapters/git/init.lua @@ -326,12 +326,16 @@ function GitAdapter:get_merge_context() } out, code = self:exec_sync({ "merge-base", "HEAD", their_head }, self.ctx.toplevel) - assert(code == 0) - - ret.base = { - hash = out[1], - ref_names = self:exec_sync({ "show", "-s", "--pretty=format:%D", out[1], "--no-show-signature" }, self.ctx.toplevel)[1], - } + if code ~= 0 then + -- merge-base can fail during --root rebases for initial commits. + -- Use the canonical empty tree SHA as the base. + ret.base = { hash = self.Rev.NULL_TREE_SHA, ref_names = nil } + else + ret.base = { + hash = out[1], + ref_names = self:exec_sync({ "show", "-s", "--no-show-signature", "--pretty=format:%D", out[1] }, self.ctx.toplevel)[1], + } + end return ret end From 2e106b786f2b6f0c8daae6992dcf91b47714ece6 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Sat, 29 Nov 2025 13:15:00 +0100 Subject: [PATCH 07/77] feat: add mini.icons support as file icons provider (#571) Add support for mini.icons as an alternative to nvim-web-devicons for file icons. The plugin will try nvim-web-devicons first, then fall back to mini.icons if available. --- README.md | 2 +- doc/diffview_defaults.txt | 2 +- lua/diffview/health.lua | 4 ++++ lua/diffview/hl.lua | 16 ++++++++++++---- 4 files changed, 18 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 9298e1f5..bccee4a6 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ for any git rev. - Git ≥ 2.31.0 (for Git support) - Mercurial ≥ 5.4.0 (for Mercurial support) - Neovim ≥ 0.7.0 (with LuaJIT) -- [nvim-web-devicons](https://github.com/nvim-tree/nvim-web-devicons) (optional) For file icons +- [nvim-web-devicons](https://github.com/nvim-tree/nvim-web-devicons) or [mini.icons](https://github.com/echasnovski/mini.nvim/blob/main/readmes/mini-icons.md) (optional) for file icons ## Installation diff --git a/doc/diffview_defaults.txt b/doc/diffview_defaults.txt index 1ff1f7db..0b60b064 100644 --- a/doc/diffview_defaults.txt +++ b/doc/diffview_defaults.txt @@ -7,7 +7,7 @@ DEFAULT CONFIG *diffview.defaults* enhanced_diff_hl = false, -- See |diffview-config-enhanced_diff_hl| git_cmd = { "git" }, -- The git executable followed by default args. hg_cmd = { "hg" }, -- The hg executable followed by default args. - use_icons = true, -- Requires nvim-web-devicons + use_icons = true, -- Requires nvim-web-devicons or mini.icons show_help_hints = true, -- Show hints for how to open the help panel watch_index = true, -- Update views and index buffers when the git index changes. icons = { -- Only applies when use_icons is true. diff --git a/lua/diffview/health.lua b/lua/diffview/health.lua index 9c67c42b..3583c5c1 100644 --- a/lua/diffview/health.lua +++ b/lua/diffview/health.lua @@ -19,6 +19,10 @@ M.plugin_deps = { name = "nvim-web-devicons", optional = true, }, + { + name = "mini.icons", + optional = true, + }, } ---@param cmd string|string[] diff --git a/lua/diffview/hl.lua b/lua/diffview/hl.lua index 693dd358..970fe775 100644 --- a/lua/diffview/hl.lua +++ b/lua/diffview/hl.lua @@ -4,7 +4,7 @@ local config = lazy.require("diffview.config") ---@module "diffview.config" local utils = lazy.require("diffview.utils") ---@module "diffview.utils" local api = vim.api -local web_devicons +local web_devicons, mini_icons local icon_cache = {} local M = {} @@ -349,14 +349,19 @@ end function M.get_file_icon(name, ext, render_data, line_idx, offset) if not config.get_config().use_icons then return "" end - if not web_devicons then + if not (web_devicons or mini_icons) then local ok ok, web_devicons = pcall(require, "nvim-web-devicons") + if not ok then + ok, mini_icons = pcall(require, "mini.icons") + web_devicons = nil + end + if not ok then config.get_config().use_icons = false utils.warn( - "nvim-web-devicons is required to use file icons! " + "nvim-web-devicons or mini.icons is required to use file icons! " .. "Set `use_icons = false` in your config to stop seeing this message." ) @@ -369,9 +374,12 @@ function M.get_file_icon(name, ext, render_data, line_idx, offset) if icon_cache[icon_key] then icon, hl = unpack(icon_cache[icon_key]) - else + elseif web_devicons then icon, hl = web_devicons.get_icon(name, ext, { default = true }) icon_cache[icon_key] = { icon, hl } + else + icon, hl = mini_icons.get("file", name) + icon_cache[icon_key] = { icon, hl } end if icon then From 1b386ce4715ba0fd3493ba188b48bdd07fef8393 Mon Sep 17 00:00:00 2001 From: Kevin Traver Date: Sat, 6 Dec 2025 10:45:00 +0100 Subject: [PATCH 08/77] feat: add close keymaps (q, ) to commit log panel (#482) Add q and keymaps to close the commit log panel, matching the behaviour of other panels like the help panel. The keymaps are configurable via keymaps.commit_log_panel. Fixes #481 --- lua/diffview/config.lua | 4 ++++ lua/diffview/scene/views/diff/diff_view.lua | 2 +- .../views/file_history/file_history_view.lua | 2 +- lua/diffview/ui/panels/commit_log_panel.lua | 17 ++++++++++++++++- 4 files changed, 22 insertions(+), 3 deletions(-) diff --git a/lua/diffview/config.lua b/lua/diffview/config.lua index f1f6d0f1..670bd5a2 100644 --- a/lua/diffview/config.lua +++ b/lua/diffview/config.lua @@ -255,6 +255,10 @@ M.defaults = { { "n", "q", actions.close, { desc = "Close help menu" } }, { "n", "", actions.close, { desc = "Close help menu" } }, }, + commit_log_panel = { + { "n", "q", actions.close, { desc = "Close commit log" } }, + { "n", "", actions.close, { desc = "Close commit log" } }, + }, }, } -- stylua: ignore end diff --git a/lua/diffview/scene/views/diff/diff_view.lua b/lua/diffview/scene/views/diff/diff_view.lua index 84d99413..cec76d70 100644 --- a/lua/diffview/scene/views/diff/diff_view.lua +++ b/lua/diffview/scene/views/diff/diff_view.lua @@ -82,7 +82,7 @@ end function DiffView:post_open() vim.cmd("redraw") - self.commit_log_panel = CommitLogPanel(self.adapter, { + self.commit_log_panel = CommitLogPanel(self, self.adapter, { name = fmt("diffview://%s/log/%d/%s", self.adapter.ctx.dir, self.tabpage, "commit_log"), }) diff --git a/lua/diffview/scene/views/file_history/file_history_view.lua b/lua/diffview/scene/views/file_history/file_history_view.lua index 1710b942..fd1c6093 100644 --- a/lua/diffview/scene/views/file_history/file_history_view.lua +++ b/lua/diffview/scene/views/file_history/file_history_view.lua @@ -40,7 +40,7 @@ function FileHistoryView:init(opt) end function FileHistoryView:post_open() - self.commit_log_panel = CommitLogPanel(self.adapter, { + self.commit_log_panel = CommitLogPanel(self, self.adapter, { name = ("diffview://%s/log/%d/%s"):format(self.adapter.ctx.dir, self.tabpage, "commit_log"), }) diff --git a/lua/diffview/ui/panels/commit_log_panel.lua b/lua/diffview/ui/panels/commit_log_panel.lua index 1160a261..12c6068e 100644 --- a/lua/diffview/ui/panels/commit_log_panel.lua +++ b/lua/diffview/ui/panels/commit_log_panel.lua @@ -51,7 +51,7 @@ end ---@param adapter VCSAdapter ---@param opt CommitLogPanelSpec -function CommitLogPanel:init(adapter, opt) +function CommitLogPanel:init(parent, adapter, opt) self:super({ bufname = opt.name, config = opt.config or get_user_config().commit_log_panel.win_config, @@ -65,6 +65,21 @@ function CommitLogPanel:init(adapter, opt) vim.bo[self.bufid].bufhidden = "wipe" end, }) + + local conf = get_user_config().keymaps + local default_opt = { silent = true, nowait = true, buffer = self.bufid } + + for _, mapping in ipairs(conf.commit_log_panel) do + local map_opt = vim.tbl_extend("force", default_opt, mapping[4] or {}, { buffer = self.bufid }) + vim.keymap.set(mapping[1], mapping[2], mapping[3], map_opt) + end + + parent.emitter:on("close", function(e) + if self:is_focused() then + self:close() + e:stop_propagation() + end + end) end ---@param self CommitLogPanel From 3e2732294cd982290cc2d2b21b58beb91e257616 Mon Sep 17 00:00:00 2001 From: Nick deLannoy Date: Thu, 27 Jun 2024 12:28:21 -0500 Subject: [PATCH 09/77] feat: add `:DiffviewToggle` command (#517) --- lua/diffview/init.lua | 11 +++++++++++ plugin/diffview.lua | 4 ++++ 2 files changed, 15 insertions(+) diff --git a/lua/diffview/init.lua b/lua/diffview/init.lua index 4063f71e..0055d650 100644 --- a/lua/diffview/init.lua +++ b/lua/diffview/init.lua @@ -133,6 +133,7 @@ function M.open(args) end end + ---@param range? { [1]: integer, [2]: integer } ---@param args string[] function M.file_history(range, args) @@ -157,6 +158,16 @@ function M.close(tabpage) end end +-- @param args string[] +function M.toggle(args) + local view = lib.get_current_view() + if view then + M.close() + else + M.open(args) + end +end + function M.completion(_, cmd_line, cur_pos) local ctx = arg_parser.scan(cmd_line, { cur_pos = cur_pos, allow_ex_range = true }) local cmd = ctx.args[1] diff --git a/plugin/diffview.lua b/plugin/diffview.lua index c7eeb54b..f402d57d 100644 --- a/plugin/diffview.lua +++ b/plugin/diffview.lua @@ -24,6 +24,10 @@ command("DiffviewOpen", function(ctx) diffview.open(arg_parser.scan(ctx.args).args) end, { nargs = "*", complete = completion }) +command("DiffviewToggle", function(ctx) + diffview.toggle(arg_parser.scan(ctx.args).args) +end, { nargs = "*", complete = completion }) + command("DiffviewFileHistory", function(ctx) local range From 478f726ac9f28f96d82d85ba8570192bf0b3e227 Mon Sep 17 00:00:00 2001 From: Nick deLannoy Date: Thu, 27 Jun 2024 12:48:26 -0500 Subject: [PATCH 10/77] docs: add `:DiffviewToggle` docs (#517) --- doc/diffview.txt | 13 ++++++++++--- lua/diffview/init.lua | 1 - 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/doc/diffview.txt b/doc/diffview.txt index 3a564c12..f76ab945 100644 --- a/doc/diffview.txt +++ b/doc/diffview.txt @@ -18,12 +18,12 @@ for any git rev. USAGE *diffview-usage* -Quick-start: `:DiffviewOpen` to open a Diffview that compares against the +Quick-start: `:DiffviewOpen` or `:DiffviewToggle` to open a Diffview that compares against the index. You can have multiple Diffviews open, tracking different git revs. Each Diffview -opens in its own tabpage. To close and dispose of a Diffview, call either -`:DiffviewClose` or `:tabclose` while a Diffview is the current tabpage. +opens in its own tabpage. To close and dispose of a Diffview, call +`:DiffviewClose`, `:DiffviewToggle` or `:tabclose` while a Diffview is the current tabpage. Diffviews are automatically updated: • Every time you enter a Diffview @@ -370,6 +370,13 @@ COMMANDS *diffview-commands* *:DiffviewClose* :DiffviewClose Close the active Diffview. + *:DiffviewToggle* +:DiffviewToggle [git-rev] [options] [ -- {paths...}] + + Alias for `:DiffviewOpen` when the current tabpage is not a Diffview, + otherwise acts as an alias for `:DiffviewClose`. Accepts the same + arguments as `:DiffviewOpen`. + *:DiffviewToggleFiles* :DiffviewToggleFiles Toggles the file panel. diff --git a/lua/diffview/init.lua b/lua/diffview/init.lua index 0055d650..938f4e8f 100644 --- a/lua/diffview/init.lua +++ b/lua/diffview/init.lua @@ -133,7 +133,6 @@ function M.open(args) end end - ---@param range? { [1]: integer, [2]: integer } ---@param args string[] function M.file_history(range, args) From 1451a46ff850d9ee9cc1c34fad55ede563219080 Mon Sep 17 00:00:00 2001 From: Kenta Yamaguchi Date: Tue, 15 Jul 2025 01:50:59 +0900 Subject: [PATCH 11/77] feat(git): add `--merge-base` option (#590) --- lua/diffview/vcs/adapters/git/init.lua | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/lua/diffview/vcs/adapters/git/init.lua b/lua/diffview/vcs/adapters/git/init.lua index a44f36c3..7dc3802c 100644 --- a/lua/diffview/vcs/adapters/git/init.lua +++ b/lua/diffview/vcs/adapters/git/init.lua @@ -1276,6 +1276,7 @@ function GitAdapter:diffview_options(argo) local left, right = self:parse_revs(rev_arg, { cached = argo:get_flag({ "cached", "staged" }), imply_local = argo:get_flag("imply-local"), + merge_base = argo:get_flag("merge-base"), }) if not (left and right) then @@ -1453,10 +1454,26 @@ function GitAdapter:parse_revs(rev_arg, opt) end else local hash = rev_strings[1]:gsub("^%^", "") - left = GitRev(RevType.COMMIT, hash) if opt.cached then + left = GitRev(RevType.COMMIT, hash) right = GitRev(RevType.STAGE, 0) else + -- When comparing a single ref with working tree, optionally use merge-base + if opt.merge_base then + local merge_base_out, merge_base_code = self:exec_sync( + { "merge-base", "HEAD", hash }, + { cwd = self.ctx.toplevel, fail_on_empty = true, retry = 2 } + ) + if merge_base_code == 0 and #merge_base_out > 0 then + -- Use merge-base as the left side + left = GitRev(RevType.COMMIT, merge_base_out[1]) + else + -- Fallback to the ref itself if merge-base fails + left = GitRev(RevType.COMMIT, hash) + end + else + left = GitRev(RevType.COMMIT, hash) + end right = GitRev(RevType.LOCAL) end end @@ -2127,6 +2144,7 @@ function GitAdapter:init_completion() self.comp.open:put({ "u", "untracked-files" }, { "true", "normal", "all", "false", "no" }) self.comp.open:put({ "cached", "staged" }) self.comp.open:put({ "imply-local" }) + self.comp.open:put({ "merge-base" }) self.comp.open:put({ "C" }, function(_, arg_lead) return vim.fn.getcompletion(arg_lead, "dir") end) From 47b9d9e8fa202e2136b2b2bcaa6f2f020cc097ae Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Sat, 17 Jan 2026 20:14:47 +0100 Subject: [PATCH 12/77] fix(help_panel): prevent nil index error on first line Add nil check for comp.context before accessing mapping property. Pressing on the first line of the help panel no longer errors. Fixes #469 --- lua/diffview/ui/panels/help_panel.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/diffview/ui/panels/help_panel.lua b/lua/diffview/ui/panels/help_panel.lua index 97bf5731..4d3d3f83 100644 --- a/lua/diffview/ui/panels/help_panel.lua +++ b/lua/diffview/ui/panels/help_panel.lua @@ -81,7 +81,7 @@ function HelpPanel:apply_cmd() local row, _ = unpack(vim.api.nvim_win_get_cursor(0)) local comp = self.components.comp:get_comp_on_line(row) - if comp then + if comp and comp.context then local mapping = comp.context.mapping local last_winid = vim.fn.win_getid(vim.fn.winnr("#")) From 7aab651e590aeb962932b6db17538cdf5cb29043 Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Sat, 10 Jan 2026 14:10:57 +0100 Subject: [PATCH 13/77] fix(lib): ensure bootstrap runs before accessing DiffviewGlobal Requiring diffview.lib directly without going through diffview.bootstrap would fail with "attempt to index global 'DiffviewGlobal' (a nil value)". Now lib.lua explicitly requires bootstrap to ensure initialisation. Fixes #511 --- lua/diffview/lib.lua | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lua/diffview/lib.lua b/lua/diffview/lib.lua index ae8ea605..be9f1b8f 100644 --- a/lua/diffview/lib.lua +++ b/lua/diffview/lib.lua @@ -1,5 +1,8 @@ local lazy = require("diffview.lazy") +-- Ensure bootstrap has run before accessing DiffviewGlobal. +require("diffview.bootstrap") + local DiffView = lazy.access("diffview.scene.views.diff.diff_view", "DiffView") ---@type DiffView|LazyModule local FileHistoryView = lazy.access("diffview.scene.views.file_history.file_history_view", "FileHistoryView") ---@type FileHistoryView|LazyModule local StandardView = lazy.access("diffview.scene.views.standard.standard_view", "StandardView") ---@type StandardView|LazyModule From 0c48e02dfba3b5ae78b79d028870738091566f65 Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Sun, 25 Jan 2026 21:01:59 +0100 Subject: [PATCH 14/77] fix(hl): ensure diff.vim highlights are loaded before linking Some colorschemes don't define diffAdded/diffRemoved/diffChanged until the diff filetype is encountered. Now we explicitly load the diff syntax file if these highlight groups don't exist, ensuring our highlight links work correctly. Fixes #351 --- lua/diffview/hl.lua | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lua/diffview/hl.lua b/lua/diffview/hl.lua index 970fe775..a0df60d5 100644 --- a/lua/diffview/hl.lua +++ b/lua/diffview/hl.lua @@ -490,6 +490,13 @@ function M.update_diff_hl() end function M.setup() + -- Ensure diff highlights are defined by loading the diff syntax if needed. + -- Some colorschemes don't set diffAdded/diffRemoved/diffChanged until the + -- diff filetype is encountered. + if vim.fn.hlexists("diffAdded") == 0 then + vim.cmd("runtime! syntax/diff.vim") + end + for name, v in pairs(M.get_hl_groups()) do v = vim.tbl_extend("force", v, { default = true }) M.hi("Diffview" .. name, v) From ec3349d8b97d9174c8e359dfdf37ad336a049360 Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Sun, 11 Jan 2026 19:06:44 +0100 Subject: [PATCH 15/77] feat(ui): add trailing slash to folder names in file tree Makes it more visually clear which items are directories, especially when not using folder icons. Closes #247 --- lua/diffview/scene/views/diff/render.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/diffview/scene/views/diff/render.lua b/lua/diffview/scene/views/diff/render.lua index 3790a555..5acc1618 100644 --- a/lua/diffview/scene/views/diff/render.lua +++ b/lua/diffview/scene/views/diff/render.lua @@ -104,7 +104,7 @@ local function render_file_tree_recurse(depth, comp) ) end - dir:add_text(ctx.name, "DiffviewFolderName") + dir:add_text(ctx.name .. "/", "DiffviewFolderName") dir:ln() if not ctx.collapsed then From 0c944fc044e89a6ed60ecadab26452aaea306f00 Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Sat, 24 Jan 2026 12:04:10 +0100 Subject: [PATCH 16/77] feat(config): add file_panel.show option to hide panel by default Users can now set `file_panel.show = false` to have the file panel hidden by default when opening Diffview. The panel can still be toggled using the toggle_files action. Closes #303 --- doc/diffview_defaults.txt | 1 + lua/diffview/config.lua | 1 + lua/diffview/scene/views/standard/standard_view.lua | 3 ++- 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/doc/diffview_defaults.txt b/doc/diffview_defaults.txt index 0b60b064..619837ab 100644 --- a/doc/diffview_defaults.txt +++ b/doc/diffview_defaults.txt @@ -60,6 +60,7 @@ DEFAULT CONFIG *diffview.defaults* width = 35, win_opts = {}, }, + show = true, -- Show the file panel when opening Diffview. }, file_history_panel = { log_options = { -- See |diffview-config-log_options| diff --git a/lua/diffview/config.lua b/lua/diffview/config.lua index 670bd5a2..7dadd376 100644 --- a/lua/diffview/config.lua +++ b/lua/diffview/config.lua @@ -81,6 +81,7 @@ M.defaults = { width = 35, win_opts = {} }, + show = true, -- Show the file panel by default when opening Diffview. }, file_history_panel = { log_options = { diff --git a/lua/diffview/scene/views/standard/standard_view.lua b/lua/diffview/scene/views/standard/standard_view.lua index f88a1dd7..0f25bd75 100644 --- a/lua/diffview/scene/views/standard/standard_view.lua +++ b/lua/diffview/scene/views/standard/standard_view.lua @@ -61,7 +61,8 @@ function StandardView:init_layout() api.nvim_win_close(curwin, false) end - self.panel:focus() + local show_panel = config.get_config().file_panel.show + self.panel:focus(not show_panel) self.emitter:emit("post_layout") end From bc90682f49ecc8d3a75b9a3cd56ad4564a04870c Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Sun, 18 Jan 2026 17:00:31 +0100 Subject: [PATCH 17/77] perf(git): set GIT_OPTIONAL_LOCKS=0 to reduce lock contention When multiple git operations run concurrently (e.g., staging files while other plugins like nvim-tree also make git calls), lock contention can cause significant slowdowns. Setting GIT_OPTIONAL_LOCKS=0 tells git to skip optional locking, improving performance. Fixes #535 --- lua/diffview/job.lua | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lua/diffview/job.lua b/lua/diffview/job.lua index baa20cd4..da26a1d4 100644 --- a/lua/diffview/job.lua +++ b/lua/diffview/job.lua @@ -49,7 +49,10 @@ local M = {} local Job = oop.create_class("Job", async.Waitable) local function prepare_env(env) - local ret = {} + -- Always set GIT_OPTIONAL_LOCKS=0 to avoid lock contention when multiple + -- git operations run concurrently. This prevents slowdowns when staging + -- files while other plugins also make git calls. + local ret = { "GIT_OPTIONAL_LOCKS=0" } for k, v in pairs(env) do table.insert(ret, k .. "=" .. v) From 59237ad8f4e996b6373e191070d90f578d339634 Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Sat, 24 Jan 2026 14:17:57 +0100 Subject: [PATCH 18/77] feat(actions): add set_layout for specific layout selection Allows users to bind keys to specific layouts directly instead of cycling through all layouts. --- lua/diffview/actions.lua | 68 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/lua/diffview/actions.lua b/lua/diffview/actions.lua index 6b89e1fa..f9e1dd3d 100644 --- a/lua/diffview/actions.lua +++ b/lua/diffview/actions.lua @@ -554,6 +554,74 @@ function M.cycle_layout() end end +---@type table +local layout_name_map = { + diff1_plain = Diff1, + diff2_horizontal = Diff2Hor, + diff2_vertical = Diff2Ver, + diff3_horizontal = Diff3Hor, + diff3_vertical = Diff3Ver, + diff3_mixed = Diff3Mixed, + diff4_mixed = Diff4Mixed, +} + +---Set a specific layout for the current view. +---@param layout_name string One of: diff1_plain, diff2_horizontal, diff2_vertical, diff3_horizontal, diff3_vertical, diff3_mixed, diff4_mixed +function M.set_layout(layout_name) + return function() + local layout_class = layout_name_map[layout_name] + if not layout_class then + utils.err(("Unknown layout: '%s'. See ':h diffview-config-view.x.layout' for valid layouts."):format(layout_name)) + return + end + + local view = lib.get_current_view() + if not view then return end + + local files, cur_file + + if view:instanceof(FileHistoryView.__get()) then + ---@cast view FileHistoryView + files = view.panel:list_files() + cur_file = view:cur_file() + elseif view:instanceof(DiffView.__get()) then + ---@cast view DiffView + cur_file = view.cur_entry + if cur_file then + files = cur_file.kind == "conflicting" + and view.files.conflicting + or utils.vec_join(view.panel.files.working, view.panel.files.staged) + end + else + return + end + + if not files then return end + + local target_layout = layout_class.__get() + + for _, entry in ipairs(files) do + entry:convert_layout(target_layout) + end + + if cur_file then + local main = view.cur_layout:get_main_win() + local pos = api.nvim_win_get_cursor(main.id) + local was_focused = view.cur_layout:is_focused() + + cur_file.layout.emitter:once("files_opened", function() + utils.set_cursor(main.id, unpack(pos)) + if not was_focused then view.cur_layout:sync_scroll() end + end) + + view:set_file(cur_file, false) + main = view.cur_layout:get_main_win() + + if was_focused then main:focus() end + end + end +end + ---@param keymap_groups string|string[] function M.help(keymap_groups) keymap_groups = type(keymap_groups) == "table" and keymap_groups or { keymap_groups } From fb4788ba941645ea41489759ba342f623ffd0d31 Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Sat, 31 Jan 2026 11:38:12 +0100 Subject: [PATCH 19/77] fix(hl): remove redundant FilePanelFileName definition The explicit definition with a "White" fallback caused invisible filenames on light colorschemes. FilePanelFileName is already linked to Normal in hl_links, which correctly inherits the theme's text color. --- lua/diffview/hl.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/diffview/hl.lua b/lua/diffview/hl.lua index a0df60d5..d1d4626b 100644 --- a/lua/diffview/hl.lua +++ b/lua/diffview/hl.lua @@ -431,7 +431,7 @@ function M.get_hl_groups() return { FilePanelTitle = { fg = M.get_fg("Label") or colors.blue, style = "bold" }, FilePanelCounter = { fg = M.get_fg("Identifier") or colors.purple, style = "bold" }, - FilePanelFileName = { fg = M.get_fg("Normal") or colors.white }, + -- FilePanelFileName is linked to Normal in hl_links. Dim1 = { fg = M.get_fg("Comment") or colors.white }, Primary = { fg = M.get_fg("Function") or "Purple" }, Secondary = { fg = M.get_fg("String") or "Orange" }, From be1a7085097038f69b3aa5fd88c475389a11df47 Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Sun, 11 Jan 2026 14:35:06 +0100 Subject: [PATCH 20/77] fix(hl): use DiffviewFolderSign for FileHistory fold indicators Use consistent highlighting for fold indicators across all views. Previously FileHistory used CursorLineNr which didn't match the rest of the UI. --- lua/diffview/scene/views/file_history/render.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/diffview/scene/views/file_history/render.lua b/lua/diffview/scene/views/file_history/render.lua index 24b13970..120b6ccc 100644 --- a/lua/diffview/scene/views/file_history/render.lua +++ b/lua/diffview/scene/views/file_history/render.lua @@ -85,7 +85,7 @@ local function render_entries(panel, parent, entries, updating) local comp = entry_struct.commit.comp if not entry.single_file then - comp:add_text((entry.folded and c.signs.fold_closed or c.signs.fold_open) .. " ", "CursorLineNr") + comp:add_text((entry.folded and c.signs.fold_closed or c.signs.fold_open) .. " ", "DiffviewFolderSign") end if entry.status then From b4994a628371aa64ae7b3326b7bd400c21a6137e Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Sat, 10 Jan 2026 19:46:47 +0100 Subject: [PATCH 21/77] feat(config): add commit_subject_max_length option Allow users to configure the maximum length for commit subject display in the file history panel. Defaults to 72 characters. --- lua/diffview/config.lua | 1 + lua/diffview/scene/views/file_history/render.lua | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/lua/diffview/config.lua b/lua/diffview/config.lua index 7dadd376..9eb0591f 100644 --- a/lua/diffview/config.lua +++ b/lua/diffview/config.lua @@ -106,6 +106,7 @@ M.defaults = { height = 16, win_opts = {} }, + commit_subject_max_length = 72, -- Max length for commit subject display. }, commit_log_panel = { win_config = { diff --git a/lua/diffview/scene/views/file_history/render.lua b/lua/diffview/scene/views/file_history/render.lua index 120b6ccc..77459443 100644 --- a/lua/diffview/scene/views/file_history/render.lua +++ b/lua/diffview/scene/views/file_history/render.lua @@ -142,7 +142,10 @@ local function render_entries(panel, parent, entries, updating) comp:add_text((" (%s)"):format(entry.commit.ref_names), "DiffviewReference") end - local subject = utils.str_trunc(entry.commit.subject, 72) + local subject = utils.str_trunc( + entry.commit.subject, + config.get_config().file_history_panel.commit_subject_max_length + ) if subject == "" then subject = "[empty message]" From 4ccbaa23eba1446b79466eea7da2f919098c139d Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Sat, 24 Jan 2026 15:59:01 +0100 Subject: [PATCH 22/77] docs: add recommended keymaps and workflow tips Added "Recommended Keymaps" section with common patterns: - Toggle diffview open/close - File/line/range history - Diff against main/master branch Added tips for merge-base comparisons, Neogit integration, and line evolution tracing. --- README.md | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/README.md b/README.md index bccee4a6..66615442 100644 --- a/README.md +++ b/README.md @@ -503,6 +503,45 @@ restore the file to the state from the left side of the diff (key binding `X` from the file panel by default). The current state of the file is stored in the git object database, and a command is echoed that shows how to undo the change. +## Recommended Keymaps + +These keymaps are commonly used patterns for working with diffview: + +```lua +-- Toggle diffview open/close +vim.keymap.set('n', 'dv', function() + if next(require('diffview.lib').views) == nil then + vim.cmd('DiffviewOpen') + else + vim.cmd('DiffviewClose') + end +end, { desc = 'Toggle Diffview' }) + +-- Diff working directory +vim.keymap.set('n', 'do', 'DiffviewOpen', { desc = 'Diffview open' }) +vim.keymap.set('n', 'dc', 'DiffviewClose', { desc = 'Diffview close' }) + +-- File history +vim.keymap.set('n', 'dh', 'DiffviewFileHistory %', { desc = 'File history (current file)' }) +vim.keymap.set('n', 'dH', 'DiffviewFileHistory', { desc = 'File history (repo)' }) + +-- Visual mode: history for selection +vim.keymap.set('v', 'dh', "'<,'>DiffviewFileHistory --follow", { desc = 'Range history' }) + +-- Single line history +vim.keymap.set('n', 'dl', '.DiffviewFileHistory --follow', { desc = 'Line history' }) + +-- Diff against main/master branch (useful before merging) +vim.keymap.set('n', 'dm', function() + -- Try main first, fall back to master + local handle = io.popen('git rev-parse --verify main 2>/dev/null') + local result = handle:read('*a') + handle:close() + local branch = result ~= '' and 'main' or 'master' + vim.cmd('DiffviewOpen ' .. branch) +end, { desc = 'Diff against main/master' }) +``` + ## Tips and FAQ - **Hide untracked files:** @@ -524,5 +563,14 @@ git object database, and a command is echoed that shows how to undo the change. - **Q: How do I jump between hunks in the diff?** - A: Use `[c` and `]c` - `:h jumpto-diffs` +- **Compare against merge-base (PR-style diff):** + - `DiffviewOpen origin/main...HEAD --merge-base` + - Shows only changes introduced since branching. +- **Use with Neogit:** + - Configure Neogit with `integrations = { diffview = true }` for seamless + integration. +- **Trace line evolution:** + - Visual select lines, then `:'<,'>DiffviewFileHistory --follow` + - Or for single line: `:.DiffviewFileHistory --follow` From 1f07a2b65533f45ae9016ebf5d9fc4f0698eab18 Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Sun, 4 Jan 2026 20:50:13 +0100 Subject: [PATCH 23/77] feat(ui): show "Working tree clean" when no changes Display a friendly message in the file panel when there are no changes to show instead of just an empty section. --- lua/diffview/scene/views/diff/render.lua | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lua/diffview/scene/views/diff/render.lua b/lua/diffview/scene/views/diff/render.lua index 5acc1618..bcbb29bd 100644 --- a/lua/diffview/scene/views/diff/render.lua +++ b/lua/diffview/scene/views/diff/render.lua @@ -173,7 +173,12 @@ return function(panel) comp:add_text("(" .. #panel.files.working .. ")", "DiffviewFilePanelCounter") comp:ln() - render_files(panel.listing_style, panel.components.working.files.comp) + -- Show friendly message when working tree is clean. + if #panel.files.working == 0 and not has_other_files then + panel.components.working.files.comp:add_line(" Working tree clean", "DiffviewDim1") + else + render_files(panel.listing_style, panel.components.working.files.comp) + end panel.components.working.margin.comp:add_line() end From d46abb7fd77f7801adf8d631c85f41d784625bbd Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Sat, 17 Jan 2026 22:22:24 +0100 Subject: [PATCH 24/77] feat(config): add cycle_layouts option to customise layout cycling Added view.cycle_layouts config with default and merge_tool presets to allow users to specify which layouts to cycle through. Resolves #336 --- lua/diffview/actions.lua | 56 ++++++++++++++++++++++++---------------- lua/diffview/config.lua | 5 ++++ 2 files changed, 39 insertions(+), 22 deletions(-) diff --git a/lua/diffview/actions.lua b/lua/diffview/actions.lua index f9e1dd3d..bb218648 100644 --- a/lua/diffview/actions.lua +++ b/lua/diffview/actions.lua @@ -7,6 +7,7 @@ local DiffView = lazy.access("diffview.scene.views.diff.diff_view", "DiffView") local FileHistoryView = lazy.access("diffview.scene.views.file_history.file_history_view", "FileHistoryView") ---@type FileHistoryView|LazyModule local HelpPanel = lazy.access("diffview.ui.panels.help_panel", "HelpPanel") ---@type HelpPanel|LazyModule local StandardView = lazy.access("diffview.scene.views.standard.standard_view", "StandardView") ---@type StandardView|LazyModule +local config = lazy.require("diffview.config") ---@module "diffview.config" local lib = lazy.require("diffview.lib") ---@module "diffview.lib" local utils = lazy.require("diffview.utils") ---@module "diffview.utils" local vcs_utils = lazy.require("diffview.vcs.utils") ---@module "diffview.vcs.utils" @@ -489,19 +490,41 @@ function M.diffput(target) end end +---@type table +local layout_name_map = { + diff1_plain = Diff1, + diff2_horizontal = Diff2Hor, + diff2_vertical = Diff2Ver, + diff3_horizontal = Diff3Hor, + diff3_vertical = Diff3Ver, + diff3_mixed = Diff3Mixed, + diff4_mixed = Diff4Mixed, +} + function M.cycle_layout() + local conf = config.get_config() + local cycle_config = conf.view.cycle_layouts or {} + + -- Convert layout names to layout classes. + local function resolve_layouts(names) + local result = {} + for _, name in ipairs(names or {}) do + local layout_class = layout_name_map[name] + if layout_class then + result[#result + 1] = layout_class.__get() + end + end + return result + end + + -- Use config or fall back to defaults. local layout_cycles = { - standard = { - Diff2Hor.__get(), - Diff2Ver.__get(), - }, - merge_tool = { - Diff3Hor.__get(), - Diff3Ver.__get(), - Diff3Mixed.__get(), - Diff4Mixed.__get(), - Diff1.__get(), - } + standard = #(cycle_config.default or {}) > 0 + and resolve_layouts(cycle_config.default) + or { Diff2Hor.__get(), Diff2Ver.__get() }, + merge_tool = #(cycle_config.merge_tool or {}) > 0 + and resolve_layouts(cycle_config.merge_tool) + or { Diff3Hor.__get(), Diff3Ver.__get(), Diff3Mixed.__get(), Diff4Mixed.__get(), Diff1.__get() }, } local view = lib.get_current_view() @@ -554,17 +577,6 @@ function M.cycle_layout() end end ----@type table -local layout_name_map = { - diff1_plain = Diff1, - diff2_horizontal = Diff2Hor, - diff2_vertical = Diff2Ver, - diff3_horizontal = Diff3Hor, - diff3_vertical = Diff3Ver, - diff3_mixed = Diff3Mixed, - diff4_mixed = Diff4Mixed, -} - ---Set a specific layout for the current view. ---@param layout_name string One of: diff1_plain, diff2_horizontal, diff2_vertical, diff3_horizontal, diff3_vertical, diff3_mixed, diff4_mixed function M.set_layout(layout_name) diff --git a/lua/diffview/config.lua b/lua/diffview/config.lua index 9eb0591f..6f714ce3 100644 --- a/lua/diffview/config.lua +++ b/lua/diffview/config.lua @@ -69,6 +69,11 @@ M.defaults = { disable_diagnostics = false, winbar_info = false, }, + -- Layouts to cycle through with `cycle_layout` action. + cycle_layouts = { + default = { "diff2_horizontal", "diff2_vertical" }, + merge_tool = { "diff3_horizontal", "diff3_vertical", "diff3_mixed", "diff4_mixed", "diff1_plain" }, + }, }, file_panel = { listing_style = "tree", From 0aa20151d7ad37c5801a8f436b5f564f548566a4 Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Sat, 10 Jan 2026 09:29:19 +0100 Subject: [PATCH 25/77] docs: add Telescope integration examples Added section showing how to use Telescope for branch and commit selection when opening diffview. Resolves #279 --- README.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/README.md b/README.md index 66615442..6f71f3ee 100644 --- a/README.md +++ b/README.md @@ -573,4 +573,38 @@ end, { desc = 'Diff against main/master' }) - Visual select lines, then `:'<,'>DiffviewFileHistory --follow` - Or for single line: `:.DiffviewFileHistory --follow` +## Telescope Integration + +You can use Telescope to select branches or commits for diffview: + +```lua +-- Diff against a branch selected via Telescope +vim.keymap.set('n', 'db', function() + require('telescope.builtin').git_branches({ + attach_mappings = function(_, map) + map('i', '', function(prompt_bufnr) + local selection = require('telescope.actions.state').get_selected_entry() + require('telescope.actions').close(prompt_bufnr) + vim.cmd('DiffviewOpen ' .. selection.value) + end) + return true + end, + }) +end, { desc = 'Diffview branch' }) + +-- File history for a commit selected via Telescope +vim.keymap.set('n', 'dC', function() + require('telescope.builtin').git_commits({ + attach_mappings = function(_, map) + map('i', '', function(prompt_bufnr) + local selection = require('telescope.actions.state').get_selected_entry() + require('telescope.actions').close(prompt_bufnr) + vim.cmd('DiffviewOpen ' .. selection.value .. '^!') + end) + return true + end, + }) +end, { desc = 'Diffview commit' }) +``` + From 3c1c2012be0071e159eab5f6794bcf29c298ac87 Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Sun, 11 Jan 2026 12:37:58 +0100 Subject: [PATCH 26/77] feat(config): add always_show_sections option When enabled, Changes and Staged changes sections are always shown in the file panel even when empty. Resolves #478 --- lua/diffview/config.lua | 1 + lua/diffview/scene/views/diff/render.lua | 15 +++++++++++---- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/lua/diffview/config.lua b/lua/diffview/config.lua index 6f714ce3..1114a5c1 100644 --- a/lua/diffview/config.lua +++ b/lua/diffview/config.lua @@ -87,6 +87,7 @@ M.defaults = { win_opts = {} }, show = true, -- Show the file panel by default when opening Diffview. + always_show_sections = false, -- Always show Changes and Staged changes sections even when empty. }, file_history_panel = { log_options = { diff --git a/lua/diffview/scene/views/diff/render.lua b/lua/diffview/scene/views/diff/render.lua index bcbb29bd..6ec36d14 100644 --- a/lua/diffview/scene/views/diff/render.lua +++ b/lua/diffview/scene/views/diff/render.lua @@ -164,10 +164,11 @@ return function(panel) end local has_other_files = #panel.files.conflicting > 0 or #panel.files.staged > 0 + local always_show = conf.file_panel.always_show_sections -- Don't show the 'Changes' section if it's empty and we have other visible - -- sections. - if #panel.files.working > 0 or not has_other_files then + -- sections (unless always_show_sections is enabled). + if #panel.files.working > 0 or not has_other_files or always_show then comp = panel.components.working.title.comp comp:add_text("Changes ", "DiffviewFilePanelTitle") comp:add_text("(" .. #panel.files.working .. ")", "DiffviewFilePanelCounter") @@ -176,19 +177,25 @@ return function(panel) -- Show friendly message when working tree is clean. if #panel.files.working == 0 and not has_other_files then panel.components.working.files.comp:add_line(" Working tree clean", "DiffviewDim1") + elseif #panel.files.working == 0 then + panel.components.working.files.comp:add_line(" (empty)", "DiffviewDim1") else render_files(panel.listing_style, panel.components.working.files.comp) end panel.components.working.margin.comp:add_line() end - if #panel.files.staged > 0 then + if #panel.files.staged > 0 or always_show then comp = panel.components.staged.title.comp comp:add_text("Staged changes ", "DiffviewFilePanelTitle") comp:add_text("(" .. #panel.files.staged .. ")", "DiffviewFilePanelCounter") comp:ln() - render_files(panel.listing_style, panel.components.staged.files.comp) + if #panel.files.staged == 0 then + panel.components.staged.files.comp:add_line(" (empty)", "DiffviewDim1") + else + render_files(panel.listing_style, panel.components.staged.files.comp) + end panel.components.staged.margin.comp:add_line() end From 4ea11e6b606d048ab6dbb33fd5599f4fc660768d Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Sun, 4 Jan 2026 19:27:35 +0100 Subject: [PATCH 27/77] feat(actions): add open_file_external to open files with system app Uses xdg-open on Linux, open on macOS, and start on Windows. Default keymap is 'gx' matching vim's convention. Resolves #456 --- lua/diffview/actions.lua | 21 +++++++++++++++++++++ lua/diffview/config.lua | 3 +++ 2 files changed, 24 insertions(+) diff --git a/lua/diffview/actions.lua b/lua/diffview/actions.lua index bb218648..a3832f5b 100644 --- a/lua/diffview/actions.lua +++ b/lua/diffview/actions.lua @@ -165,6 +165,27 @@ function M.goto_file_tab() end end +---Open the current file with the system default application. +function M.open_file_external() + local file = prepare_goto_file() + + if file then + local cmd + if vim.fn.has("mac") == 1 then + cmd = { "open", file.absolute_path } + elseif vim.fn.has("unix") == 1 then + cmd = { "xdg-open", file.absolute_path } + elseif vim.fn.has("win32") == 1 then + cmd = { "cmd", "/c", "start", "", file.absolute_path } + else + utils.err("Unsupported platform for opening files externally.") + return + end + + vim.fn.jobstart(cmd, { detach = true }) + end +end + ---@class diffview.ConflictCount ---@field total integer ---@field current integer diff --git a/lua/diffview/config.lua b/lua/diffview/config.lua index 1114a5c1..653e9640 100644 --- a/lua/diffview/config.lua +++ b/lua/diffview/config.lua @@ -137,6 +137,7 @@ M.defaults = { { "n", "gf", actions.goto_file_edit, { desc = "Open the file in the previous tabpage" } }, { "n", "", actions.goto_file_split, { desc = "Open the file in a new split" } }, { "n", "gf", actions.goto_file_tab, { desc = "Open the file in a new tabpage" } }, + { "n", "gx", actions.open_file_external, { desc = "Open the file with default system application" } }, { "n", "e", actions.focus_files, { desc = "Bring focus to the file panel" } }, { "n", "b", actions.toggle_files, { desc = "Toggle the file panel." } }, { "n", "g", actions.cycle_layout, { desc = "Cycle through available layouts." } }, @@ -205,6 +206,7 @@ M.defaults = { { "n", "gf", actions.goto_file_edit, { desc = "Open the file in the previous tabpage" } }, { "n", "", actions.goto_file_split, { desc = "Open the file in a new split" } }, { "n", "gf", actions.goto_file_tab, { desc = "Open the file in a new tabpage" } }, + { "n", "gx", actions.open_file_external, { desc = "Open the file with default system application" } }, { "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" } }, { "n", "R", actions.refresh_files, { desc = "Update stats and entries in the file list" } }, @@ -249,6 +251,7 @@ M.defaults = { { "n", "gf", actions.goto_file_edit, { desc = "Open the file in the previous tabpage" } }, { "n", "", actions.goto_file_split, { desc = "Open the file in a new split" } }, { "n", "gf", actions.goto_file_tab, { desc = "Open the file in a new tabpage" } }, + { "n", "gx", actions.open_file_external, { desc = "Open the file with default system application" } }, { "n", "e", actions.focus_files, { desc = "Bring focus to the file panel" } }, { "n", "b", actions.toggle_files, { desc = "Toggle the file panel" } }, { "n", "g", actions.cycle_layout, { desc = "Cycle available layouts" } }, From c78b3c1df51085f2e0bcc7af201c6139284d50e1 Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Sat, 1 Nov 2025 16:29:56 +0100 Subject: [PATCH 28/77] feat(actions): add diff_against_head to compare HEAD with history entry (#569) Adds a new action diff_against_head that opens a diffview comparing HEAD with the commit under cursor in the file history panel. Bound to 'H' by default. --- lua/diffview/actions.lua | 1 + lua/diffview/config.lua | 1 + .../scene/views/file_history/listeners.lua | 19 +++++++++++++++++++ 3 files changed, 21 insertions(+) diff --git a/lua/diffview/actions.lua b/lua/diffview/actions.lua index a3832f5b..699db9ab 100644 --- a/lua/diffview/actions.lua +++ b/lua/diffview/actions.lua @@ -721,6 +721,7 @@ local action_names = { "close_all_folds", "close_fold", "copy_hash", + "diff_against_head", "focus_entry", "focus_files", "listing_style", diff --git a/lua/diffview/config.lua b/lua/diffview/config.lua index 653e9640..53ec3a80 100644 --- a/lua/diffview/config.lua +++ b/lua/diffview/config.lua @@ -225,6 +225,7 @@ M.defaults = { file_history_panel = { { "n", "g!", actions.options, { desc = "Open the option panel" } }, { "n", "", actions.open_in_diffview, { desc = "Open the entry under the cursor in a diffview" } }, + { "n", "H", actions.diff_against_head, { desc = "Open a diffview comparing HEAD with the commit under the cursor" } }, { "n", "y", actions.copy_hash, { desc = "Copy the commit hash of the entry under the cursor" } }, { "n", "L", actions.open_commit_log, { desc = "Show commit details" } }, { "n", "X", actions.restore_entry, { desc = "Restore file to the state from the selected entry" } }, diff --git a/lua/diffview/scene/views/file_history/listeners.lua b/lua/diffview/scene/views/file_history/listeners.lua index f4db4209..5c588fc0 100644 --- a/lua/diffview/scene/views/file_history/listeners.lua +++ b/lua/diffview/scene/views/file_history/listeners.lua @@ -3,6 +3,7 @@ local lazy = require("diffview.lazy") local DiffView = lazy.access("diffview.scene.views.diff.diff_view", "DiffView") ---@type DiffView|LazyModule local JobStatus = lazy.access("diffview.vcs.utils", "JobStatus") ---@type JobStatus|LazyModule +local RevType = lazy.access("diffview.vcs.rev", "RevType") ---@type RevType|LazyModule local lib = lazy.require("diffview.lib") ---@module "diffview.lib" local utils = lazy.require("diffview.utils") ---@module "diffview.utils" local vcs_utils = lazy.require("diffview.vcs.utils") ---@module "diffview.vcs.utils" @@ -53,6 +54,24 @@ return function(view) new_view:open() end end, + ---Open a diffview comparing HEAD with the commit under cursor. + diff_against_head = function() + local item = view.panel:get_item_at_cursor() + if not item then return end + + local commit = item.commit or (item.entry and item.entry.commit) + if not commit then return end + + local new_view = DiffView({ + adapter = view.adapter, + rev_arg = commit.hash, + left = view.adapter.Rev(RevType.COMMIT, commit.hash), + right = view.adapter.Rev(RevType.LOCAL), + }) + + lib.add_view(new_view) + new_view:open() + end, select_next_entry = function() view:next_item() end, From 49c3984495043a3329c6b0d89dde2109f9f3f2d7 Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Sat, 18 Oct 2025 14:51:25 +0100 Subject: [PATCH 29/77] fix: preserve tree collapsed state on tab switch (#582) Save and restore the collapsed state of directory nodes when file trees are recreated. This prevents folders from expanding when switching tabs. --- .../ui/models/file_tree/file_tree.lua | 52 +++++++++++++++++++ lua/diffview/vcs/file_dict.lua | 10 ++++ 2 files changed, 62 insertions(+) diff --git a/lua/diffview/ui/models/file_tree/file_tree.lua b/lua/diffview/ui/models/file_tree/file_tree.lua index 7825e801..164445d2 100644 --- a/lua/diffview/ui/models/file_tree/file_tree.lua +++ b/lua/diffview/ui/models/file_tree/file_tree.lua @@ -150,6 +150,58 @@ function FileTree:create_comp_schema(data) return schema end +---Get the collapsed state of all directories as a map from path to collapsed boolean. +---@return table +function FileTree:get_collapsed_state() + local state = {} + + local function recurse(node) + if not node:has_children() then + return + end + + ---@type DirData + local dir_data = node.data + if dir_data and dir_data.path then + state[dir_data.path] = dir_data.collapsed + end + + for _, child in ipairs(node.children) do + recurse(child) + end + end + + for _, node in ipairs(self.root.children) do + recurse(node) + end + + return state +end + +---Restore the collapsed state of directories from a saved state map. +---@param state table +function FileTree:set_collapsed_state(state) + local function recurse(node) + if not node:has_children() then + return + end + + ---@type DirData + local dir_data = node.data + if dir_data and dir_data.path and state[dir_data.path] ~= nil then + dir_data.collapsed = state[dir_data.path] + end + + for _, child in ipairs(node.children) do + recurse(child) + end + end + + for _, node in ipairs(self.root.children) do + recurse(node) + end +end + M.FileTree = FileTree return M diff --git a/lua/diffview/vcs/file_dict.lua b/lua/diffview/vcs/file_dict.lua index 5859d667..7af5f95c 100644 --- a/lua/diffview/vcs/file_dict.lua +++ b/lua/diffview/vcs/file_dict.lua @@ -41,9 +41,19 @@ function FileDict:__index(k) end function FileDict:update_file_trees() + -- Save collapsed state from existing trees before recreating them. + local conflicting_state = self.conflicting_tree and self.conflicting_tree:get_collapsed_state() or {} + local working_state = self.working_tree and self.working_tree:get_collapsed_state() or {} + local staged_state = self.staged_tree and self.staged_tree:get_collapsed_state() or {} + self.conflicting_tree = FileTree(self.conflicting) self.working_tree = FileTree(self.working) self.staged_tree = FileTree(self.staged) + + -- Restore collapsed state to the new trees. + self.conflicting_tree:set_collapsed_state(conflicting_state) + self.working_tree:set_collapsed_state(working_state) + self.staged_tree:set_collapsed_state(staged_state) end function FileDict:len() From 431ee89587c9a9ee28e6797bbaf82c9331ecf48b Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Sun, 10 Dec 2023 10:30:27 +0100 Subject: [PATCH 30/77] fix: prevent scrollbind/cursorbind from persisting after close (#330) - Use window-local options (vim.wo) instead of global options (vim.o) when saving window options before opening a diff - Add explicit winopts to NULL_FILE to disable diff-related settings (scrollbind, cursorbind, etc.) preventing option cascading --- lua/diffview/scene/window.lua | 9 ++++----- lua/diffview/vcs/file.lua | 13 +++++++++++++ 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/lua/diffview/scene/window.lua b/lua/diffview/scene/window.lua index 3081b098..967cf134 100644 --- a/lua/diffview/scene/window.lua +++ b/lua/diffview/scene/window.lua @@ -245,11 +245,10 @@ function Window:_save_winopts() if Window.winopt_store[self.file.bufnr] then return end Window.winopt_store[self.file.bufnr] = {} - api.nvim_win_call(self.id, function() - for option, _ in pairs(self.file.winopts) do - Window.winopt_store[self.file.bufnr][option] = vim.o[option] - end - end) + -- Use vim.wo to get window-local option values, not vim.o which gets global values. + for option, _ in pairs(self.file.winopts) do + Window.winopt_store[self.file.bufnr][option] = vim.wo[self.id][option] + end end function Window:_restore_winopts() diff --git a/lua/diffview/vcs/file.lua b/lua/diffview/vcs/file.lua index 53e04ac5..012af2b3 100644 --- a/lua/diffview/vcs/file.lua +++ b/lua/diffview/vcs/file.lua @@ -469,6 +469,19 @@ File.NULL_FILE = File({ binary = false, nulled = true, rev = GitRev.new_null_tree(), + -- Explicitly disable diff-related window options for the null buffer. + -- This prevents scrollbind/cursorbind from persisting after closing diffview. + winopts = { + diff = false, + scrollbind = false, + cursorbind = false, + foldmethod = "manual", + scrollopt = {}, + foldcolumn = "0", + foldlevel = 99, + foldenable = false, + winhl = {}, + }, }) M.File = File From 5ec3cde1c417e22497c61774ff12ede521aff0ad Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Sun, 8 Dec 2024 20:42:15 +0100 Subject: [PATCH 31/77] feat(config): add show_branch_name option for file panel (#542) Add option to display the current branch name in the file panel header. Works for both Git and Mercurial repositories. --- lua/diffview/config.lua | 1 + lua/diffview/scene/views/diff/render.lua | 8 ++++++++ lua/diffview/vcs/adapters/git/init.lua | 15 +++++++++++++++ lua/diffview/vcs/adapters/hg/init.lua | 15 +++++++++++++++ 4 files changed, 39 insertions(+) diff --git a/lua/diffview/config.lua b/lua/diffview/config.lua index 53ec3a80..1f92ae06 100644 --- a/lua/diffview/config.lua +++ b/lua/diffview/config.lua @@ -88,6 +88,7 @@ M.defaults = { }, show = true, -- Show the file panel by default when opening Diffview. always_show_sections = false, -- Always show Changes and Staged changes sections even when empty. + show_branch_name = false, -- Show branch name in the file panel header. }, file_history_panel = { log_options = { diff --git a/lua/diffview/scene/views/diff/render.lua b/lua/diffview/scene/views/diff/render.lua index 6ec36d14..20920750 100644 --- a/lua/diffview/scene/views/diff/render.lua +++ b/lua/diffview/scene/views/diff/render.lua @@ -147,6 +147,14 @@ return function(panel) "DiffviewFilePanelRootPath" ) + if conf.file_panel.show_branch_name then + local branch_name = panel.adapter:get_branch_name() + if branch_name then + comp:add_text("Branch: ", "DiffviewFilePanelPath") + comp:add_line(branch_name, "DiffviewFilePanelTitle") + end + end + if conf.show_help_hints and panel.help_mapping then comp:add_text("Help: ", "DiffviewFilePanelPath") comp:add_line(panel.help_mapping, "DiffviewFilePanelCounter") diff --git a/lua/diffview/vcs/adapters/git/init.lua b/lua/diffview/vcs/adapters/git/init.lua index 7dc3802c..9de36fea 100644 --- a/lua/diffview/vcs/adapters/git/init.lua +++ b/lua/diffview/vcs/adapters/git/init.lua @@ -1317,6 +1317,21 @@ function GitAdapter:head_rev() return GitRev(RevType.COMMIT, s, true) end +---Get the current branch name. +---@return string? branch_name The branch name, or nil if detached HEAD. +function GitAdapter:get_branch_name() + local out, code = self:exec_sync( + { "symbolic-ref", "--short", "HEAD" }, + { cwd = self.ctx.toplevel, silent = true } + ) + + if code == 0 and out[1] then + return vim.trim(out[1]) + end + + return nil +end + ---@param path string ---@param rev_arg string? ---@return string? diff --git a/lua/diffview/vcs/adapters/hg/init.lua b/lua/diffview/vcs/adapters/hg/init.lua index 94d25942..88730bca 100644 --- a/lua/diffview/vcs/adapters/hg/init.lua +++ b/lua/diffview/vcs/adapters/hg/init.lua @@ -861,6 +861,21 @@ function HgAdapter:head_rev() return HgRev(RevType.COMMIT, s, true) end +---Get the current branch name. +---@return string? branch_name The branch name, or nil if not available. +function HgAdapter:get_branch_name() + local out, code = self:exec_sync( + { "branch" }, + { cwd = self.ctx.toplevel, silent = true } + ) + + if code == 0 and out[1] then + return vim.trim(out[1]) + end + + return nil +end + function HgAdapter:rev_to_args(left, right) assert( not (left.type == RevType.LOCAL and right.type == RevType.LOCAL), From 5f1603a60cd7f9bfab99298dc271e78ae399f0d0 Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Sun, 10 Aug 2025 12:06:20 +0100 Subject: [PATCH 32/77] feat: add loading indicator in file panel (#570) Show "Fetching changes..." message while loading files, providing feedback to users during refresh operations. Combined with existing "Working tree clean" message for empty state. Clear loading state and re-render before selecting file to ensure highlight_file can find the file components. --- lua/diffview/scene/views/diff/diff_view.lua | 9 +++++++++ lua/diffview/scene/views/diff/file_panel.lua | 1 + lua/diffview/scene/views/diff/render.lua | 5 +++++ 3 files changed, 15 insertions(+) diff --git a/lua/diffview/scene/views/diff/diff_view.lua b/lua/diffview/scene/views/diff/diff_view.lua index cec76d70..02f7fdea 100644 --- a/lua/diffview/scene/views/diff/diff_view.lua +++ b/lua/diffview/scene/views/diff/diff_view.lua @@ -58,6 +58,7 @@ function DiffView:init(opt) self.left = opt.left self.right = opt.right self.initialized = false + self.is_loading = true self.options = opt.options or {} self.options.selected_file = self.options.selected_file and pl:chain(self.options.selected_file) @@ -480,6 +481,14 @@ DiffView.update_files = debounce.debounce_trailing( end end end + + -- Clear loading state and re-render panel before set_file, so highlight_file + -- can find the file components (the previous render returned early due to is_loading). + self.is_loading = false + self.panel.is_loading = false + self.panel:render() + self.panel:redraw() + self:set_file(self.panel.cur_file or self.panel:next_file(), false, not self.initialized) self.update_needed = false diff --git a/lua/diffview/scene/views/diff/file_panel.lua b/lua/diffview/scene/views/diff/file_panel.lua index c2223266..9435a654 100644 --- a/lua/diffview/scene/views/diff/file_panel.lua +++ b/lua/diffview/scene/views/diff/file_panel.lua @@ -58,6 +58,7 @@ function FilePanel:init(adapter, files, path_args, rev_pretty_name) self.rev_pretty_name = rev_pretty_name self.listing_style = conf.file_panel.listing_style self.tree_options = conf.file_panel.tree_options + self.is_loading = true self:on_autocmd("BufNew", { callback = function() diff --git a/lua/diffview/scene/views/diff/render.lua b/lua/diffview/scene/views/diff/render.lua index 20920750..5550c4a2 100644 --- a/lua/diffview/scene/views/diff/render.lua +++ b/lua/diffview/scene/views/diff/render.lua @@ -161,6 +161,11 @@ return function(panel) comp:add_line() end + if panel.is_loading then + comp:add_line(" Fetching changes...", "DiffviewDim1") + return + end + if #panel.files.conflicting > 0 then comp = panel.components.conflicting.title.comp comp:add_text("Conflicts ", "DiffviewFilePanelTitle") From bdbe846968f3e3b13eb54ce062fc26ca9574899a Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Sun, 21 Dec 2025 09:24:24 +0100 Subject: [PATCH 33/77] feat: show file count on collapsed folders (#597) Display the number of files contained within a folder when it is collapsed, e.g., "src/ (5)". This helps users understand the scope of changes without expanding folders. --- lua/diffview/scene/views/diff/render.lua | 5 +++++ lua/diffview/ui/models/file_tree/file_tree.lua | 1 + 2 files changed, 6 insertions(+) diff --git a/lua/diffview/scene/views/diff/render.lua b/lua/diffview/scene/views/diff/render.lua index 5550c4a2..6486af5b 100644 --- a/lua/diffview/scene/views/diff/render.lua +++ b/lua/diffview/scene/views/diff/render.lua @@ -105,6 +105,11 @@ local function render_file_tree_recurse(depth, comp) end dir:add_text(ctx.name .. "/", "DiffviewFolderName") + -- Show file count when folder is collapsed. + if ctx.collapsed and ctx._node then + local file_count = #ctx._node:leaves() + dir:add_text(" (" .. file_count .. ")", "DiffviewDim1") + end dir:ln() if not ctx.collapsed then diff --git a/lua/diffview/ui/models/file_tree/file_tree.lua b/lua/diffview/ui/models/file_tree/file_tree.lua index 164445d2..932ed88e 100644 --- a/lua/diffview/ui/models/file_tree/file_tree.lua +++ b/lua/diffview/ui/models/file_tree/file_tree.lua @@ -112,6 +112,7 @@ function FileTree:create_comp_schema(data) ---@type DirData local dir_data = node.data + dir_data._node = node if data.flatten_dirs then while #node.children == 1 and node.children[1]:has_children() do From 40ccf1d3cc0725019903fffe5a4ae517a6b829a0 Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Sat, 17 May 2025 14:00:07 +0100 Subject: [PATCH 34/77] docs: add diffopt tip for better diff display (#526) --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 6f71f3ee..cde1a36c 100644 --- a/README.md +++ b/README.md @@ -572,6 +572,11 @@ end, { desc = 'Diff against main/master' }) - **Trace line evolution:** - Visual select lines, then `:'<,'>DiffviewFileHistory --follow` - Or for single line: `:.DiffviewFileHistory --follow` +- **Better diff display (changes shown as add+delete instead of modification):** + - Set Neovim's `diffopt` to use a better algorithm: + - `vim.opt.diffopt:append { "algorithm:histogram" }` + - Alternatives: `algorithm:patience` or `algorithm:minimal` + - This affects how Neovim's built-in diff mode displays changes. ## Telescope Integration From 81a8d413f581bef9a1c871226b4af1d5d8c04b46 Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Sat, 15 Jun 2024 13:48:12 +0100 Subject: [PATCH 35/77] feat(actions): add open_in_new_tab to duplicate diffview (#432) Adds an action to open the current diffview in a new tab with the same revision. Useful for comparing the same diff in multiple tabs. Bound to T by default. --- lua/diffview/actions.lua | 27 +++++++++++++++++++++++++++ lua/diffview/config.lua | 2 ++ 2 files changed, 29 insertions(+) diff --git a/lua/diffview/actions.lua b/lua/diffview/actions.lua index 699db9ab..e7dfbb7d 100644 --- a/lua/diffview/actions.lua +++ b/lua/diffview/actions.lua @@ -186,6 +186,33 @@ function M.open_file_external() end end +---Open the current diffview in a new tab with the same revision. +function M.open_in_new_tab() + local view = lib.get_current_view() + + if not view then + return + end + + -- Only works for DiffView (not FileHistoryView). + if not DiffView.__get():ancestorof(view) then + utils.info("This action only works in a diff view.") + return + end + + local new_view = DiffView({ + adapter = view.adapter, + rev_arg = view.rev_arg, + left = view.left, + right = view.right, + path_args = view.path_args, + options = view.options or {}, + }) + + lib.add_view(new_view) + new_view:open() +end + ---@class diffview.ConflictCount ---@field total integer ---@field current integer diff --git a/lua/diffview/config.lua b/lua/diffview/config.lua index 1f92ae06..90408379 100644 --- a/lua/diffview/config.lua +++ b/lua/diffview/config.lua @@ -139,6 +139,7 @@ M.defaults = { { "n", "", actions.goto_file_split, { desc = "Open the file in a new split" } }, { "n", "gf", actions.goto_file_tab, { desc = "Open the file in a new tabpage" } }, { "n", "gx", actions.open_file_external, { desc = "Open the file with default system application" } }, + { "n", "T", actions.open_in_new_tab, { desc = "Open diffview in a new tab" } }, { "n", "e", actions.focus_files, { desc = "Bring focus to the file panel" } }, { "n", "b", actions.toggle_files, { desc = "Toggle the file panel." } }, { "n", "g", actions.cycle_layout, { desc = "Cycle through available layouts." } }, @@ -208,6 +209,7 @@ M.defaults = { { "n", "", actions.goto_file_split, { desc = "Open the file in a new split" } }, { "n", "gf", actions.goto_file_tab, { desc = "Open the file in a new tabpage" } }, { "n", "gx", actions.open_file_external, { desc = "Open the file with default system application" } }, + { "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" } }, { "n", "R", actions.refresh_files, { desc = "Update stats and entries in the file list" } }, From 045881c716755c57f024fe61acb747cb0dcd4b4a Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Sun, 6 Oct 2024 11:39:56 +0100 Subject: [PATCH 36/77] docs: clarify revision argument behaviour (#514) --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index cde1a36c..2fa61094 100644 --- a/README.md +++ b/README.md @@ -577,6 +577,11 @@ end, { desc = 'Diff against main/master' }) - `vim.opt.diffopt:append { "algorithm:histogram" }` - Alternatives: `algorithm:patience` or `algorithm:minimal` - This affects how Neovim's built-in diff mode displays changes. +- **Understanding revision arguments:** + - `DiffviewOpen HEAD~5` compares HEAD~5 to working tree (all changes since) + - `DiffviewOpen HEAD~5..HEAD` compares HEAD~5 to HEAD (excludes working tree changes) + - `DiffviewOpen HEAD~5^..HEAD~5` shows changes within that single commit + - For viewing a specific commit's changes, use `DiffviewFileHistory` instead ## Telescope Integration From ef5735708e6b0517183734cbb00faa5bf9480a4d Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Sun, 30 Nov 2025 14:49:37 +0100 Subject: [PATCH 37/77] fix: prevent coroutine failure when view closes during update (#528) Add closing signal check at the start of update_files to prevent race condition where gitsigns staging triggers refresh while view is closing. --- lua/diffview/scene/views/diff/diff_view.lua | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/lua/diffview/scene/views/diff/diff_view.lua b/lua/diffview/scene/views/diff/diff_view.lua index 02f7fdea..21ea93b8 100644 --- a/lua/diffview/scene/views/diff/diff_view.lua +++ b/lua/diffview/scene/views/diff/diff_view.lua @@ -326,10 +326,16 @@ DiffView.update_files = debounce.debounce_trailing( ---@param self DiffView ---@param callback fun(err?: string[]) async.wrap(function(self, callback) + -- Never update if the view is closing (prevents coroutine failure from race conditions). + if self.closing:check() then + callback({ "The update was cancelled." }) + return + end + await(async.scheduler()) -- Never update unless the view is in focus - if self.tabpage ~= api.nvim_get_current_tabpage() then + if self.closing:check() or self.tabpage ~= api.nvim_get_current_tabpage() then callback({ "The update was cancelled." }) return end @@ -363,8 +369,8 @@ DiffView.update_files = debounce.debounce_trailing( return end - -- Stop the update if the view is no longer in focus. - if self.tabpage ~= api.nvim_get_current_tabpage() then + -- Stop the update if the view is closing or no longer in focus. + if self.closing:check() or self.tabpage ~= api.nvim_get_current_tabpage() then callback({ "The update was cancelled." }) return end From 2843ba644cf21f3cc0bb8fac22e8840cb2f1a000 Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Sat, 26 Apr 2025 09:25:09 +0100 Subject: [PATCH 38/77] feat(config): add "auto" position option for panels (#536) Add support for position = "auto" in file_panel.win_config which respects vim's splitright/splitbelow options when determining panel placement. Users can set this to have the panel position follow their vim split preferences. --- lua/diffview/ui/panel.lua | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/lua/diffview/ui/panel.lua b/lua/diffview/ui/panel.lua index ccd640f7..9b0e2826 100644 --- a/lua/diffview/ui/panel.lua +++ b/lua/diffview/ui/panel.lua @@ -269,7 +269,17 @@ function Panel:open() local config = self:get_config() if config.type == "split" then - local split_dir = vim.tbl_contains({ "top", "left" }, config.position) and "aboveleft" or "belowright" + -- Resolve "auto" position based on vim's splitright/splitbelow options. + local position = config.position + if position == "auto" then + if self.state.form == "row" then + position = vim.o.splitbelow and "bottom" or "top" + else + position = vim.o.splitright and "right" or "left" + end + end + + local split_dir = vim.tbl_contains({ "top", "left" }, position) and "aboveleft" or "belowright" local split_cmd = self.state.form == "row" and "sp" or "vsp" local rel_winid = config.relative == "win" and api.nvim_win_is_valid(config.win or -1) @@ -282,7 +292,7 @@ function Panel:open() api.nvim_win_set_buf(self.winid, self.bufid) if config.relative == "editor" then - local dir = ({ left = "H", bottom = "J", top = "K", right = "L" })[config.position] + local dir = ({ left = "H", bottom = "J", top = "K", right = "L" })[position] vim.cmd("wincmd " .. dir) vim.cmd("wincmd =") end From ed13882c32c8ff3cb88e34c0bd7fece1d869979e Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Sun, 4 Jan 2026 11:18:52 +0100 Subject: [PATCH 39/77] fix: disable context plugins in diff buffers for scrollbind alignment Disable nvim-treesitter-context and context.vim in diff buffers to prevent scrollbind misalignment. These plugins add virtual lines at the top of windows which interfere with synchronized scrolling. --- lua/diffview/vcs/file.lua | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lua/diffview/vcs/file.lua b/lua/diffview/vcs/file.lua index 012af2b3..235b09ae 100644 --- a/lua/diffview/vcs/file.lua +++ b/lua/diffview/vcs/file.lua @@ -292,6 +292,10 @@ File.create_buffer = async.wrap(function(self, callback) vim.cmd("filetype detect") end) + -- Disable context plugins that interfere with scrollbind alignment. + vim.b[self.bufnr].ts_context_disable = true -- nvim-treesitter-context + vim.b[self.bufnr].context_enabled = false -- context.vim + vim.bo[self.bufnr].modifiable = last_modifiable vim.bo[self.bufnr].modified = last_modified self:post_buf_created() From cd8e3cce5112fd1cf5536e332cdee0bf40d40940 Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Sun, 28 Dec 2025 18:28:21 +0100 Subject: [PATCH 40/77] fix: preserve panel cursor position when switching tabs (#457) Save the file panel cursor position on tab_leave and restore it on tab_enter. This complements the fold state preservation from #582. --- lua/diffview/scene/views/diff/listeners.lua | 16 ++++++++++++++++ .../scene/views/file_history/listeners.lua | 17 +++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/lua/diffview/scene/views/diff/listeners.lua b/lua/diffview/scene/views/diff/listeners.lua index 9e6d63b1..fc26640b 100644 --- a/lua/diffview/scene/views/diff/listeners.lua +++ b/lua/diffview/scene/views/diff/listeners.lua @@ -19,6 +19,14 @@ return function(view) view:set_file(file, false, true) end + -- Restore panel cursor position. + if view.panel_cursor and view.panel:is_open() then + local winid = view.panel:get_winid() + if winid and api.nvim_win_is_valid(winid) then + pcall(api.nvim_win_set_cursor, winid, view.panel_cursor) + end + end + if view.ready then view:update_files() end @@ -26,6 +34,14 @@ return function(view) tab_leave = function() local file = view.panel.cur_file + -- Save panel cursor position. + if view.panel:is_open() then + local winid = view.panel:get_winid() + if winid and api.nvim_win_is_valid(winid) then + view.panel_cursor = api.nvim_win_get_cursor(winid) + end + end + if file then file.layout:detach_files() end diff --git a/lua/diffview/scene/views/file_history/listeners.lua b/lua/diffview/scene/views/file_history/listeners.lua index 5c588fc0..75b8bb80 100644 --- a/lua/diffview/scene/views/file_history/listeners.lua +++ b/lua/diffview/scene/views/file_history/listeners.lua @@ -8,6 +8,7 @@ local lib = lazy.require("diffview.lib") ---@module "diffview.lib" local utils = lazy.require("diffview.utils") ---@module "diffview.utils" local vcs_utils = lazy.require("diffview.vcs.utils") ---@module "diffview.vcs.utils" +local api = vim.api local await = async.await ---@param view FileHistoryView @@ -18,10 +19,26 @@ return function(view) if file then view:set_file(file) end + + -- Restore panel cursor position. + if view.panel_cursor and view.panel:is_open() then + local winid = view.panel:get_winid() + if winid and api.nvim_win_is_valid(winid) then + pcall(api.nvim_win_set_cursor, winid, view.panel_cursor) + end + end end, tab_leave = function() local file = view.panel.cur_item[2] + -- Save panel cursor position. + if view.panel:is_open() then + local winid = view.panel:get_winid() + if winid and api.nvim_win_is_valid(winid) then + view.panel_cursor = api.nvim_win_get_cursor(winid) + end + end + if file then file.layout:detach_files() end From 61ace2201753ab3da74c4ed71f14d3551216dc03 Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Sun, 4 Jan 2026 13:52:36 +0100 Subject: [PATCH 41/77] feat(config): add hide_merge_artifacts option Filter out common merge artifact files (*.orig, *.BACKUP.*, *.BASE.*, *.LOCAL.*, *.REMOTE.*) from file listings when the option is enabled. --- doc/diffview_defaults.txt | 1 + lua/diffview/config.lua | 1 + lua/diffview/vcs/utils.lua | 44 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+) diff --git a/doc/diffview_defaults.txt b/doc/diffview_defaults.txt index 619837ab..059b2101 100644 --- a/doc/diffview_defaults.txt +++ b/doc/diffview_defaults.txt @@ -10,6 +10,7 @@ DEFAULT CONFIG *diffview.defaults* use_icons = true, -- Requires nvim-web-devicons or mini.icons show_help_hints = true, -- Show hints for how to open the help panel watch_index = true, -- Update views and index buffers when the git index changes. + hide_merge_artifacts = false, -- Hide merge artifact files (*.orig, *.BACKUP.*, *.BASE.*, *.LOCAL.*, *.REMOTE.*) icons = { -- Only applies when use_icons is true. folder_closed = "", folder_open = "", diff --git a/lua/diffview/config.lua b/lua/diffview/config.lua index 90408379..130c6bed 100644 --- a/lua/diffview/config.lua +++ b/lua/diffview/config.lua @@ -44,6 +44,7 @@ M.defaults = { use_icons = true, show_help_hints = true, watch_index = true, + hide_merge_artifacts = false, -- Hide merge artifact files (*.orig, *.BACKUP.*, etc.) icons = { folder_closed = "", folder_open = "", diff --git a/lua/diffview/vcs/utils.lua b/lua/diffview/vcs/utils.lua index 617c449d..cad82d10 100644 --- a/lua/diffview/vcs/utils.lua +++ b/lua/diffview/vcs/utils.lua @@ -11,8 +11,47 @@ local await = async.await local fmt = string.format local logger = DiffviewGlobal.logger +local config = require("diffview.config") + local M = {} +-- Patterns for merge artifact files. +local merge_artifact_patterns = { + "%.orig$", + "%.BACKUP%.", + "%.BASE%.", + "%.LOCAL%.", + "%.REMOTE%.", +} + +---Check if a file path matches a merge artifact pattern. +---@param path string +---@return boolean +function M.is_merge_artifact(path) + for _, pattern in ipairs(merge_artifact_patterns) do + if path:match(pattern) then + return true + end + end + return false +end + +---Filter out merge artifacts from a file list if configured. +---@param files FileEntry[] +---@return FileEntry[] +function M.filter_merge_artifacts(files) + if not config.get_config().hide_merge_artifacts then + return files + end + local result = {} + for _, file in ipairs(files) do + if not M.is_merge_artifact(file.path) then + result[#result + 1] = file + end + end + return result +end + ---@enum JobStatus local JobStatus = oop.enum({ SUCCESS = 1, @@ -140,6 +179,11 @@ M.diff_file_list = async.wrap(function(adapter, left, right, path_args, dv_opt, return end + -- Filter out merge artifacts if configured. + files:set_working(M.filter_merge_artifacts(files.working)) + files:set_conflicting(M.filter_merge_artifacts(files.conflicting)) + files:set_staged(M.filter_merge_artifacts(files.staged)) + files:update_file_trees() callback(nil, files) end, 7) From e41367c034fbab600cfd0b5827248bf9ac1fce43 Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Sun, 11 Jan 2026 17:49:51 +0100 Subject: [PATCH 42/77] feat(config): add auto_close_on_empty option Automatically close diffview when the last working/conflicting file has been staged. Useful for quick staging workflows. --- doc/diffview_defaults.txt | 1 + lua/diffview/config.lua | 1 + lua/diffview/scene/views/diff/listeners.lua | 16 ++++++++++++++++ 3 files changed, 18 insertions(+) diff --git a/doc/diffview_defaults.txt b/doc/diffview_defaults.txt index 059b2101..157dc06e 100644 --- a/doc/diffview_defaults.txt +++ b/doc/diffview_defaults.txt @@ -11,6 +11,7 @@ DEFAULT CONFIG *diffview.defaults* show_help_hints = true, -- Show hints for how to open the help panel watch_index = true, -- Update views and index buffers when the git index changes. hide_merge_artifacts = false, -- Hide merge artifact files (*.orig, *.BACKUP.*, *.BASE.*, *.LOCAL.*, *.REMOTE.*) + auto_close_on_empty = false, -- Close diffview when the last file is staged/resolved icons = { -- Only applies when use_icons is true. folder_closed = "", folder_open = "", diff --git a/lua/diffview/config.lua b/lua/diffview/config.lua index 130c6bed..4ef6c15a 100644 --- a/lua/diffview/config.lua +++ b/lua/diffview/config.lua @@ -45,6 +45,7 @@ M.defaults = { show_help_hints = true, watch_index = true, hide_merge_artifacts = false, -- Hide merge artifact files (*.orig, *.BACKUP.*, etc.) + auto_close_on_empty = false, -- Automatically close diffview when the last file is staged/resolved icons = { folder_closed = "", folder_open = "", diff --git a/lua/diffview/scene/views/diff/listeners.lua b/lua/diffview/scene/views/diff/listeners.lua index fc26640b..95fe4424 100644 --- a/lua/diffview/scene/views/diff/listeners.lua +++ b/lua/diffview/scene/views/diff/listeners.lua @@ -4,6 +4,8 @@ local lazy = require("diffview.lazy") local EventName = lazy.access("diffview.events", "EventName") ---@type EventName|LazyModule local RevType = lazy.access("diffview.vcs.rev", "RevType") ---@type RevType|LazyModule local actions = lazy.require("diffview.actions") ---@module "diffview.actions" +local config = lazy.require("diffview.config") ---@module "diffview.config" +local lib = lazy.require("diffview.lib") ---@module "diffview.lib" local utils = lazy.require("diffview.utils") ---@module "diffview.utils" local vcs_utils = lazy.require("diffview.vcs.utils") ---@module "diffview.vcs.utils" @@ -200,6 +202,13 @@ return function(view) view:update_files( vim.schedule_wrap(function() view.panel:highlight_cur_file() + -- Auto-close if all working/conflicting files have been staged. + if config.get_config().auto_close_on_empty then + if #view.files.working == 0 and #view.files.conflicting == 0 then + view:close() + lib.dispose_view(view) + end + end end) ) view.emitter:emit(EventName.FILES_STAGED, view) @@ -220,6 +229,13 @@ return function(view) view:update_files(function() view.panel:highlight_cur_file() + -- Auto-close if all working/conflicting files have been staged. + if config.get_config().auto_close_on_empty then + if #view.files.working == 0 and #view.files.conflicting == 0 then + view:close() + lib.dispose_view(view) + end + end end) view.emitter:emit(EventName.FILES_STAGED, view) end From 0fb4d16cd2d54e13662bc5351adefe6ad63c320b Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Sun, 27 Oct 2024 11:21:45 +0100 Subject: [PATCH 43/77] feat: extend restore_entry to work on directories (#186) The restore_entry action (X) now restores all files in a directory when the cursor is on a folder. Files with unsaved changes are skipped with a warning. --- lua/diffview/scene/views/diff/listeners.lua | 44 +++++++++++++++++---- 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/lua/diffview/scene/views/diff/listeners.lua b/lua/diffview/scene/views/diff/listeners.lua index 95fe4424..cef9c66a 100644 --- a/lua/diffview/scene/views/diff/listeners.lua +++ b/lua/diffview/scene/views/diff/listeners.lua @@ -263,17 +263,47 @@ return function(view) commit = view.left.commit end - local file = view:infer_cur_file() - if not file then return end + local item = view:infer_cur_file(true) + if not item then return end + + -- Check if item is a directory. + if type(item.collapsed) == "boolean" then + ---@cast item DirData + local node = item._node + if not node then return end + + -- Get all files under this directory. + local leaves = node:leaves() + local restored_count = 0 + for _, leaf in ipairs(leaves) do + local file = leaf.data + if file and file.path then + local bufid = utils.find_file_buffer(file.path) + if bufid and vim.bo[bufid].modified then + utils.warn(("Skipping '%s': file has unsaved changes."):format(file.path)) + else + await(vcs_utils.restore_file(view.adapter, file.path, file.kind, commit)) + restored_count = restored_count + 1 + end + end + end - local bufid = utils.find_file_buffer(file.path) + if restored_count > 0 then + utils.info(("Restored %d file(s)."):format(restored_count)) + end + else + -- Single file restore. + local file = item + local bufid = utils.find_file_buffer(file.path) - if bufid and vim.bo[bufid].modified then - utils.err("The file is open with unsaved changes! Aborting file restoration.") - return + if bufid and vim.bo[bufid].modified then + utils.err("The file is open with unsaved changes! Aborting file restoration.") + return + end + + await(vcs_utils.restore_file(view.adapter, file.path, file.kind, commit)) end - await(vcs_utils.restore_file(view.adapter, file.path, file.kind, commit)) view:update_files() end), listing_style = function() From 0875d9c2a8776378c8873ca43dc1cb1d17943d55 Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Sat, 1 Mar 2025 10:55:16 +0100 Subject: [PATCH 44/77] feat(config): add date_format option for file history panel (#525) Control how dates are displayed in the file history panel: - "auto" (default): relative for recent commits (< 3 months), ISO for old - "relative": always show relative dates (e.g., "2 days ago") - "iso": always show ISO dates --- doc/diffview_defaults.txt | 1 + lua/diffview/config.lua | 1 + .../scene/views/file_history/render.lua | 20 +++++++++++++------ 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/doc/diffview_defaults.txt b/doc/diffview_defaults.txt index 157dc06e..a1d45897 100644 --- a/doc/diffview_defaults.txt +++ b/doc/diffview_defaults.txt @@ -84,6 +84,7 @@ DEFAULT CONFIG *diffview.defaults* height = 16, win_opts = {}, }, + date_format = "auto", -- Date format: "auto" | "relative" | "iso" }, commit_log_panel = { win_config = {}, -- See |diffview-config-win_config| diff --git a/lua/diffview/config.lua b/lua/diffview/config.lua index 4ef6c15a..272f17aa 100644 --- a/lua/diffview/config.lua +++ b/lua/diffview/config.lua @@ -116,6 +116,7 @@ M.defaults = { win_opts = {} }, commit_subject_max_length = 72, -- Max length for commit subject display. + date_format = "auto", -- Date format: "auto" (relative for recent, ISO for old), "relative", or "iso". }, commit_log_panel = { win_config = { diff --git a/lua/diffview/scene/views/file_history/render.lua b/lua/diffview/scene/views/file_history/render.lua index 77459443..6d3b7c43 100644 --- a/lua/diffview/scene/views/file_history/render.lua +++ b/lua/diffview/scene/views/file_history/render.lua @@ -157,12 +157,20 @@ local function render_entries(panel, parent, entries, updating) ) if entry.commit then - -- 3 months - local date = ( - os.difftime(os.time(), entry.commit.time) > 60 * 60 * 24 * 30 * 3 - and entry.commit.iso_date - or entry.commit.rel_date - ) + local date_format = config.get_config().file_history_panel.date_format + local date + if date_format == "relative" then + date = entry.commit.rel_date + elseif date_format == "iso" then + date = entry.commit.iso_date + else + -- "auto": show relative for recent commits (< 3 months), ISO for older. + date = ( + os.difftime(os.time(), entry.commit.time) > 60 * 60 * 24 * 30 * 3 + and entry.commit.iso_date + or entry.commit.rel_date + ) + end comp:add_text(" " .. entry.commit.author .. ", " .. date, "DiffviewFilePanelPath") end From 318ce58594c599c133ef59a91fbb9b558fedf6a6 Mon Sep 17 00:00:00 2001 From: przepompownia Date: Wed, 26 Mar 2025 02:10:53 +0100 Subject: [PATCH 45/77] feat(file-history): cycle within commit on prev/next (#566) --- lua/diffview/actions.lua | 2 ++ lua/diffview/config.lua | 4 ++++ .../views/file_history/file_history_panel.lua | 20 ++++++++++--------- .../views/file_history/file_history_view.lua | 8 ++++---- .../scene/views/file_history/listeners.lua | 6 ++++++ 5 files changed, 27 insertions(+), 13 deletions(-) diff --git a/lua/diffview/actions.lua b/lua/diffview/actions.lua index e7dfbb7d..1b9df94e 100644 --- a/lua/diffview/actions.lua +++ b/lua/diffview/actions.lua @@ -763,7 +763,9 @@ local action_names = { "restore_entry", "select_entry", "select_next_entry", + "select_next_entry_in_commit", "select_prev_entry", + "select_prev_entry_in_commit", "select_first_entry", "select_last_entry", "select_next_commit", diff --git a/lua/diffview/config.lua b/lua/diffview/config.lua index 272f17aa..e092d746 100644 --- a/lua/diffview/config.lua +++ b/lua/diffview/config.lua @@ -136,6 +136,8 @@ M.defaults = { -- tabpage is a Diffview. { "n", "", actions.select_next_entry, { desc = "Open the diff for the next file" } }, { "n", "", actions.select_prev_entry, { desc = "Open the diff for the previous file" } }, + { "n", "]k", actions.select_next_entry_in_commit, { desc = "Open the diff for the next file within commit" } }, + { "n", "[k", actions.select_prev_entry_in_commit, { desc = "Open the diff for the previous file within commit" } }, { "n", "[F", actions.select_first_entry, { desc = "Open the diff for the first file" } }, { "n", "]F", actions.select_last_entry, { desc = "Open the diff for the last file" } }, { "n", "gf", actions.goto_file_edit, { desc = "Open the file in the previous tabpage" } }, @@ -253,6 +255,8 @@ M.defaults = { { "n", "", actions.scroll_view(0.25), { desc = "Scroll the view down" } }, { "n", "", actions.select_next_entry, { desc = "Open the diff for the next file" } }, { "n", "", actions.select_prev_entry, { desc = "Open the diff for the previous file" } }, + { "n", "]k", actions.select_next_entry_in_commit, { desc = "Open the diff for the next file within commit" } }, + { "n", "[k", actions.select_prev_entry_in_commit, { desc = "Open the diff for the previous file within commit" } }, { "n", "[F", actions.select_first_entry, { desc = "Open the diff for the first file" } }, { "n", "]F", actions.select_last_entry, { desc = "Open the diff for the last file" } }, { "n", "gf", actions.goto_file_edit, { desc = "Open the file in the previous tabpage" } }, diff --git a/lua/diffview/scene/views/file_history/file_history_panel.lua b/lua/diffview/scene/views/file_history/file_history_panel.lua index 2240452f..38a2a28c 100644 --- a/lua/diffview/scene/views/file_history/file_history_panel.lua +++ b/lua/diffview/scene/views/file_history/file_history_panel.lua @@ -386,11 +386,13 @@ end ---@param offset integer ---@return LogEntry? ---@return FileEntry? -function FileHistoryPanel:_get_entry_by_file_offset(entry_idx, file_idx, offset) +function FileHistoryPanel:_get_entry_by_file_offset(entry_idx, file_idx, offset, cycle_in_commit) local cur_entry = self.entries[entry_idx] - if cur_entry.files[file_idx + offset] then - return cur_entry, cur_entry.files[file_idx + offset] + local entryPos = cycle_in_commit and ((file_idx + offset - 1) % #cur_entry.files + 1) or (file_idx + offset) + + if cur_entry.files[entryPos] then + return cur_entry, cur_entry.files[entryPos] end local sign = utils.sign(offset) @@ -410,7 +412,7 @@ function FileHistoryPanel:_get_entry_by_file_offset(entry_idx, file_idx, offset) end end -function FileHistoryPanel:set_file_by_offset(offset) +function FileHistoryPanel:set_file_by_offset(offset, cycle_in_commit) if self:num_items() == 0 then return end local entry, file = self.cur_item[1], self.cur_item[2] @@ -425,7 +427,7 @@ function FileHistoryPanel:set_file_by_offset(offset) local file_idx = utils.vec_indexof(entry.files, file) if entry_idx ~= -1 and file_idx ~= -1 then - local next_entry, next_file = self:_get_entry_by_file_offset(entry_idx, file_idx, offset) + local next_entry, next_file = self:_get_entry_by_file_offset(entry_idx, file_idx, offset, cycle_in_commit) self:set_cur_item({ next_entry, next_file }) if next_entry ~= entry then @@ -440,12 +442,12 @@ function FileHistoryPanel:set_file_by_offset(offset) end end -function FileHistoryPanel:prev_file() - return self:set_file_by_offset(-vim.v.count1) +function FileHistoryPanel:prev_file(cycle_in_commit) + return self:set_file_by_offset(-vim.v.count1, cycle_in_commit) end -function FileHistoryPanel:next_file() - return self:set_file_by_offset(vim.v.count1) +function FileHistoryPanel:next_file(cycle_in_commit) + return self:set_file_by_offset(vim.v.count1, cycle_in_commit) end ---@param item LogEntry|FileEntry diff --git a/lua/diffview/scene/views/file_history/file_history_view.lua b/lua/diffview/scene/views/file_history/file_history_view.lua index fd1c6093..28913739 100644 --- a/lua/diffview/scene/views/file_history/file_history_view.lua +++ b/lua/diffview/scene/views/file_history/file_history_view.lua @@ -124,13 +124,13 @@ FileHistoryView._set_file = async.void(function(self, file) end end) -function FileHistoryView:next_item() +function FileHistoryView:next_item(cycle_in_commit) self:ensure_layout() if self:file_safeguard() then return end if self.panel:num_items() > 1 or self.nulled then - local cur = self.panel:next_file() + local cur = self.panel:next_file(cycle_in_commit) if cur then self.panel:highlight_item(cur) @@ -142,13 +142,13 @@ function FileHistoryView:next_item() end end -function FileHistoryView:prev_item() +function FileHistoryView:prev_item(cycle_in_commit) self:ensure_layout() if self:file_safeguard() then return end if self.panel:num_items() > 1 or self.nulled then - local cur = self.panel:prev_file() + local cur = self.panel:prev_file(cycle_in_commit) if cur then self.panel:highlight_item(cur) diff --git a/lua/diffview/scene/views/file_history/listeners.lua b/lua/diffview/scene/views/file_history/listeners.lua index 75b8bb80..e8d80af7 100644 --- a/lua/diffview/scene/views/file_history/listeners.lua +++ b/lua/diffview/scene/views/file_history/listeners.lua @@ -95,6 +95,12 @@ return function(view) select_prev_entry = function() view:prev_item() end, + select_next_entry_in_commit = function() + view:next_item(true) + end, + select_prev_entry_in_commit = function() + view:prev_item(true) + end, select_first_entry = function() local entry = view.panel.entries[1] if entry and #entry.files > 0 then From 8f55f8d1408c9b29cf2e7f4be849d0e8105dc56a Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Sun, 22 Jun 2025 10:52:06 +0100 Subject: [PATCH 46/77] feat(actions): add open_commit_in_browser action (#476) Open the commit under cursor in the default web browser. Supports GitHub, GitLab, Bitbucket, and other Git hosting services that use common URL patterns. Uses xdg-open on Linux, open on Mac, wslview on WSL, and start on Windows. --- lua/diffview/actions.lua | 1 + .../scene/views/file_history/listeners.lua | 28 ++++++ lua/diffview/vcs/adapters/git/init.lua | 97 ++++++++++++++++++- 3 files changed, 123 insertions(+), 3 deletions(-) diff --git a/lua/diffview/actions.lua b/lua/diffview/actions.lua index 1b9df94e..54e59a0a 100644 --- a/lua/diffview/actions.lua +++ b/lua/diffview/actions.lua @@ -754,6 +754,7 @@ local action_names = { "listing_style", "next_entry", "open_all_folds", + "open_commit_in_browser", "open_commit_log", "open_fold", "open_in_diffview", diff --git a/lua/diffview/scene/views/file_history/listeners.lua b/lua/diffview/scene/views/file_history/listeners.lua index e8d80af7..81f893ec 100644 --- a/lua/diffview/scene/views/file_history/listeners.lua +++ b/lua/diffview/scene/views/file_history/listeners.lua @@ -255,6 +255,34 @@ return function(view) end end end, + open_commit_in_browser = function() + local item = view.panel:get_item_at_cursor() + if not item then + item = view.panel.cur_item[1] + end + if not item or not item.commit then return end + + local url = view.adapter:get_commit_url(item.commit.hash) + if not url then + utils.err("Could not construct browser URL. Remote URL not recognized.") + return + end + + local cmd + if vim.fn.has("mac") == 1 then + cmd = { "open", url } + elseif vim.fn.has("wsl") == 1 then + cmd = { "wslview", url } + elseif vim.fn.has("unix") == 1 then + cmd = { "xdg-open", url } + elseif vim.fn.has("win32") == 1 then + cmd = { "cmd", "/c", "start", '""', url } + end + + if cmd then + vim.fn.jobstart(cmd, { detach = true }) + end + end, restore_entry = async.void(function() local item = view:infer_cur_file() if not item then return end diff --git a/lua/diffview/vcs/adapters/git/init.lua b/lua/diffview/vcs/adapters/git/init.lua index 9de36fea..3424531a 100644 --- a/lua/diffview/vcs/adapters/git/init.lua +++ b/lua/diffview/vcs/adapters/git/init.lua @@ -1332,6 +1332,98 @@ function GitAdapter:get_branch_name() return nil end +---Get the default branch name (main, master, etc.). +---@return string? branch_name The default branch name. +function GitAdapter:get_default_branch() + -- Try to get from origin/HEAD symbolic reference. + local out, code = self:exec_sync( + { "symbolic-ref", "refs/remotes/origin/HEAD" }, + { cwd = self.ctx.toplevel, silent = true } + ) + + if code == 0 and out[1] then + local ref = vim.trim(out[1]) + -- Extract branch name from "refs/remotes/origin/main". + local branch = ref:match("refs/remotes/origin/(.+)$") + if branch then + return branch + end + end + + -- Fall back to checking if main or master exist. + for _, branch in ipairs({ "main", "master" }) do + local _, code_check = self:exec_sync( + { "rev-parse", "--verify", branch }, + { cwd = self.ctx.toplevel, silent = true } + ) + if code_check == 0 then + return branch + end + end + + return nil +end + +---Get the remote URL for origin. +---@param remote? string The remote name (default: "origin"). +---@return string? url The remote URL. +function GitAdapter:get_remote_url(remote) + remote = remote or "origin" + local out, code = self:exec_sync( + { "remote", "get-url", remote }, + { cwd = self.ctx.toplevel, silent = true } + ) + + if code == 0 and out[1] then + return vim.trim(out[1]) + end + + return nil +end + +---Construct a web URL for viewing a commit in the browser. +---Supports GitHub, GitLab, and Bitbucket. +---@param commit_hash string The commit hash. +---@return string? url The web URL, or nil if the hosting service is not recognized. +function GitAdapter:get_commit_url(commit_hash) + local remote_url = self:get_remote_url() + if not remote_url then return nil end + + -- Normalize the URL to extract host and repo path. + local host, repo + + -- Handle SSH URLs: git@github.com:user/repo.git + local ssh_host, ssh_repo = remote_url:match("^git@([^:]+):(.+)$") + if ssh_host and ssh_repo then + host = ssh_host + repo = ssh_repo + else + -- Handle HTTPS URLs: https://github.com/user/repo.git + local https_host, https_repo = remote_url:match("^https?://([^/]+)/(.+)$") + if https_host and https_repo then + host = https_host + repo = https_repo + end + end + + if not host or not repo then return nil end + + -- Remove .git suffix if present. + repo = repo:gsub("%.git$", "") + + -- Construct URL based on hosting service. + if host:match("github") then + return fmt("https://%s/%s/commit/%s", host, repo, commit_hash) + elseif host:match("gitlab") then + return fmt("https://%s/%s/-/commit/%s", host, repo, commit_hash) + elseif host:match("bitbucket") then + return fmt("https://%s/%s/commits/%s", host, repo, commit_hash) + else + -- Generic format (works for many Git hosting services). + return fmt("https://%s/%s/commit/%s", host, repo, commit_hash) + end +end + ---@param path string ---@param rev_arg string? ---@return string? @@ -1473,17 +1565,16 @@ function GitAdapter:parse_revs(rev_arg, opt) left = GitRev(RevType.COMMIT, hash) right = GitRev(RevType.STAGE, 0) else - -- When comparing a single ref with working tree, optionally use merge-base + -- When comparing a single ref with working tree, optionally use merge-base. if opt.merge_base then local merge_base_out, merge_base_code = self:exec_sync( { "merge-base", "HEAD", hash }, { cwd = self.ctx.toplevel, fail_on_empty = true, retry = 2 } ) if merge_base_code == 0 and #merge_base_out > 0 then - -- Use merge-base as the left side left = GitRev(RevType.COMMIT, merge_base_out[1]) else - -- Fallback to the ref itself if merge-base fails + -- Fallback to the ref itself if merge-base fails. left = GitRev(RevType.COMMIT, hash) end else From 4c8a82e298a8f1a3efb0ce0ea36f6b56f921388a Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Sun, 11 Jan 2026 10:23:19 +0100 Subject: [PATCH 47/77] fix: handle global-only options in winopts save scrollopt is a global option and cannot be accessed via vim.wo. Add a whitelist of global-only options and handle them separately when saving window options. --- lua/diffview/scene/window.lua | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/lua/diffview/scene/window.lua b/lua/diffview/scene/window.lua index 967cf134..4d73949e 100644 --- a/lua/diffview/scene/window.lua +++ b/lua/diffview/scene/window.lua @@ -241,13 +241,23 @@ function Window:_is_file_in_use() return false end +-- Options that are global-only and cannot be accessed via vim.wo. +local global_only_opts = { + scrollopt = true, +} + function Window:_save_winopts() if Window.winopt_store[self.file.bufnr] then return end Window.winopt_store[self.file.bufnr] = {} - -- Use vim.wo to get window-local option values, not vim.o which gets global values. for option, _ in pairs(self.file.winopts) do - Window.winopt_store[self.file.bufnr][option] = vim.wo[self.id][option] + if global_only_opts[option] then + -- Global options: save from vim.o. + Window.winopt_store[self.file.bufnr][option] = vim.o[option] + else + -- Window-local options: save from vim.wo to get actual window values. + Window.winopt_store[self.file.bufnr][option] = vim.wo[self.id][option] + end end end From e528fd9988922c9d9cd49c260ed845241a365c8f Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Sun, 18 Jan 2026 22:34:33 +0100 Subject: [PATCH 48/77] fix: disable context plugins on local buffers in diff view Context plugins (nvim-treesitter-context, context.vim) were only being disabled for VCS file buffers (left side), but not for local file buffers (right side). This caused scrollbind visual misalignment where the highlighted line appeared at different vertical positions. Now context plugins are disabled via attach_buffer for all files in the diff view, and the original state is restored when detaching (closing diffview). --- lua/diffview/scene/window.lua | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/lua/diffview/scene/window.lua b/lua/diffview/scene/window.lua index 4d73949e..48334380 100644 --- a/lua/diffview/scene/window.lua +++ b/lua/diffview/scene/window.lua @@ -146,6 +146,17 @@ Window.open_file = async.void(function(self) self.emitter:emit("pre_open") + -- Disable context plugins BEFORE the buffer enters the window. + -- This must happen before BufWinEnter fires, when context plugins decide to show. + -- Save original state for restoration later (only for LOCAL files that we'll restore). + if not self.file._context_state_saved then + self.file._orig_ts_context_disable = vim.b[self.file.bufnr].ts_context_disable + self.file._orig_context_enabled = vim.b[self.file.bufnr].context_enabled + self.file._context_state_saved = true + end + vim.b[self.file.bufnr].ts_context_disable = true -- nvim-treesitter-context + vim.b[self.file.bufnr].context_enabled = false -- context.vim + local conf = config.get_config() api.nvim_win_set_buf(self.id, self.file.bufnr) @@ -222,8 +233,16 @@ function Window:open_null() end function Window:detach_file() - if self.file and self.file:is_valid() then - self.file:detach_buffer() + if self.file then + -- Restore context plugin state for local files. + if self.file._context_state_saved and self.file:is_valid() then + vim.b[self.file.bufnr].ts_context_disable = self.file._orig_ts_context_disable + vim.b[self.file.bufnr].context_enabled = self.file._orig_context_enabled + end + + if self.file:is_valid() then + self.file:detach_buffer() + end end end From 4a1fb3fecf163d6ea764d845758420dc3254aa88 Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Sat, 24 Jan 2026 16:49:56 +0100 Subject: [PATCH 49/77] fix: wrap timer callbacks in `vim.schedule` --- lua/diffview/debounce.lua | 36 ++++++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/lua/diffview/debounce.lua b/lua/diffview/debounce.lua index 74fc51f5..b228b192 100644 --- a/lua/diffview/debounce.lua +++ b/lua/diffview/debounce.lua @@ -85,7 +85,10 @@ function M.debounce_trailing(ms, rush_first, fn) if args then local a = args args = nil - fn(utils.tbl_unpack(a)) + -- Use vim.schedule to avoid E5560 when fn accesses nvim APIs. + vim.schedule(function() + fn(utils.tbl_unpack(a)) + end) end end) end) @@ -142,11 +145,14 @@ function M.throttle_trailing(ms, rush_first, fn) if args then local a = args args = nil - if rush_first then - throttled_fn(utils.tbl_unpack(a)) - else - fn(utils.tbl_unpack(a)) - end + -- Use vim.schedule to avoid E5560 when fn accesses nvim APIs. + vim.schedule(function() + if rush_first then + throttled_fn(utils.tbl_unpack(a)) + else + fn(utils.tbl_unpack(a)) + end + end) end end) end) @@ -211,10 +217,13 @@ function M.set_interval(func, delay) } timer:start(delay, delay, function() - local should_close = func() - if type(should_close) == "boolean" and should_close then - ret.close() - end + -- Use vim.schedule to avoid E5560 when func accesses nvim APIs. + vim.schedule(function() + local should_close = func() + if type(should_close) == "boolean" and should_close then + ret.close() + end + end) end) return ret @@ -235,8 +244,11 @@ function M.set_timeout(func, delay) } timer:start(delay, 0, function() - func() - ret.close() + -- Use vim.schedule to avoid E5560 when func accesses nvim APIs. + vim.schedule(function() + func() + ret.close() + end) end) return ret From f27bd55cca44d5dad97d20b820dc346dbc036303 Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Sat, 10 Jan 2026 09:13:48 +0100 Subject: [PATCH 50/77] docs: clarify treesitter-context setup --- lua/diffview/vcs/file.lua | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lua/diffview/vcs/file.lua b/lua/diffview/vcs/file.lua index 235b09ae..d633514b 100644 --- a/lua/diffview/vcs/file.lua +++ b/lua/diffview/vcs/file.lua @@ -293,8 +293,10 @@ File.create_buffer = async.wrap(function(self, callback) end) -- Disable context plugins that interfere with scrollbind alignment. - vim.b[self.bufnr].ts_context_disable = true -- nvim-treesitter-context - vim.b[self.bufnr].context_enabled = false -- context.vim + -- Note: nvim-treesitter-context does NOT check this variable by default; + -- users must configure `on_attach` callback to check it. context.vim does. + vim.b[self.bufnr].ts_context_disable = true + vim.b[self.bufnr].context_enabled = false vim.bo[self.bufnr].modifiable = last_modifiable vim.bo[self.bufnr].modified = last_modified From a67a808f31415ba0f62fb164a4b67bd47b7c4742 Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Sun, 13 Jul 2025 18:46:41 +0100 Subject: [PATCH 51/77] fix: limit custom fold creation to prevent UI freeze (#552) When using line trace mode (DiffviewFileHistory with line range), many custom folds may need to be created. Creating folds one-by-one with vim.cmd can freeze the UI for large fold counts. Add MAX_CUSTOM_FOLDS limit (100) to skip fold creation when there are too many folds. Also wrap each fold command in pcall for robustness. --- lua/diffview/scene/window.lua | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/lua/diffview/scene/window.lua b/lua/diffview/scene/window.lua index 48334380..dc9604aa 100644 --- a/lua/diffview/scene/window.lua +++ b/lua/diffview/scene/window.lua @@ -340,17 +340,28 @@ function Window:use_winopts(opts) end) end +---Maximum number of custom folds to create. Creating too many folds can freeze +---the UI, especially when transitioning from diff foldmethod to manual. +Window.MAX_CUSTOM_FOLDS = 100 + function Window:apply_custom_folds() if self.file.custom_folds and not self:is_nulled() and vim.wo[self.id].foldmethod == "manual" then + local folds = self.file.custom_folds + local fold_count = #folds + + if fold_count > Window.MAX_CUSTOM_FOLDS then + -- Skip fold creation to prevent UI freeze with many folds. + return + end + api.nvim_win_call(self.id, function() pcall(vim.cmd, "norm! zE") -- Delete all folds in the window - for _, fold in ipairs(self.file.custom_folds) do - vim.cmd(fmt("%d,%dfold", fold[1], fold[2])) - -- print(fmt("%d,%dfold", fold[1], fold[2])) + for _, fold in ipairs(folds) do + pcall(vim.cmd, fmt("%d,%dfold", fold[1], fold[2])) end end) end From 77c99d268a4af0bcdf3d5c41768e9434796b091a Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Sun, 12 Jan 2025 09:33:22 +0100 Subject: [PATCH 52/77] fix: prevent buffer flash when opening diffview (#509) When opening DiffviewOpen a second time, newly created windows inherit the pivot window's buffer, causing a brief flash of incorrect content before the diff buffers are loaded. Fix by immediately loading null buffers in create_post before the async file loading begins. This shows an empty buffer instead of the previous window's content during the loading delay. --- lua/diffview/scene/layout.lua | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lua/diffview/scene/layout.lua b/lua/diffview/scene/layout.lua index 439833bb..6f341f01 100644 --- a/lua/diffview/scene/layout.lua +++ b/lua/diffview/scene/layout.lua @@ -74,6 +74,9 @@ end ---@param self Layout Layout.create_post = async.void(function(self) + -- Immediately load null buffers to prevent flashing the pivot buffer content + -- while waiting for async file loading (#509). + self:open_null() await(self:open_files()) vim.opt.equalalways = self.state.save_equalalways end) From 3b4d3553d228faadf85d7eefca3332882b25218f Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Sat, 4 Jan 2025 20:16:01 +0100 Subject: [PATCH 53/77] fix: nil guards in view update (#395) --- lua/diffview/scene/views/diff/diff_view.lua | 80 +++++++++++++-------- 1 file changed, 50 insertions(+), 30 deletions(-) diff --git a/lua/diffview/scene/views/diff/diff_view.lua b/lua/diffview/scene/views/diff/diff_view.lua index 21ea93b8..05f760d2 100644 --- a/lua/diffview/scene/views/diff/diff_view.lua +++ b/lua/diffview/scene/views/diff/diff_view.lua @@ -401,55 +401,75 @@ DiffView.update_files = debounce.debounce_trailing( for _, opr in ipairs(script) do if opr == EditToken.NOOP then -- Update status and stats - local a_stats = v.cur_files[ai].stats - local b_stats = v.new_files[bi].stats + -- Guard against nil entries that can occur during async race conditions (#395). + local cur_file = v.cur_files[ai] + local new_file = v.new_files[bi] - if a_stats then - v.cur_files[ai].stats = vim.tbl_extend("force", a_stats, b_stats or {}) - else - v.cur_files[ai].stats = v.new_files[bi].stats - end + if cur_file and new_file then + local a_stats = cur_file.stats + local b_stats = new_file.stats + + if a_stats then + cur_file.stats = vim.tbl_extend("force", a_stats, b_stats or {}) + else + cur_file.stats = new_file.stats + end - v.cur_files[ai].status = v.new_files[bi].status - v.cur_files[ai]:validate_stage_buffers(index_stat) + cur_file.status = new_file.status + cur_file:validate_stage_buffers(index_stat) - if new_head then - v.cur_files[ai]:update_heads(new_head) + if new_head then + cur_file:update_heads(new_head) + end end ai = ai + 1 bi = bi + 1 elseif opr == EditToken.DELETE then - if self.panel.cur_file == v.cur_files[ai] then - local file_list = self.panel:ordered_file_list() - if file_list[1] == self.panel.cur_file then - self.panel:set_cur_file(nil) - else - self.panel:set_cur_file(self.panel:prev_file()) + local cur_file = v.cur_files[ai] + if cur_file then + if self.panel.cur_file == cur_file then + local file_list = self.panel:ordered_file_list() + if file_list[1] == self.panel.cur_file then + self.panel:set_cur_file(nil) + else + self.panel:set_cur_file(self.panel:prev_file()) + end end - end - v.cur_files[ai]:destroy() - table.remove(v.cur_files, ai) + cur_file:destroy() + table.remove(v.cur_files, ai) + end elseif opr == EditToken.INSERT then - table.insert(v.cur_files, ai, v.new_files[bi]) - ai = ai + 1 + local new_file = v.new_files[bi] + if new_file then + table.insert(v.cur_files, ai, new_file) + ai = ai + 1 + end bi = bi + 1 elseif opr == EditToken.REPLACE then - if self.panel.cur_file == v.cur_files[ai] then - local file_list = self.panel:ordered_file_list() - if file_list[1] == self.panel.cur_file then - self.panel:set_cur_file(nil) - else - self.panel:set_cur_file(self.panel:prev_file()) + local cur_file = v.cur_files[ai] + local new_file = v.new_files[bi] + + if cur_file then + if self.panel.cur_file == cur_file then + local file_list = self.panel:ordered_file_list() + if file_list[1] == self.panel.cur_file then + self.panel:set_cur_file(nil) + else + self.panel:set_cur_file(self.panel:prev_file()) + end end + + cur_file:destroy() end - v.cur_files[ai]:destroy() - v.cur_files[ai] = v.new_files[bi] + if new_file then + v.cur_files[ai] = new_file + end ai = ai + 1 bi = bi + 1 end From 1d957419ce0e8ac4f62204935c3dfb8f5c217382 Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Sat, 3 Jan 2026 10:32:00 +0100 Subject: [PATCH 54/77] feat: use high foldlevel and jump to first diff - Set foldlevel=99 to keep folds open by default, preventing issues with plugins like vim-markdown that create section folds - Add explicit jump to first diff hunk when opening files, since the previous behaviour of showing the first diff was a side effect of foldlevel=0 folding away unchanged lines - Add Plugin Compatibility section to README --- README.md | 46 +++++++++++++++++++++ lua/diffview/scene/views/diff/listeners.lua | 5 ++- lua/diffview/vcs/file.lua | 5 ++- 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2fa61094..c0281574 100644 --- a/README.md +++ b/README.md @@ -583,6 +583,52 @@ end, { desc = 'Diff against main/master' }) - `DiffviewOpen HEAD~5^..HEAD~5` shows changes within that single commit - For viewing a specific commit's changes, use `DiffviewFileHistory` instead +## Plugin Compatibility + +Some plugins may conflict with diffview's window layout or keymaps. Here are +known issues and workarounds: + +- **lens.vim (automatic window resizing):** + - [camspiers/lens.vim](https://github.com/camspiers/lens.vim) automatically + resizes windows based on focus, which interferes with diffview's layout. + - **Workaround:** Configure lens.vim to exclude diffview filetypes: + ```lua + -- In your lens.vim or lens.nvim config: + vim.g['lens#disabled_filetypes'] = { + 'DiffviewFiles', 'DiffviewFileHistory', 'DiffviewFileHistoryPanel' + } + ``` + +- **nvim-treesitter-context:** + - Context plugins that show code context at the top of windows can cause + visual scrollbind misalignment. + - **Workaround:** Configure the plugin to disable itself for diffview buffers + using the `on_attach` callback: + ```lua + require('treesitter-context').setup({ + on_attach = function(buf) + return not vim.b[buf].ts_context_disable + end, + }) + ``` + +- **vim-markdown (preservim/vim-markdown):** + - vim-markdown creates folds for markdown sections. Older versions of + diffview set `foldlevel=0` which collapsed these sections, hiding diff + content. This has been fixed by setting `foldlevel=99` by default. + - If you still experience issues, you can manually set foldlevel in hooks: + ```lua + require('diffview').setup({ + hooks = { + diff_buf_win_enter = function(bufnr, winid, ctx) + if ctx.layout_name == 'diff2_horizontal' then + vim.wo[winid].foldlevel = 99 + end + end, + }, + }) + ``` + ## Telescope Integration You can use Telescope to select branches or commits for diffview: diff --git a/lua/diffview/scene/views/diff/listeners.lua b/lua/diffview/scene/views/diff/listeners.lua index cef9c66a..89eaa183 100644 --- a/lua/diffview/scene/views/diff/listeners.lua +++ b/lua/diffview/scene/views/diff/listeners.lua @@ -66,8 +66,11 @@ return function(view) if view.cur_entry and view.cur_entry.kind == "conflicting" then actions.next_conflict() - vim.cmd("norm! zz") + else + -- Jump to first diff hunk for regular files. + pcall(vim.cmd, "norm! ]c") end + vim.cmd("norm! zz") end) view.cur_layout:sync_scroll() diff --git a/lua/diffview/vcs/file.lua b/lua/diffview/vcs/file.lua index d633514b..aae64530 100644 --- a/lua/diffview/vcs/file.lua +++ b/lua/diffview/vcs/file.lua @@ -87,7 +87,10 @@ function File:init(opt) foldmethod = "diff", scrollopt = { "ver", "hor", "jump" }, foldcolumn = "1", - foldlevel = 0, + -- Use high foldlevel to keep folds open by default. This prevents issues + -- with plugins like vim-markdown that create section folds which would + -- otherwise collapse and hide diff content. + foldlevel = 99, foldenable = true, winhl = { "DiffAdd:DiffviewDiffAdd", From 0bca5b6a3858ca504e8e0a236cde50b927c4f696 Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Sun, 10 Nov 2024 15:57:42 +0100 Subject: [PATCH 55/77] fix: use prepend for `winhl` (#515) --- lua/diffview/vcs/file.lua | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lua/diffview/vcs/file.lua b/lua/diffview/vcs/file.lua index aae64530..37232c8e 100644 --- a/lua/diffview/vcs/file.lua +++ b/lua/diffview/vcs/file.lua @@ -92,11 +92,14 @@ function File:init(opt) -- otherwise collapse and hide diff content. foldlevel = 99, foldenable = true, + -- Use prepend method so diffview's highlights take precedence but don't + -- clobber user's additional winhl customizations (#515). winhl = { "DiffAdd:DiffviewDiffAdd", "DiffDelete:DiffviewDiffDelete", "DiffChange:DiffviewDiffChange", "DiffText:DiffviewDiffText", + opt = { method = "prepend" }, }, } From a07ce5893e3b0f55c6e4c79e6c6161c8155a8575 Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Sun, 22 Jun 2025 22:41:08 +0100 Subject: [PATCH 56/77] fix: always show folder icons (#579) --- lua/diffview/scene/views/diff/render.lua | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lua/diffview/scene/views/diff/render.lua b/lua/diffview/scene/views/diff/render.lua index 6486af5b..048182af 100644 --- a/lua/diffview/scene/views/diff/render.lua +++ b/lua/diffview/scene/views/diff/render.lua @@ -97,12 +97,12 @@ local function render_file_tree_recurse(depth, comp) dir:add_text(string.rep(" ", depth * 2)) dir:add_text(ctx.collapsed and conf.signs.fold_closed or conf.signs.fold_open, "DiffviewNonText") - if conf.use_icons then - dir:add_text( - " " .. (ctx.collapsed and conf.icons.folder_closed or conf.icons.folder_open) .. " ", - "DiffviewFolderSign" - ) - end + -- Always show folder icons since they're user-configurable and don't require + -- an icon provider like file-type icons do (#579). + dir:add_text( + " " .. (ctx.collapsed and conf.icons.folder_closed or conf.icons.folder_open) .. " ", + "DiffviewFolderSign" + ) dir:add_text(ctx.name .. "/", "DiffviewFolderName") -- Show file count when folder is collapsed. From e91acae1ed391828628fa130ce214a09f732c97a Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Sun, 18 Jan 2026 15:26:27 +0100 Subject: [PATCH 57/77] fix: disable inlay hints in non-LOCAL buffers --- lua/diffview/vcs/file.lua | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/lua/diffview/vcs/file.lua b/lua/diffview/vcs/file.lua index 37232c8e..a3624d73 100644 --- a/lua/diffview/vcs/file.lua +++ b/lua/diffview/vcs/file.lua @@ -377,6 +377,14 @@ function File:attach_buffer(force, opt) end end + -- Inlay hints: Always disable for non-LOCAL buffers to prevent + -- "Invalid 'col': out of range" errors. Inlay hint positions are + -- computed for the current file version, which may differ from the + -- revision shown in the diff buffer. + if HAS_NVIM_0_10 and self.rev and self.rev.type ~= RevType.LOCAL then + pcall(vim.lsp.inlay_hint.enable, false, { bufnr = self.bufnr }) + end + File.attached[self.bufnr] = state end end @@ -409,6 +417,11 @@ function File:detach_buffer() end end + -- Re-enable inlay hints for non-LOCAL buffers (if they were disabled) + if HAS_NVIM_0_10 and self.rev and self.rev.type ~= RevType.LOCAL then + pcall(vim.lsp.inlay_hint.enable, true, { bufnr = self.bufnr }) + end + File.attached[self.bufnr] = nil end end From a1c23270e5134f159ff82ef83529d53686b668ed Mon Sep 17 00:00:00 2001 From: Antoine Saez Dumas Date: Thu, 22 Jan 2026 16:57:47 +0100 Subject: [PATCH 58/77] feat(config): add `status_icons` option (#607) --- doc/diffview_defaults.txt | 13 +++++++++++++ lua/diffview/config.lua | 13 +++++++++++++ lua/diffview/hl.lua | 7 +++++++ lua/diffview/scene/views/diff/render.lua | 4 ++-- lua/diffview/scene/views/file_history/render.lua | 4 ++-- 5 files changed, 37 insertions(+), 4 deletions(-) diff --git a/doc/diffview_defaults.txt b/doc/diffview_defaults.txt index a1d45897..9639d398 100644 --- a/doc/diffview_defaults.txt +++ b/doc/diffview_defaults.txt @@ -16,6 +16,19 @@ DEFAULT CONFIG *diffview.defaults* folder_closed = "", folder_open = "", }, + status_icons = { + ["A"] = "A", -- added + ["?"] = "?", -- untracked + ["M"] = "M", -- modified + ["R"] = "R", -- renamed + ["C"] = "C", -- copied + ["T"] = "T", -- file type change + ["U"] = "U", -- updated but unmerged + ["X"] = "X", + ["D"] = "D", -- deleted + ["B"] = "B", + ["!"] = "!", -- untracked + }, signs = { fold_closed = "", fold_open = "", diff --git a/lua/diffview/config.lua b/lua/diffview/config.lua index e092d746..d62da3a6 100644 --- a/lua/diffview/config.lua +++ b/lua/diffview/config.lua @@ -50,6 +50,19 @@ M.defaults = { folder_closed = "", folder_open = "", }, + status_icons = { + ["A"] = "A", + ["?"] = "?", + ["M"] = "M", + ["R"] = "R", + ["C"] = "C", + ["T"] = "T", + ["U"] = "U", + ["X"] = "X", + ["D"] = "D", + ["B"] = "B", + ["!"] = "!", + }, signs = { fold_closed = "", fold_open = "", diff --git a/lua/diffview/hl.lua b/lua/diffview/hl.lua index d1d4626b..715f0057 100644 --- a/lua/diffview/hl.lua +++ b/lua/diffview/hl.lua @@ -411,6 +411,13 @@ function M.get_git_hl(status) return git_status_hl_map[status] end + +--- @param status string +--- @return string +function M.get_status_icon(status) + return config._config.status_icons[status] or status +end + function M.get_colors() return { white = M.get_fg("Normal") or "White", diff --git a/lua/diffview/scene/views/diff/render.lua b/lua/diffview/scene/views/diff/render.lua index 048182af..d54bd836 100644 --- a/lua/diffview/scene/views/diff/render.lua +++ b/lua/diffview/scene/views/diff/render.lua @@ -11,7 +11,7 @@ local function render_file(comp, show_path, depth) ---@type FileEntry local file = comp.context - comp:add_text(file.status .. " ", hl.get_git_hl(file.status)) + comp:add_text(hl.get_status_icon(file.status) .. " ", hl.get_git_hl(file.status)) if depth then comp:add_text(string.rep(" ", depth * 2 + 2)) @@ -91,7 +91,7 @@ local function render_file_tree_recurse(depth, comp) local ctx = comp.context --[[@as DirData ]] dir:add_text( - get_dir_status_text(ctx, conf.file_panel.tree_options) .. " ", + hl.get_status_icon(get_dir_status_text(ctx, conf.file_panel.tree_options)) .. " ", hl.get_git_hl(ctx.status) ) dir:add_text(string.rep(" ", depth * 2)) diff --git a/lua/diffview/scene/views/file_history/render.lua b/lua/diffview/scene/views/file_history/render.lua index 6d3b7c43..57a0c77a 100644 --- a/lua/diffview/scene/views/file_history/render.lua +++ b/lua/diffview/scene/views/file_history/render.lua @@ -23,7 +23,7 @@ local function render_files(comp, files) ) else if file.status then - comp:add_text(file.status .. " ", hl.get_git_hl(file.status)) + comp:add_text(hl.get_status_icon(file.status) .. " ", hl.get_git_hl(file.status)) else comp:add_text("-" .. " ", "DiffviewNonText") end @@ -89,7 +89,7 @@ local function render_entries(panel, parent, entries, updating) end if entry.status then - comp:add_text(entry.status, hl.get_git_hl(entry.status)) + comp:add_text(hl.get_status_icon(entry.status), hl.get_git_hl(entry.status)) else comp:add_text("-", "DiffviewNonText") end From 06bad4485a47c70c5d6cf4d592f1f055602de881 Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Sun, 27 Jul 2025 13:47:43 +0100 Subject: [PATCH 59/77] docs: clarify LSP diagnostics behaviour in diff buffers (#580) Diagnostics only work for working tree (LOCAL) buffers because LSP servers don't attach to non-file buffers (buftype=nowrite). When comparing commits like `main..HEAD`, users should use `DiffviewOpen main` to see diagnostics on the working tree side. --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index c0281574..4b33aef7 100644 --- a/README.md +++ b/README.md @@ -582,6 +582,15 @@ end, { desc = 'Diff against main/master' }) - `DiffviewOpen HEAD~5..HEAD` compares HEAD~5 to HEAD (excludes working tree changes) - `DiffviewOpen HEAD~5^..HEAD~5` shows changes within that single commit - For viewing a specific commit's changes, use `DiffviewFileHistory` instead +- **LSP diagnostics in diff buffers:** + - Diagnostics only appear for the working tree (LOCAL) side of diffs. + - When comparing commits (e.g., `DiffviewOpen main..HEAD`), neither side is the + working tree, so LSP won't attach to those buffers. + - To see diagnostics, compare against the working tree: `DiffviewOpen main` + (not `main..HEAD`). The right side will show your current files with + diagnostics. + - Inlay hints are automatically disabled for non-working-tree buffers to + prevent position mismatch errors. ## Plugin Compatibility From 2c3456f9f0ad61fb9f6022779292986088c53885 Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Sat, 30 Aug 2025 18:59:12 +0100 Subject: [PATCH 60/77] docs: add FAQ for customising keymaps to avoid conflicts (#518) The default keymaps (e, b, c*) may conflict with user configurations. Rather than changing defaults (breaking change), document how to override them with localleader or disable them entirely. --- README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/README.md b/README.md index 4b33aef7..1e421b30 100644 --- a/README.md +++ b/README.md @@ -591,6 +591,22 @@ end, { desc = 'Diff against main/master' }) diagnostics. - Inlay hints are automatically disabled for non-working-tree buffers to prevent position mismatch errors. +- **Customizing default keymaps to avoid conflicts:** + - The default keymaps (`e`, `b`, `c*`) may conflict + with your configuration. Override them in your setup: + ```lua + require("diffview").setup({ + keymaps = { + view = { + -- Use localleader instead to avoid conflicts + { "n", "e", actions.focus_files }, + { "n", "b", actions.toggle_files }, + -- Or disable specific mappings + { "n", "e", false }, + }, + }, + }) + ``` ## Plugin Compatibility From 07be1280c92868a196bfd4a1660f4991266e6bbb Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Sun, 25 Jan 2026 21:35:13 +0100 Subject: [PATCH 61/77] chore: normalize keymap description punctuation Remove trailing periods from two keymap descriptions for consistency. All descriptions now use sentence fragments without trailing periods. Also document technical debt in OPEN_ISSUES.md: - Deprecated nvim_buf_set_option/get_option API calls - FIXME comments for null handling and Mercurial limitations --- lua/diffview/config.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lua/diffview/config.lua b/lua/diffview/config.lua index d62da3a6..57ff3eea 100644 --- a/lua/diffview/config.lua +++ b/lua/diffview/config.lua @@ -159,8 +159,8 @@ M.defaults = { { "n", "gx", actions.open_file_external, { desc = "Open the file with default system application" } }, { "n", "T", actions.open_in_new_tab, { desc = "Open diffview in a new tab" } }, { "n", "e", actions.focus_files, { desc = "Bring focus to the file panel" } }, - { "n", "b", actions.toggle_files, { desc = "Toggle the file panel." } }, - { "n", "g", actions.cycle_layout, { desc = "Cycle through available layouts." } }, + { "n", "b", actions.toggle_files, { desc = "Toggle the file panel" } }, + { "n", "g", actions.cycle_layout, { desc = "Cycle through available layouts" } }, { "n", "[x", actions.prev_conflict, { desc = "In the merge-tool: jump to the previous conflict" } }, { "n", "]x", actions.next_conflict, { desc = "In the merge-tool: jump to the next conflict" } }, { "n", "co", actions.conflict_choose("ours"), { desc = "Choose the OURS version of a conflict" } }, From af12567c4eebb2b3f331a8ae5cd921c06d5aef09 Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Sat, 10 Jan 2026 14:25:53 +0100 Subject: [PATCH 62/77] fix(git): only show untracked files when comparing index vs working tree PR #587 changed the condition to show untracked files whenever the right side is LOCAL (working tree). This caused untracked files to appear when running `DiffviewOpen `, which compares a commit to the working tree. Restore the original behaviour: only show untracked files when comparing STAGE (index) vs LOCAL (working tree), i.e. standard `DiffviewOpen`. --- lua/diffview/ui/panels/commit_log_panel.lua | 1 + lua/diffview/vcs/adapters/git/init.lua | 11 ++++++----- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/lua/diffview/ui/panels/commit_log_panel.lua b/lua/diffview/ui/panels/commit_log_panel.lua index 12c6068e..e6e8106f 100644 --- a/lua/diffview/ui/panels/commit_log_panel.lua +++ b/lua/diffview/ui/panels/commit_log_panel.lua @@ -49,6 +49,7 @@ end ---@field args string[] ---@field name string +---@param parent StandardView ---@param adapter VCSAdapter ---@param opt CommitLogPanelSpec function CommitLogPanel:init(parent, adapter, opt) diff --git a/lua/diffview/vcs/adapters/git/init.lua b/lua/diffview/vcs/adapters/git/init.lua index 3424531a..02936025 100644 --- a/lua/diffview/vcs/adapters/git/init.lua +++ b/lua/diffview/vcs/adapters/git/init.lua @@ -311,14 +311,14 @@ function GitAdapter:get_merge_context() end local ret = {} - local out, code = self:exec_sync({ "show", "-s", "--pretty=format:%H%n%D", "HEAD", "--no-show-signature", "--" }, self.ctx.toplevel) + local out, code = self:exec_sync({ "show", "-s", "--no-show-signature", "--pretty=format:%H%n%D", "HEAD", "--" }, self.ctx.toplevel) ret.ours = code ~= 0 and {} or { hash = out[1], ref_names = out[2], } - out, code = self:exec_sync({ "show", "-s", "--pretty=format:%H%n%D", their_head, "--no-show-signature", "--" }, self.ctx.toplevel) + out, code = self:exec_sync({ "show", "-s", "--no-show-signature", "--pretty=format:%H%n%D", their_head, "--" }, self.ctx.toplevel) ret.theirs = code ~= 0 and {} or { hash = out[1], @@ -1040,10 +1040,10 @@ GitAdapter.fh_retry_commit = async.wrap(function(self, rev_arg, state, opt, call "-c", "core.quotePath=false", "show", + "--no-show-signature", "--pretty=format:" .. GitAdapter.COMMIT_PRETTY_FMT, "--numstat", "--raw", - "--no-show-signature", "--diff-merges=" .. state.log_options.diff_merges, (state.single_file and state.log_options.follow) and "--follow" or nil, rev_arg, @@ -1801,8 +1801,9 @@ function GitAdapter:show_untracked(opt) opt = opt or {} if opt.revs then - -- Show untracked files when comparing against the working tree (LOCAL) - if opt.revs.right.type ~= RevType.LOCAL then + -- Show untracked files only when comparing index (STAGE) vs working tree (LOCAL). + -- Don't show untracked when comparing a commit to working tree. + if not (opt.revs.left.type == RevType.STAGE and opt.revs.right.type == RevType.LOCAL) then return false end end From 9edde8263481ef467625a4ac0f901c336569438b Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Wed, 4 Feb 2026 10:18:29 +0100 Subject: [PATCH 63/77] chore: improvements from PR integrations --- doc/diffview.txt | 13 +++--- doc/diffview_defaults.txt | 24 +++++------ lua/diffview/actions.lua | 41 ++++++++++++++++++- lua/diffview/config.lua | 26 +++++------- lua/diffview/hl.lua | 8 ++-- lua/diffview/init.lua | 26 ++++++------ lua/diffview/lib.lua | 25 +++++++++++ lua/diffview/scene/views/diff/listeners.lua | 12 ++++++ .../views/file_history/file_history_panel.lua | 20 ++++----- .../views/file_history/file_history_view.lua | 8 ++-- .../scene/views/file_history/listeners.lua | 30 +++++++++++--- lua/diffview/utils.lua | 13 ++++++ lua/diffview/vcs/adapters/hg/init.lua | 7 ++++ 13 files changed, 180 insertions(+), 73 deletions(-) diff --git a/doc/diffview.txt b/doc/diffview.txt index f76ab945..900b40d7 100644 --- a/doc/diffview.txt +++ b/doc/diffview.txt @@ -18,12 +18,13 @@ for any git rev. USAGE *diffview-usage* -Quick-start: `:DiffviewOpen` or `:DiffviewToggle` to open a Diffview that compares against the -index. +Quick-start: `:DiffviewOpen` or `:DiffviewToggle` to open a Diffview that +compares against the index. You can have multiple Diffviews open, tracking different git revs. Each Diffview opens in its own tabpage. To close and dispose of a Diffview, call -`:DiffviewClose`, `:DiffviewToggle` or `:tabclose` while a Diffview is the current tabpage. +`:DiffviewClose`, `:DiffviewToggle`, or `:tabclose` while a Diffview is the +current tabpage. Diffviews are automatically updated: • Every time you enter a Diffview @@ -373,9 +374,9 @@ COMMANDS *diffview-commands* *:DiffviewToggle* :DiffviewToggle [git-rev] [options] [ -- {paths...}] - Alias for `:DiffviewOpen` when the current tabpage is not a Diffview, - otherwise acts as an alias for `:DiffviewClose`. Accepts the same - arguments as `:DiffviewOpen`. + Alias for `:DiffviewOpen` when the current tabpage is not a Diffview, + otherwise acts as an alias for `:DiffviewClose`. Accepts the same + arguments as `:DiffviewOpen`. *:DiffviewToggleFiles* :DiffviewToggleFiles Toggles the file panel. diff --git a/doc/diffview_defaults.txt b/doc/diffview_defaults.txt index 9639d398..0287efc1 100644 --- a/doc/diffview_defaults.txt +++ b/doc/diffview_defaults.txt @@ -16,18 +16,18 @@ DEFAULT CONFIG *diffview.defaults* folder_closed = "", folder_open = "", }, - status_icons = { - ["A"] = "A", -- added - ["?"] = "?", -- untracked - ["M"] = "M", -- modified - ["R"] = "R", -- renamed - ["C"] = "C", -- copied - ["T"] = "T", -- file type change - ["U"] = "U", -- updated but unmerged - ["X"] = "X", - ["D"] = "D", -- deleted - ["B"] = "B", - ["!"] = "!", -- untracked + status_icons = { -- Configure icons for git status letters. + ["A"] = "A", -- Added + ["?"] = "?", -- Untracked + ["M"] = "M", -- Modified + ["R"] = "R", -- Renamed + ["C"] = "C", -- Copied + ["T"] = "T", -- Type changed + ["U"] = "U", -- Unmerged + ["X"] = "X", -- Unknown + ["D"] = "D", -- Deleted + ["B"] = "B", -- Broken + ["!"] = "!", -- Ignored }, signs = { fold_closed = "", diff --git a/lua/diffview/actions.lua b/lua/diffview/actions.lua index 54e59a0a..636b5502 100644 --- a/lua/diffview/actions.lua +++ b/lua/diffview/actions.lua @@ -6,6 +6,7 @@ local lazy = require("diffview.lazy") local DiffView = lazy.access("diffview.scene.views.diff.diff_view", "DiffView") ---@type DiffView|LazyModule local FileHistoryView = lazy.access("diffview.scene.views.file_history.file_history_view", "FileHistoryView") ---@type FileHistoryView|LazyModule local HelpPanel = lazy.access("diffview.ui.panels.help_panel", "HelpPanel") ---@type HelpPanel|LazyModule +local RevType = lazy.access("diffview.vcs.rev", "RevType") ---@type RevType|LazyModule local StandardView = lazy.access("diffview.scene.views.standard.standard_view", "StandardView") ---@type StandardView|LazyModule local config = lazy.require("diffview.config") ---@module "diffview.config" local lib = lazy.require("diffview.lib") ---@module "diffview.lib" @@ -213,6 +214,41 @@ function M.open_in_new_tab() new_view:open() end +---Open a diffview comparing the default branch against working tree. +---The default branch is detected automatically (main, master, or from origin/HEAD). +function M.diff_against_default_branch() + local view = lib.get_current_view() + local adapter + + if view then + adapter = view.adapter + else + -- Get an adapter for the current working directory. + local err + err, adapter = require("diffview.vcs").get_adapter() + if err or not adapter then + utils.err("Failed to get VCS adapter: " .. (err or "unknown error")) + return + end + end + + local default_branch = adapter:get_default_branch() + if not default_branch then + utils.err("Could not detect default branch (main/master). Please specify manually.") + return + end + + local new_view = DiffView({ + adapter = adapter, + rev_arg = default_branch, + left = adapter.Rev(RevType.COMMIT, default_branch), + right = adapter.Rev(RevType.LOCAL), + }) + + lib.add_view(new_view) + new_view:open() +end + ---@class diffview.ConflictCount ---@field total integer ---@field current integer @@ -753,6 +789,7 @@ local action_names = { "focus_files", "listing_style", "next_entry", + "next_entry_in_commit", "open_all_folds", "open_commit_in_browser", "open_commit_log", @@ -760,13 +797,12 @@ local action_names = { "open_in_diffview", "options", "prev_entry", + "prev_entry_in_commit", "refresh_files", "restore_entry", "select_entry", "select_next_entry", - "select_next_entry_in_commit", "select_prev_entry", - "select_prev_entry_in_commit", "select_first_entry", "select_last_entry", "select_next_commit", @@ -776,6 +812,7 @@ local action_names = { "toggle_flatten_dirs", "toggle_fold", "toggle_stage_entry", + "toggle_untracked", "unstage_all", } diff --git a/lua/diffview/config.lua b/lua/diffview/config.lua index 57ff3eea..3944a582 100644 --- a/lua/diffview/config.lua +++ b/lua/diffview/config.lua @@ -51,17 +51,17 @@ M.defaults = { folder_open = "", }, status_icons = { - ["A"] = "A", - ["?"] = "?", - ["M"] = "M", - ["R"] = "R", - ["C"] = "C", - ["T"] = "T", - ["U"] = "U", - ["X"] = "X", - ["D"] = "D", - ["B"] = "B", - ["!"] = "!", + ["A"] = "A", -- Added + ["?"] = "?", -- Untracked + ["M"] = "M", -- Modified + ["R"] = "R", -- Renamed + ["C"] = "C", -- Copied + ["T"] = "T", -- Type changed + ["U"] = "U", -- Unmerged + ["X"] = "X", -- Unknown + ["D"] = "D", -- Deleted + ["B"] = "B", -- Broken + ["!"] = "!", -- Ignored }, signs = { fold_closed = "", @@ -149,8 +149,6 @@ M.defaults = { -- tabpage is a Diffview. { "n", "", actions.select_next_entry, { desc = "Open the diff for the next file" } }, { "n", "", actions.select_prev_entry, { desc = "Open the diff for the previous file" } }, - { "n", "]k", actions.select_next_entry_in_commit, { desc = "Open the diff for the next file within commit" } }, - { "n", "[k", actions.select_prev_entry_in_commit, { desc = "Open the diff for the previous file within commit" } }, { "n", "[F", actions.select_first_entry, { desc = "Open the diff for the first file" } }, { "n", "]F", actions.select_last_entry, { desc = "Open the diff for the last file" } }, { "n", "gf", actions.goto_file_edit, { desc = "Open the file in the previous tabpage" } }, @@ -268,8 +266,6 @@ M.defaults = { { "n", "", actions.scroll_view(0.25), { desc = "Scroll the view down" } }, { "n", "", actions.select_next_entry, { desc = "Open the diff for the next file" } }, { "n", "", actions.select_prev_entry, { desc = "Open the diff for the previous file" } }, - { "n", "]k", actions.select_next_entry_in_commit, { desc = "Open the diff for the next file within commit" } }, - { "n", "[k", actions.select_prev_entry_in_commit, { desc = "Open the diff for the previous file within commit" } }, { "n", "[F", actions.select_first_entry, { desc = "Open the diff for the first file" } }, { "n", "]F", actions.select_last_entry, { desc = "Open the diff for the last file" } }, { "n", "gf", actions.goto_file_edit, { desc = "Open the file in the previous tabpage" } }, diff --git a/lua/diffview/hl.lua b/lua/diffview/hl.lua index 715f0057..767b04c1 100644 --- a/lua/diffview/hl.lua +++ b/lua/diffview/hl.lua @@ -411,11 +411,11 @@ function M.get_git_hl(status) return git_status_hl_map[status] end - ---- @param status string ---- @return string +---Get the configured status icon for a git status letter. +---@param status string Git status letter (e.g., "M", "A", "D"). +---@return string function M.get_status_icon(status) - return config._config.status_icons[status] or status + return config.get_config().status_icons[status] or status end function M.get_colors() diff --git a/lua/diffview/init.lua b/lua/diffview/init.lua index 938f4e8f..6611b6ea 100644 --- a/lua/diffview/init.lua +++ b/lua/diffview/init.lua @@ -128,7 +128,7 @@ end ---@param args string[] function M.open(args) local view = lib.diffview_open(args) - if view then + if view and not (view.tabpage and api.nvim_tabpage_is_valid(view.tabpage)) then view:open() end end @@ -157,7 +157,7 @@ function M.close(tabpage) end end --- @param args string[] +---@param args string[] function M.toggle(args) local view = lib.get_current_view() if view then @@ -213,21 +213,21 @@ M.completers = { if ctx.argidx > ctx.divideridx then if adapter then - utils.vec_push(candidates, unpack(adapter:path_candidates(ctx.arg_lead))) + utils.vec_extend(candidates, adapter:path_candidates(ctx.arg_lead)) else - utils.vec_push(candidates, unpack(vim.fn.getcompletion(ctx.arg_lead, "file", 0))) + utils.vec_extend(candidates, vim.fn.getcompletion(ctx.arg_lead, "file", 0)) end elseif adapter then if not has_rev_arg and ctx.arg_lead:sub(1, 1) ~= "-" then - utils.vec_push(candidates, unpack(adapter.comp.open:get_all_names())) - utils.vec_push(candidates, unpack(adapter:rev_candidates(ctx.arg_lead, { + utils.vec_extend(candidates, adapter.comp.open:get_all_names()) + utils.vec_extend(candidates, adapter:rev_candidates(ctx.arg_lead, { accept_range = true, - }))) + })) else - utils.vec_push(candidates, unpack( + utils.vec_extend(candidates, adapter.comp.open:get_completion(ctx.arg_lead) or adapter.comp.open:get_all_names() - )) + ) end end @@ -239,13 +239,13 @@ M.completers = { local candidates = {} if adapter then - utils.vec_push(candidates, unpack( + utils.vec_extend(candidates, adapter.comp.file_history:get_completion(ctx.arg_lead) or adapter.comp.file_history:get_all_names() - )) - utils.vec_push(candidates, unpack(adapter:path_candidates(ctx.arg_lead))) + ) + utils.vec_extend(candidates, adapter:path_candidates(ctx.arg_lead)) else - utils.vec_push(candidates, unpack(vim.fn.getcompletion(ctx.arg_lead, "file", 0))) + utils.vec_extend(candidates, vim.fn.getcompletion(ctx.arg_lead, "file", 0)) end return candidates diff --git a/lua/diffview/lib.lua b/lua/diffview/lib.lua index be9f1b8f..13e43a2d 100644 --- a/lua/diffview/lib.lua +++ b/lua/diffview/lib.lua @@ -19,6 +19,23 @@ local M = {} ---@type View[] M.views = {} +---Find an existing DiffView matching the given parameters. +---@param adapter VCSAdapter +---@param rev_arg string? +---@param path_args string[] +---@return DiffView? +function M.find_existing_view(adapter, rev_arg, path_args) + for _, view in ipairs(M.views) do + if DiffView.__get():ancestorof(view) + and view.adapter.ctx.toplevel == adapter.ctx.toplevel + and view.rev_arg == rev_arg + and vim.deep_equal(view.path_args or {}, path_args or {}) then + return view + end + end + return nil +end + function M.diffview_open(args) local default_args = config.get_config().default_args.DiffviewOpen local argo = arg_parser.parse(utils.flatten({ default_args, args })) @@ -43,6 +60,14 @@ function M.diffview_open(args) ---@cast adapter -? + -- Check for existing view with matching parameters. + local existing = M.find_existing_view(adapter, rev_arg, adapter.ctx.path_args) + if existing and existing.tabpage and api.nvim_tabpage_is_valid(existing.tabpage) then + api.nvim_set_current_tabpage(existing.tabpage) + logger:debug("Switched to existing DiffView") + return existing + end + local opts = adapter:diffview_options(argo) if opts == nil then diff --git a/lua/diffview/scene/views/diff/listeners.lua b/lua/diffview/scene/views/diff/listeners.lua index 89eaa183..d79c4b7a 100644 --- a/lua/diffview/scene/views/diff/listeners.lua +++ b/lua/diffview/scene/views/diff/listeners.lua @@ -325,6 +325,18 @@ return function(view) view.panel:render() view.panel:redraw() end, + toggle_untracked = function() + -- Only applicable to working tree comparisons. + if not (view.left.type == RevType.STAGE and view.right.type == RevType.LOCAL) then + utils.info("Toggle untracked is only available when comparing staged vs working tree.") + return + end + + view.options.show_untracked = not view.options.show_untracked + local state = view.options.show_untracked and "shown" or "hidden" + utils.info(("Untracked files: %s"):format(state)) + view:update_files() + end, focus_files = function() view.panel:focus() end, diff --git a/lua/diffview/scene/views/file_history/file_history_panel.lua b/lua/diffview/scene/views/file_history/file_history_panel.lua index 38a2a28c..2240452f 100644 --- a/lua/diffview/scene/views/file_history/file_history_panel.lua +++ b/lua/diffview/scene/views/file_history/file_history_panel.lua @@ -386,13 +386,11 @@ end ---@param offset integer ---@return LogEntry? ---@return FileEntry? -function FileHistoryPanel:_get_entry_by_file_offset(entry_idx, file_idx, offset, cycle_in_commit) +function FileHistoryPanel:_get_entry_by_file_offset(entry_idx, file_idx, offset) local cur_entry = self.entries[entry_idx] - local entryPos = cycle_in_commit and ((file_idx + offset - 1) % #cur_entry.files + 1) or (file_idx + offset) - - if cur_entry.files[entryPos] then - return cur_entry, cur_entry.files[entryPos] + if cur_entry.files[file_idx + offset] then + return cur_entry, cur_entry.files[file_idx + offset] end local sign = utils.sign(offset) @@ -412,7 +410,7 @@ function FileHistoryPanel:_get_entry_by_file_offset(entry_idx, file_idx, offset, end end -function FileHistoryPanel:set_file_by_offset(offset, cycle_in_commit) +function FileHistoryPanel:set_file_by_offset(offset) if self:num_items() == 0 then return end local entry, file = self.cur_item[1], self.cur_item[2] @@ -427,7 +425,7 @@ function FileHistoryPanel:set_file_by_offset(offset, cycle_in_commit) local file_idx = utils.vec_indexof(entry.files, file) if entry_idx ~= -1 and file_idx ~= -1 then - local next_entry, next_file = self:_get_entry_by_file_offset(entry_idx, file_idx, offset, cycle_in_commit) + local next_entry, next_file = self:_get_entry_by_file_offset(entry_idx, file_idx, offset) self:set_cur_item({ next_entry, next_file }) if next_entry ~= entry then @@ -442,12 +440,12 @@ function FileHistoryPanel:set_file_by_offset(offset, cycle_in_commit) end end -function FileHistoryPanel:prev_file(cycle_in_commit) - return self:set_file_by_offset(-vim.v.count1, cycle_in_commit) +function FileHistoryPanel:prev_file() + return self:set_file_by_offset(-vim.v.count1) end -function FileHistoryPanel:next_file(cycle_in_commit) - return self:set_file_by_offset(vim.v.count1, cycle_in_commit) +function FileHistoryPanel:next_file() + return self:set_file_by_offset(vim.v.count1) end ---@param item LogEntry|FileEntry diff --git a/lua/diffview/scene/views/file_history/file_history_view.lua b/lua/diffview/scene/views/file_history/file_history_view.lua index 28913739..fd1c6093 100644 --- a/lua/diffview/scene/views/file_history/file_history_view.lua +++ b/lua/diffview/scene/views/file_history/file_history_view.lua @@ -124,13 +124,13 @@ FileHistoryView._set_file = async.void(function(self, file) end end) -function FileHistoryView:next_item(cycle_in_commit) +function FileHistoryView:next_item() self:ensure_layout() if self:file_safeguard() then return end if self.panel:num_items() > 1 or self.nulled then - local cur = self.panel:next_file(cycle_in_commit) + local cur = self.panel:next_file() if cur then self.panel:highlight_item(cur) @@ -142,13 +142,13 @@ function FileHistoryView:next_item(cycle_in_commit) end end -function FileHistoryView:prev_item(cycle_in_commit) +function FileHistoryView:prev_item() self:ensure_layout() if self:file_safeguard() then return end if self.panel:num_items() > 1 or self.nulled then - local cur = self.panel:prev_file(cycle_in_commit) + local cur = self.panel:prev_file() if cur then self.panel:highlight_item(cur) diff --git a/lua/diffview/scene/views/file_history/listeners.lua b/lua/diffview/scene/views/file_history/listeners.lua index 81f893ec..a456ba7a 100644 --- a/lua/diffview/scene/views/file_history/listeners.lua +++ b/lua/diffview/scene/views/file_history/listeners.lua @@ -95,12 +95,6 @@ return function(view) select_prev_entry = function() view:prev_item() end, - select_next_entry_in_commit = function() - view:next_item(true) - end, - select_prev_entry_in_commit = function() - view:prev_item(true) - end, select_first_entry = function() local entry = view.panel.entries[1] if entry and #entry.files > 0 then @@ -133,6 +127,30 @@ return function(view) local next_entry = view.panel.entries[next_idx] view:set_file(next_entry.files[1]) end, + ---Cycle to next file within the current commit (wrap around). + next_entry_in_commit = function() + local cur_entry = view.panel.cur_item[1] + local cur_file = view.panel.cur_item[2] + if not cur_entry or not cur_file then return end + + local file_idx = utils.vec_indexof(cur_entry.files, cur_file) + if file_idx == -1 then return end + + local next_idx = (file_idx % #cur_entry.files) + 1 + view:set_file(cur_entry.files[next_idx]) + end, + ---Cycle to previous file within the current commit (wrap around). + prev_entry_in_commit = function() + local cur_entry = view.panel.cur_item[1] + local cur_file = view.panel.cur_item[2] + if not cur_entry or not cur_file then return end + + local file_idx = utils.vec_indexof(cur_entry.files, cur_file) + if file_idx == -1 then return end + + local prev_idx = ((file_idx - 2) % #cur_entry.files) + 1 + view:set_file(cur_entry.files[prev_idx]) + end, next_entry = function() view.panel:highlight_next_file() end, diff --git a/lua/diffview/utils.lua b/lua/diffview/utils.lua index 4cbddcc8..7e0bf180 100644 --- a/lua/diffview/utils.lua +++ b/lua/diffview/utils.lua @@ -840,6 +840,19 @@ function M.vec_push(t, ...) return t end +---Append all elements from another table to the end of a vector. +---Unlike vec_push with unpack(), this handles large tables safely. +---@param t vector +---@param other table +---@return vector t +function M.vec_extend(t, other) + for i = 1, #other do + t[#t + 1] = other[i] + end + + return t +end + ---Remove an object from a vector. ---@param t vector ---@param v any diff --git a/lua/diffview/vcs/adapters/hg/init.lua b/lua/diffview/vcs/adapters/hg/init.lua index 88730bca..4ea1a998 100644 --- a/lua/diffview/vcs/adapters/hg/init.lua +++ b/lua/diffview/vcs/adapters/hg/init.lua @@ -876,6 +876,13 @@ function HgAdapter:get_branch_name() return nil end +---Get the default branch name. +---@return string branch_name The default branch name ("default" for Mercurial). +function HgAdapter:get_default_branch() + -- Mercurial's default branch is always "default". + return "default" +end + function HgAdapter:rev_to_args(left, right) assert( not (left.type == RevType.LOCAL and right.type == RevType.LOCAL), From bac5b734c889b3b0dfc46c32f9597f1cad914722 Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Wed, 4 Feb 2026 11:37:35 +0100 Subject: [PATCH 64/77] docs: add fork notice to README --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 1e421b30..8afb6612 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Diffview.nvim +> **Note:** This is a fork of [sindrets/diffview.nvim](https://github.com/sindrets/diffview.nvim) with bug fixes and improvements applied. The original repository has not been updated since June 2024. + Single tabpage interface for easily cycling through diffs for all modified files for any git rev. From f9462f84aa1356ee824960ae2447c37bd480f8c2 Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Thu, 5 Feb 2026 15:08:18 +0100 Subject: [PATCH 65/77] fix(keymaps): register commit_log_panel keymaps in init_buffer instead of init (#1) Fixes bug introduced in 1b386ce which caused Esc to close any diffview window. --- lua/diffview/ui/panels/commit_log_panel.lua | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/lua/diffview/ui/panels/commit_log_panel.lua b/lua/diffview/ui/panels/commit_log_panel.lua index e6e8106f..06ffd4b0 100644 --- a/lua/diffview/ui/panels/commit_log_panel.lua +++ b/lua/diffview/ui/panels/commit_log_panel.lua @@ -67,6 +67,17 @@ function CommitLogPanel:init(parent, adapter, opt) end, }) + parent.emitter:on("close", function(e) + if self:is_focused() then + self:close() + e:stop_propagation() + end + end) +end + +function CommitLogPanel:init_buffer() + CommitLogPanel:super_class().init_buffer(self) + local conf = get_user_config().keymaps local default_opt = { silent = true, nowait = true, buffer = self.bufid } @@ -74,13 +85,6 @@ function CommitLogPanel:init(parent, adapter, opt) local map_opt = vim.tbl_extend("force", default_opt, mapping[4] or {}, { buffer = self.bufid }) vim.keymap.set(mapping[1], mapping[2], mapping[3], map_opt) end - - parent.emitter:on("close", function(e) - if self:is_focused() then - self:close() - e:stop_propagation() - end - end) end ---@param self CommitLogPanel From 580f49fc3f2c7defa2e99c93050795d053b5e2bc Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Fri, 6 Feb 2026 16:37:33 +0100 Subject: [PATCH 66/77] feat(file-history): add stat_style config for stat bars (#2) --- lua/diffview/config.lua | 1 + .../scene/views/file_history/render.lua | 50 +++++++++++++++++-- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/lua/diffview/config.lua b/lua/diffview/config.lua index 3944a582..bfd8efae 100644 --- a/lua/diffview/config.lua +++ b/lua/diffview/config.lua @@ -106,6 +106,7 @@ M.defaults = { show_branch_name = false, -- Show branch name in the file panel header. }, file_history_panel = { + stat_style = "number", -- "number" (e.g. "5, 3"), "bar" (e.g. "| 8 +++++---"), or "both". log_options = { ---@type ConfigLogOptions git = { diff --git a/lua/diffview/scene/views/file_history/render.lua b/lua/diffview/scene/views/file_history/render.lua index 57a0c77a..7c312ce5 100644 --- a/lua/diffview/scene/views/file_history/render.lua +++ b/lua/diffview/scene/views/file_history/render.lua @@ -10,9 +10,55 @@ local pl = utils.path local cache = setmetatable({}, { __mode = "k" }) +local MAX_BAR_WIDTH = 20 + +---Render a stat bar (like git --stat) onto a component. +---@param comp RenderComponent +---@param additions integer +---@param deletions integer +local function render_stat_bar(comp, additions, deletions) + local total = additions + deletions + if total == 0 then return end + + local bar_width = math.min(total, MAX_BAR_WIDTH) + local add_width = math.floor(additions / total * bar_width + 0.5) + local del_width = bar_width - add_width + + comp:add_text(" | ", "DiffviewNonText") + comp:add_text(tostring(total) .. " ", "DiffviewFilePanelCounter") + + if add_width > 0 then + comp:add_text(string.rep("+", add_width), "DiffviewFilePanelInsertions") + end + if del_width > 0 then + comp:add_text(string.rep("-", del_width), "DiffviewFilePanelDeletions") + end +end + +---Render file stats onto a component. +---@param comp RenderComponent +---@param stats GitStats +---@param stat_style string +local function render_file_stats(comp, stats, stat_style) + local show_number = stat_style == "number" or stat_style == "both" + local show_bar = stat_style == "bar" or stat_style == "both" + + if show_number then + comp:add_text(" " .. stats.additions, "DiffviewFilePanelInsertions") + comp:add_text(", ") + comp:add_text(tostring(stats.deletions), "DiffviewFilePanelDeletions") + end + + if show_bar and stats.additions and stats.deletions then + render_stat_bar(comp, stats.additions, stats.deletions) + end +end + ---@param comp RenderComponent ---@param files FileEntry[] local function render_files(comp, files) + local stat_style = config.get_config().file_history_panel.stat_style or "number" + for i, file in ipairs(files) do comp:add_text(i == #files and "└ " or "│ ", "DiffviewNonText") @@ -38,9 +84,7 @@ local function render_files(comp, files) comp:add_text(file.basename, file.active and "DiffviewFilePanelSelected" or "DiffviewFilePanelFileName") if file.stats then - comp:add_text(" " .. file.stats.additions, "DiffviewFilePanelInsertions") - comp:add_text(", ") - comp:add_text(tostring(file.stats.deletions), "DiffviewFilePanelDeletions") + render_file_stats(comp, file.stats, stat_style) end end From 57ec564b180d007b18b5c54ed322474ca0cf7d4b Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Fri, 6 Feb 2026 16:37:38 +0100 Subject: [PATCH 67/77] feat(config): add file_panel.sort_file custom comparator (#3) --- lua/diffview/config.lua | 1 + lua/diffview/ui/models/file_tree/node.lua | 23 ++++++++++++++++++----- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/lua/diffview/config.lua b/lua/diffview/config.lua index bfd8efae..ed0024ff 100644 --- a/lua/diffview/config.lua +++ b/lua/diffview/config.lua @@ -92,6 +92,7 @@ M.defaults = { }, file_panel = { listing_style = "tree", + sort_file = nil, -- Custom file comparator: function(a_name, b_name, a_data, b_data) -> boolean tree_options = { flatten_dirs = true, folder_statuses = "only_folded" diff --git a/lua/diffview/ui/models/file_tree/node.lua b/lua/diffview/ui/models/file_tree/node.lua index 3da6e0de..6bc487aa 100644 --- a/lua/diffview/ui/models/file_tree/node.lua +++ b/lua/diffview/ui/models/file_tree/node.lua @@ -1,3 +1,4 @@ +local config = require("diffview.config") local oop = require("diffview.oop") local utils = require("diffview.utils") local M = {} @@ -46,16 +47,28 @@ function Node:has_children() end ---Compare against another node alphabetically and case-insensitive by node names. ----Directory nodes come before file nodes. +---Directory nodes come before file nodes. If a custom `file_panel.sort_file` +---comparator is configured, it is used for file-to-file comparisons. ---@param a Node ---@param b Node ---@return boolean true if node a comes before node b function Node.comparator(a, b) - if a:has_children() == b:has_children() then - return string.lower(a.name) < string.lower(b.name) - else - return a:has_children() + local a_dir = a:has_children() + local b_dir = b:has_children() + + if a_dir ~= b_dir then + return a_dir end + + -- For file nodes, use custom comparator if configured. + if not a_dir then + local sort_file = config.get_config().file_panel.sort_file + if sort_file and type(sort_file) == "function" then + return sort_file(a.name, b.name, a.data, b.data) + end + end + + return string.lower(a.name) < string.lower(b.name) end function Node:sort() From 6bf56a3939764b5966e45e535f00f8bfa3ba15c0 Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Fri, 6 Feb 2026 16:37:49 +0100 Subject: [PATCH 68/77] fix(keymaps): save and restore buffer-local keymaps on attach/detach (#4) --- lua/diffview/vcs/file.lua | 50 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/lua/diffview/vcs/file.lua b/lua/diffview/vcs/file.lua index a3624d73..b6d5f688 100644 --- a/lua/diffview/vcs/file.lua +++ b/lua/diffview/vcs/file.lua @@ -341,8 +341,41 @@ end ---@class vcs.File.AttachState ---@field keymaps table +---@field saved_keymaps table Original buffer keymaps saved before overwriting. ---@field disable_diagnostics boolean +---Save any existing buffer-local keymap for the given mode and lhs before +---diffview overwrites it, so we can restore it on detach. +---@param bufnr integer +---@param saved table +---@param mode string +---@param lhs string +local function save_existing_keymap(bufnr, saved, mode, lhs) + local key = mode .. " " .. lhs + if saved[key] then return end + + local buf_maps = api.nvim_buf_get_keymap(bufnr, mode) + for _, km in ipairs(buf_maps) do + if km.lhs == lhs then + saved[key] = { + mode = mode, + lhs = lhs, + rhs = km.rhs or "", + callback = km.callback, + opts = { + buffer = bufnr, + desc = km.desc, + silent = km.silent == 1 or km.silent == true, + noremap = km.noremap == 1 or km.noremap == true, + nowait = km.nowait == 1 or km.nowait == true, + expr = km.expr == 1 or km.expr == true, + }, + } + return + end + end +end + ---@param force? boolean ---@param opt? vcs.File.AttachState function File:attach_buffer(force, opt) @@ -360,9 +393,14 @@ function File:attach_buffer(force, opt) -- Keymaps state.keymaps = config.extend_keymaps(conf.keymaps.view, state.keymaps) + state.saved_keymaps = state.saved_keymaps or {} local default_map_opt = { silent = true, nowait = true, buffer = self.bufnr } for _, mapping in ipairs(state.keymaps) do + local modes = type(mapping[1]) == "table" and mapping[1] or { mapping[1] } + for _, mode in ipairs(modes) do + save_existing_keymap(self.bufnr, state.saved_keymaps, mode, mapping[2]) + end local map_opt = vim.tbl_extend("force", default_map_opt, mapping[4] or {}, { buffer = self.bufnr }) vim.keymap.set(mapping[1], mapping[2], mapping[3], map_opt) end @@ -395,7 +433,7 @@ function File:detach_buffer() local state = File.attached[self.bufnr] if state then - -- Keymaps + -- Keymaps: remove diffview's mappings. for lhs, mapping in pairs(state.keymaps) do if type(lhs) == "number" then local modes = type(mapping[1]) == "table" and mapping[1] or { mapping[1] } @@ -407,6 +445,16 @@ function File:detach_buffer() end end + -- Restore original buffer keymaps that were saved before attach. + if state.saved_keymaps then + for _, km in pairs(state.saved_keymaps) do + local rhs = km.callback or km.rhs + if rhs and api.nvim_buf_is_valid(self.bufnr) then + pcall(vim.keymap.set, km.mode, km.lhs, rhs, km.opts) + end + end + end + -- Diagnostics if state.disable_diagnostics then if HAS_NVIM_0_10 then From e0a661183fd0230941c94e44ad3bc938e67123ba Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Fri, 6 Feb 2026 16:38:02 +0100 Subject: [PATCH 69/77] refactor: replace deprecated nvim_buf_get/set_option with vim.bo (#5) --- lua/diffview/renderer.lua | 6 +++--- lua/diffview/ui/panel.lua | 2 +- lua/diffview/vcs/file.lua | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lua/diffview/renderer.lua b/lua/diffview/renderer.lua index 6519e81e..f5c18d18 100644 --- a/lua/diffview/renderer.lua +++ b/lua/diffview/renderer.lua @@ -492,8 +492,8 @@ function M.render(bufid, data) end local last = vim.loop.hrtime() - local was_modifiable = api.nvim_buf_get_option(bufid, "modifiable") - api.nvim_buf_set_option(bufid, "modifiable", true) + local was_modifiable = vim.bo[bufid].modifiable + vim.bo[bufid].modifiable = true local lines, hl_data local line_idx = 0 @@ -523,7 +523,7 @@ function M.render(bufid, data) end end - api.nvim_buf_set_option(bufid, "modifiable", was_modifiable) + vim.bo[bufid].modifiable = was_modifiable M.last_draw_time = (vim.loop.hrtime() - last) / 1000000 end diff --git a/lua/diffview/ui/panel.lua b/lua/diffview/ui/panel.lua index 9b0e2826..f08721ba 100644 --- a/lua/diffview/ui/panel.lua +++ b/lua/diffview/ui/panel.lua @@ -363,7 +363,7 @@ function Panel:init_buffer() local bn = api.nvim_create_buf(false, false) for k, v in pairs(self.class.bufopts) do - api.nvim_buf_set_option(bn, k, v) + vim.bo[bn][k] = v end local bufname diff --git a/lua/diffview/vcs/file.lua b/lua/diffview/vcs/file.lua index b6d5f688..3752417e 100644 --- a/lua/diffview/vcs/file.lua +++ b/lua/diffview/vcs/file.lua @@ -286,7 +286,7 @@ File.create_buffer = async.wrap(function(self, callback) end for option, value in pairs(bufopts) do - api.nvim_buf_set_option(self.bufnr, option, value) + vim.bo[self.bufnr][option] = value end local last_modifiable = vim.bo[self.bufnr].modifiable @@ -505,7 +505,7 @@ function File._get_null_buffer() if not api.nvim_buf_is_loaded(File.NULL_FILE.bufnr or -1) then local bn = api.nvim_create_buf(false, false) for option, value in pairs(File.bufopts) do - api.nvim_buf_set_option(bn, option, value) + vim.bo[bn][option] = value end local bufname = "diffview://null" From 7009c404c5b15ef2be4d710bea8321c569f86349 Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Fri, 6 Feb 2026 16:38:17 +0100 Subject: [PATCH 70/77] feat(config): add clean_up_buffers option (#6) --- lua/diffview/config.lua | 3 ++- lua/diffview/scene/views/diff/diff_view.lua | 23 +++++++++++++++++++ .../views/file_history/file_history_view.lua | 23 +++++++++++++++++++ lua/diffview/vcs/file.lua | 7 ++++++ 4 files changed, 55 insertions(+), 1 deletion(-) diff --git a/lua/diffview/config.lua b/lua/diffview/config.lua index ed0024ff..3fff6dd3 100644 --- a/lua/diffview/config.lua +++ b/lua/diffview/config.lua @@ -45,7 +45,8 @@ M.defaults = { show_help_hints = true, watch_index = true, hide_merge_artifacts = false, -- Hide merge artifact files (*.orig, *.BACKUP.*, etc.) - auto_close_on_empty = false, -- Automatically close diffview when the last file is staged/resolved + auto_close_on_empty = false, -- Automatically close diffview when the last file is staged/resolved. + clean_up_buffers = false, -- Delete file buffers created by diffview on close (only buffers not open before diffview). icons = { folder_closed = "", folder_open = "", diff --git a/lua/diffview/scene/views/diff/diff_view.lua b/lua/diffview/scene/views/diff/diff_view.lua index 05f760d2..81c5c7aa 100644 --- a/lua/diffview/scene/views/diff/diff_view.lua +++ b/lua/diffview/scene/views/diff/diff_view.lua @@ -16,6 +16,7 @@ local config = lazy.require("diffview.config") ---@module "diffview.config" local debounce = lazy.require("diffview.debounce") ---@module "diffview.debounce" local utils = lazy.require("diffview.utils") ---@module "diffview.utils" local vcs_utils = lazy.require("diffview.vcs.utils") ---@module "diffview.vcs.utils" +local File = lazy.access("diffview.vcs.file", "File") ---@type vcs.File|LazyModule local GitAdapter = lazy.access("diffview.vcs.adapters.git", "GitAdapter") ---@type GitAdapter|LazyModule local api = vim.api @@ -183,6 +184,28 @@ function DiffView:close() file:destroy() end + -- Clean up LOCAL buffers created by diffview that the user didn't have open before. + if config.get_config().clean_up_buffers then + for bufnr, _ in pairs(File.created_bufs) do + if api.nvim_buf_is_valid(bufnr) and not vim.bo[bufnr].modified then + -- Only delete if not displayed in a window outside this tabpage. + local dominated = true + for _, winid in ipairs(utils.win_find_buf(bufnr, 0)) do + if api.nvim_win_get_tabpage(winid) ~= self.tabpage then + dominated = false + break + end + end + + if dominated then + pcall(api.nvim_buf_delete, bufnr, { force = false }) + end + end + + File.created_bufs[bufnr] = nil + end + end + self.commit_log_panel:destroy() DiffView.super_class.close(self) end diff --git a/lua/diffview/scene/views/file_history/file_history_view.lua b/lua/diffview/scene/views/file_history/file_history_view.lua index fd1c6093..99219993 100644 --- a/lua/diffview/scene/views/file_history/file_history_view.lua +++ b/lua/diffview/scene/views/file_history/file_history_view.lua @@ -7,8 +7,10 @@ local EventName = lazy.access("diffview.events", "EventName") ---@type EventName local FileHistoryPanel = lazy.access("diffview.scene.views.file_history.file_history_panel", "FileHistoryPanel") ---@type FileHistoryPanel|LazyModule local JobStatus = lazy.access("diffview.vcs.utils", "JobStatus") ---@type JobStatus|LazyModule local LogEntry = lazy.access("diffview.vcs.log_entry", "LogEntry") ---@type LogEntry|LazyModule +local File = lazy.access("diffview.vcs.file", "File") ---@type vcs.File|LazyModule local StandardView = lazy.access("diffview.scene.views.standard.standard_view", "StandardView") ---@type StandardView|LazyModule local config = lazy.require("diffview.config") ---@module "diffview.config" +local utils = lazy.require("diffview.utils") ---@module "diffview.utils" local api = vim.api local await = async.await @@ -72,6 +74,27 @@ function FileHistoryView:close() entry:destroy() end + -- Clean up LOCAL buffers created by diffview that the user didn't have open before. + if config.get_config().clean_up_buffers then + for bufnr, _ in pairs(File.created_bufs) do + if api.nvim_buf_is_valid(bufnr) and not vim.bo[bufnr].modified then + local dominated = true + for _, winid in ipairs(utils.win_find_buf(bufnr, 0)) do + if api.nvim_win_get_tabpage(winid) ~= self.tabpage then + dominated = false + break + end + end + + if dominated then + pcall(api.nvim_buf_delete, bufnr, { force = false }) + end + end + + File.created_bufs[bufnr] = nil + end + end + self.commit_log_panel:destroy() FileHistoryView.super_class.close(self) end diff --git a/lua/diffview/vcs/file.lua b/lua/diffview/vcs/file.lua index 3752417e..88861fe7 100644 --- a/lua/diffview/vcs/file.lua +++ b/lua/diffview/vcs/file.lua @@ -52,6 +52,10 @@ File.attached = {} ---@type table> File.index_bufmap = {} +---Tracks LOCAL buffers that were newly created by diffview (not pre-existing). +---@type table +File.created_bufs = {} + ---@static File.bufopts = { buftype = "nowrite", @@ -173,6 +177,9 @@ function File:_create_local_buffer() end) api.nvim_win_close(winid, true) + + -- Track this buffer as created by diffview so it can be cleaned up on close. + File.created_bufs[self.bufnr] = true else -- NOTE: LSP servers might load buffers in the background and unlist -- them. Explicitly set the buffer as listed when loading it here. From 8ef5221fe7ae105da073c996d20be25c45e074f1 Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Fri, 6 Feb 2026 17:06:34 +0100 Subject: [PATCH 71/77] feat: add --selected-row option for cursor positioning (#7) --- lua/diffview/scene/views/diff/diff_view.lua | 12 ++++++++++++ lua/diffview/vcs/adapters/git/init.lua | 2 ++ lua/diffview/vcs/adapters/hg/init.lua | 1 + 3 files changed, 15 insertions(+) diff --git a/lua/diffview/scene/views/diff/diff_view.lua b/lua/diffview/scene/views/diff/diff_view.lua index 81c5c7aa..de3d42e1 100644 --- a/lua/diffview/scene/views/diff/diff_view.lua +++ b/lua/diffview/scene/views/diff/diff_view.lua @@ -30,6 +30,7 @@ local M = {} ---@class DiffViewOptions ---@field show_untracked? boolean ---@field selected_file? string Path to the preferred initially selected file. +---@field selected_row? integer Row to position the cursor on after opening the selected file. ---@class DiffView : StandardView ---@operator call : DiffView @@ -540,6 +541,17 @@ DiffView.update_files = debounce.debounce_trailing( self:set_file(self.panel.cur_file or self.panel:next_file(), false, not self.initialized) + -- Position cursor at the requested row on first open. + if not self.initialized and self.options.selected_row then + local win = self.cur_layout:get_main_win() + if win and api.nvim_win_is_valid(win.id) then + local buf = api.nvim_win_get_buf(win.id) + local line_count = api.nvim_buf_line_count(buf) + local row = math.min(self.options.selected_row, line_count) + pcall(api.nvim_win_set_cursor, win.id, { math.max(1, row), 0 }) + end + end + self.update_needed = false perf:time() logger:lvl(5):debug(perf) diff --git a/lua/diffview/vcs/adapters/git/init.lua b/lua/diffview/vcs/adapters/git/init.lua index 02936025..cef5e989 100644 --- a/lua/diffview/vcs/adapters/git/init.lua +++ b/lua/diffview/vcs/adapters/git/init.lua @@ -1296,6 +1296,7 @@ function GitAdapter:diffview_options(argo) selected_file = argo:get_flag("selected-file", { no_empty = true, expand = true }) or (vim.bo.buftype == "" and pl:vim_expand("%:p")) or nil, + selected_row = tonumber(argo:get_flag("selected-row", { no_empty = true })), } return {left = left, right = right, options = options} @@ -2258,6 +2259,7 @@ function GitAdapter:init_completion() self.comp.open:put({ "selected-file" }, function (_, arg_lead) return vim.fn.getcompletion(arg_lead, "file") end) + self.comp.open:put({ "selected-row" }) self.comp.file_history:put({ "base" }, function(_, arg_lead) return utils.vec_join("LOCAL", self:rev_candidates(arg_lead)) diff --git a/lua/diffview/vcs/adapters/hg/init.lua b/lua/diffview/vcs/adapters/hg/init.lua index 4ea1a998..542abb72 100644 --- a/lua/diffview/vcs/adapters/hg/init.lua +++ b/lua/diffview/vcs/adapters/hg/init.lua @@ -829,6 +829,7 @@ function HgAdapter:diffview_options(argo) selected_file = argo:get_flag("selected-file", { no_empty = true, expand = true }) or (vim.bo.buftype == "" and pl:vim_expand("%:p")) or nil, + selected_row = tonumber(argo:get_flag("selected-row", { no_empty = true })), } return {left = left, right = right, options = options} From c5b92001399637a634858aee4738ee4bd855d3ba Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Fri, 6 Feb 2026 17:09:55 +0100 Subject: [PATCH 72/77] feat(config): add rename_threshold for git rename detection (#9) --- lua/diffview/config.lua | 1 + lua/diffview/vcs/adapters/git/init.lua | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/lua/diffview/config.lua b/lua/diffview/config.lua index 3fff6dd3..0cfe3759 100644 --- a/lua/diffview/config.lua +++ b/lua/diffview/config.lua @@ -41,6 +41,7 @@ M.defaults = { enhanced_diff_hl = false, git_cmd = { "git" }, hg_cmd = { "hg" }, + rename_threshold = nil, -- Similarity threshold for rename detection (e.g. 40 for 40%). Nil uses git default (50%). use_icons = true, show_help_hints = true, watch_index = true, diff --git a/lua/diffview/vcs/adapters/git/init.lua b/lua/diffview/vcs/adapters/git/init.lua index cef5e989..1035a530 100644 --- a/lua/diffview/vcs/adapters/git/init.lua +++ b/lua/diffview/vcs/adapters/git/init.lua @@ -576,6 +576,10 @@ function GitAdapter:stream_fh_data(state) "log", "--no-show-signature", "--pretty=format:%x00%n" .. GitAdapter.COMMIT_PRETTY_FMT, + (function() + local t = config.get_config().rename_threshold + return t and ("-M" .. t .. "%") or nil + end)(), "--numstat", "--raw", state.prepared_log_opts.flags, @@ -1831,6 +1835,8 @@ GitAdapter.tracked_files = async.wrap(function(self, left, right, args, kind, op ---@type FileEntry[] local conflicts = {} local log_opt = { label = "GitAdapter:tracked_files()" } + local rename_threshold = config.get_config().rename_threshold + local rename_flag = rename_threshold and ("-M" .. rename_threshold .. "%") or nil local namestat_job = Job({ command = self:bin(), @@ -1839,6 +1845,7 @@ GitAdapter.tracked_files = async.wrap(function(self, left, right, args, kind, op "-c", "core.quotePath=false", "diff", + rename_flag, "--ignore-submodules", "--name-status", args @@ -1853,6 +1860,7 @@ GitAdapter.tracked_files = async.wrap(function(self, left, right, args, kind, op "-c", "core.quotePath=false", "diff", + rename_flag, "--ignore-submodules", "--numstat", args From 773e15bbc648c5981bc5c1343331a09a87bddacd Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Fri, 6 Feb 2026 17:10:15 +0100 Subject: [PATCH 73/77] feat(config): allow diff1_plain layout in standard diff views (#8) --- lua/diffview/config.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/diffview/config.lua b/lua/diffview/config.lua index 0cfe3759..5f2f0aa5 100644 --- a/lua/diffview/config.lua +++ b/lua/diffview/config.lua @@ -615,7 +615,7 @@ function M.setup(user_config) do -- Validate layouts local view = M._config.view - local standard_layouts = { "diff2_horizontal", "diff2_vertical", -1 } + local standard_layouts = { "diff1_plain", "diff2_horizontal", "diff2_vertical", -1 } local merge_layuots = { "diff1_plain", "diff3_horizontal", From ecdb020b4ed6b07d729bce268fbc32d8a64c89e0 Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Fri, 6 Feb 2026 17:13:21 +0100 Subject: [PATCH 74/77] feat(file-history): add commit_format config for entry display (#10) --- lua/diffview/config.lua | 3 + .../scene/views/file_history/render.lua | 211 +++++++++++------- 2 files changed, 131 insertions(+), 83 deletions(-) diff --git a/lua/diffview/config.lua b/lua/diffview/config.lua index 5f2f0aa5..42a5dc09 100644 --- a/lua/diffview/config.lua +++ b/lua/diffview/config.lua @@ -110,6 +110,9 @@ M.defaults = { }, file_history_panel = { stat_style = "number", -- "number" (e.g. "5, 3"), "bar" (e.g. "| 8 +++++---"), or "both". + -- Ordered list of components to show for each commit entry. + -- Available: "status", "files", "stats", "hash", "reflog", "ref", "subject", "author", "date" + commit_format = { "status", "files", "stats", "hash", "reflog", "ref", "subject", "author", "date" }, log_options = { ---@type ConfigLogOptions git = { diff --git a/lua/diffview/scene/views/file_history/render.lua b/lua/diffview/scene/views/file_history/render.lua index 7c312ce5..55ce2a75 100644 --- a/lua/diffview/scene/views/file_history/render.lua +++ b/lua/diffview/scene/views/file_history/render.lua @@ -94,101 +94,83 @@ local function render_files(comp, files) perf:lap("files") end ----@param panel FileHistoryPanel ----@param parent CompStruct RenderComponent struct ----@param entries LogEntry[] ----@param updating boolean -local function render_entries(panel, parent, entries, updating) - local c = config.get_config() - local max_num_files = -1 - local max_len_stats = -1 - - for _, entry in ipairs(entries) do - if #entry.files > max_num_files then - max_num_files = #entry.files - end - - if entry.stats then - local adds = tostring(entry.stats.additions) - local dels = tostring(entry.stats.deletions) - local l = 7 - local w = l - (#adds + #dels) - if w < 1 then - l = (#adds + #dels) - ((#adds + #dels) % 2) + 2 - end - max_len_stats = l > max_len_stats and l or max_len_stats - end - end - - for i, entry in ipairs(entries) do - if i > #parent or (updating and i > 128) then - break - end - - local entry_struct = parent[i] - local comp = entry_struct.commit.comp - - if not entry.single_file then - comp:add_text((entry.folded and c.signs.fold_closed or c.signs.fold_open) .. " ", "DiffviewFolderSign") - end - +---@class FHRenderCtx +---@field conf DiffviewConfig +---@field panel FileHistoryPanel +---@field max_num_files integer +---@field max_len_stats integer + +---Individual commit entry formatters, keyed by name. +---@type table +local formatters = { + status = function(comp, entry, _ctx) if entry.status then comp:add_text(hl.get_status_icon(entry.status), hl.get_git_hl(entry.status)) else comp:add_text("-", "DiffviewNonText") end + end, - if not entry.single_file then - local s_num_files = tostring(max_num_files) + files = function(comp, entry, ctx) + if entry.single_file then return end + local s_num_files = tostring(ctx.max_num_files) - if entry.nulled then - comp:add_text(utils.str_center_pad("empty", #s_num_files + 7), "DiffviewFilePanelCounter") - else - comp:add_text( - fmt( - " %s file%s", - utils.str_left_pad(tostring(#entry.files), #s_num_files), - #entry.files > 1 and "s" or " " - ), - "DiffviewFilePanelCounter" - ) - end + if entry.nulled then + comp:add_text(utils.str_center_pad("empty", #s_num_files + 7), "DiffviewFilePanelCounter") + else + comp:add_text( + fmt( + " %s file%s", + utils.str_left_pad(tostring(#entry.files), #s_num_files), + #entry.files > 1 and "s" or " " + ), + "DiffviewFilePanelCounter" + ) end + end, - if max_len_stats ~= -1 then - local adds = { "-", "DiffviewNonText" } - local dels = { "-", "DiffviewNonText" } + stats = function(comp, entry, ctx) + if ctx.max_len_stats == -1 then return end + local adds = { "-", "DiffviewNonText" } + local dels = { "-", "DiffviewNonText" } - if entry.stats and entry.stats.additions then - adds = { tostring(entry.stats.additions), "DiffviewFilePanelInsertions" } - end - - if entry.stats and entry.stats.deletions then - dels = { tostring(entry.stats.deletions), "DiffviewFilePanelDeletions" } - end + if entry.stats and entry.stats.additions then + adds = { tostring(entry.stats.additions), "DiffviewFilePanelInsertions" } + end - comp:add_text(" | ", "DiffviewNonText") - comp:add_text(unpack(adds)) - comp:add_text(string.rep(" ", max_len_stats - (#adds[1] + #dels[1]))) - comp:add_text(unpack(dels)) - comp:add_text(" |", "DiffviewNonText") + if entry.stats and entry.stats.deletions then + dels = { tostring(entry.stats.deletions), "DiffviewFilePanelDeletions" } end + comp:add_text(" | ", "DiffviewNonText") + comp:add_text(unpack(adds)) + comp:add_text(string.rep(" ", ctx.max_len_stats - (#adds[1] + #dels[1]))) + comp:add_text(unpack(dels)) + comp:add_text(" |", "DiffviewNonText") + end, + + hash = function(comp, entry, _ctx) if entry.commit.hash then comp:add_text(" " .. entry.commit.hash:sub(1, 8), "DiffviewHash") end + end, + reflog = function(comp, entry, _ctx) if (entry.commit --[[@as GitCommit ]]).reflog_selector then comp:add_text((" %s"):format((entry.commit --[[@as GitCommit ]]).reflog_selector), "DiffviewReflogSelector") end + end, + ref = function(comp, entry, _ctx) if entry.commit.ref_names then comp:add_text((" (%s)"):format(entry.commit.ref_names), "DiffviewReference") end + end, + subject = function(comp, entry, ctx) local subject = utils.str_trunc( entry.commit.subject, - config.get_config().file_history_panel.commit_subject_max_length + ctx.conf.file_history_panel.commit_subject_max_length ) if subject == "" then @@ -197,25 +179,88 @@ local function render_entries(panel, parent, entries, updating) comp:add_text( " " .. subject, - panel.cur_item[1] == entry and "DiffviewFilePanelSelected" or "DiffviewFilePanelFileName" + ctx.panel.cur_item[1] == entry and "DiffviewFilePanelSelected" or "DiffviewFilePanelFileName" ) + end, + author = function(comp, entry, _ctx) if entry.commit then - local date_format = config.get_config().file_history_panel.date_format - local date - if date_format == "relative" then - date = entry.commit.rel_date - elseif date_format == "iso" then - date = entry.commit.iso_date - else - -- "auto": show relative for recent commits (< 3 months), ISO for older. - date = ( - os.difftime(os.time(), entry.commit.time) > 60 * 60 * 24 * 30 * 3 - and entry.commit.iso_date - or entry.commit.rel_date - ) + comp:add_text(" " .. entry.commit.author, "DiffviewFilePanelPath") + end + end, + + date = function(comp, entry, ctx) + if not entry.commit then return end + local date_format = ctx.conf.file_history_panel.date_format + local date + if date_format == "relative" then + date = entry.commit.rel_date + elseif date_format == "iso" then + date = entry.commit.iso_date + else + -- "auto": show relative for recent commits (< 3 months), ISO for older. + date = ( + os.difftime(os.time(), entry.commit.time) > 60 * 60 * 24 * 30 * 3 + and entry.commit.iso_date + or entry.commit.rel_date + ) + end + comp:add_text(", " .. date, "DiffviewFilePanelPath") + end, +} + +---@param panel FileHistoryPanel +---@param parent CompStruct RenderComponent struct +---@param entries LogEntry[] +---@param updating boolean +local function render_entries(panel, parent, entries, updating) + local c = config.get_config() + local commit_format = c.file_history_panel.commit_format + local max_num_files = -1 + local max_len_stats = -1 + + for _, entry in ipairs(entries) do + if #entry.files > max_num_files then + max_num_files = #entry.files + end + + if entry.stats then + local adds = tostring(entry.stats.additions) + local dels = tostring(entry.stats.deletions) + local l = 7 + local w = l - (#adds + #dels) + if w < 1 then + l = (#adds + #dels) - ((#adds + #dels) % 2) + 2 + end + max_len_stats = l > max_len_stats and l or max_len_stats + end + end + + ---@type FHRenderCtx + local ctx = { + conf = c, + panel = panel, + max_num_files = max_num_files, + max_len_stats = max_len_stats, + } + + for i, entry in ipairs(entries) do + if i > #parent or (updating and i > 128) then + break + end + + local entry_struct = parent[i] + local comp = entry_struct.commit.comp + + if not entry.single_file then + comp:add_text((entry.folded and c.signs.fold_closed or c.signs.fold_open) .. " ", "DiffviewFolderSign") + end + + for _, part in ipairs(commit_format) do + local formatter = formatters[part] + if formatter then + formatter(comp, entry, ctx) end - comp:add_text(" " .. entry.commit.author .. ", " .. date, "DiffviewFilePanelPath") end comp:ln() From 5b03610744ef1c9821cdf40d377093e0811fd8f7 Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Tue, 10 Feb 2026 13:53:22 +0100 Subject: [PATCH 75/77] [breaking change] bump minimum required nvim version to 0.10 (#11) * refactor: bump minimum Neovim version to 0.10 * fix(layouts): implement proper should_null for Diff1 * fix(api): use left_null/right_null in CDiffView * feat(#485): add pushed/unpushed commit colour distinction in file history * feat(#207): add configurable diffopt overrides --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- README.md | 2 +- lua/diffview/api/views/diff/diff_view.lua | 34 ++++++- lua/diffview/async.lua | 2 +- lua/diffview/bootstrap.lua | 9 +- lua/diffview/config.lua | 4 + lua/diffview/debounce.lua | 2 +- lua/diffview/ffi.lua | 24 +---- lua/diffview/health.lua | 17 +--- lua/diffview/hl.lua | 93 +++---------------- lua/diffview/init.lua | 9 +- lua/diffview/job.lua | 2 +- lua/diffview/logger.lua | 2 +- lua/diffview/path.lua | 2 +- lua/diffview/perf.lua | 2 +- lua/diffview/renderer.lua | 4 +- lua/diffview/scene/layouts/diff_1.lua | 16 +++- lua/diffview/scene/view.lua | 63 +++++++++++++ lua/diffview/scene/views/diff/diff_view.lua | 2 +- .../scene/views/file_history/render.lua | 14 ++- lua/diffview/scene/window.lua | 8 +- lua/diffview/utils.lua | 11 ++- lua/diffview/vcs/adapters/git/init.lua | 4 +- lua/diffview/vcs/adapters/hg/init.lua | 2 +- lua/diffview/vcs/file.lua | 21 +---- lua/diffview/vcs/log_entry.lua | 8 ++ 26 files changed, 184 insertions(+), 175 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 79125a1f..7c65644d 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -127,7 +127,7 @@ body: }) vim.opt.termguicolors = true - vim.cmd("colorscheme " .. (vim.fn.has("nvim-0.8") == 1 and "habamax" or "slate")) + vim.cmd("colorscheme habamax") -- ############################################################################ -- ### ADD INIT.LUA SETTINGS THAT ARE _NECESSARY_ FOR REPRODUCING THE ISSUE ### diff --git a/README.md b/README.md index 8afb6612..8be201c4 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ for any git rev. - Git ≥ 2.31.0 (for Git support) - Mercurial ≥ 5.4.0 (for Mercurial support) -- Neovim ≥ 0.7.0 (with LuaJIT) +- Neovim ≥ 0.10.0 (with LuaJIT) - [nvim-web-devicons](https://github.com/nvim-tree/nvim-web-devicons) or [mini.icons](https://github.com/echasnovski/mini.nvim/blob/main/readmes/mini-icons.md) (optional) for file icons ## Installation diff --git a/lua/diffview/api/views/diff/diff_view.lua b/lua/diffview/api/views/diff/diff_view.lua index 6c6b8c2a..db2a0f00 100644 --- a/lua/diffview/api/views/diff/diff_view.lua +++ b/lua/diffview/api/views/diff/diff_view.lua @@ -2,6 +2,7 @@ local async = require("diffview.async") local lazy = require("diffview.lazy") local DiffView = lazy.access("diffview.scene.views.diff.diff_view", "DiffView") ---@type DiffView|LazyModule +local File = lazy.access("diffview.vcs.file", "File") ---@type vcs.File|LazyModule local FileEntry = lazy.access("diffview.scene.file_entry", "FileEntry") ---@type FileEntry|LazyModule local FilePanel = lazy.access("diffview.scene.views.diff.file_panel", "FilePanel") ---@type FilePanel|LazyModule local Rev = lazy.access("diffview.vcs.adapters.git.rev", "GitRev") ---@type GitRev|LazyModule @@ -150,7 +151,32 @@ function CDiffView:create_file_entries(files) }, })) else - table.insert(entries[v.kind], FileEntry.with_layout(CDiffView.get_default_layout(), { + local layout_class = CDiffView.get_default_layout() + + local function create_file(rev, symbol) + local nulled + if symbol == "a" then + nulled = file_data.left_null + elseif symbol == "b" then + nulled = file_data.right_null + end + -- Fall back to layout's should_null if left_null/right_null not specified. + if nulled == nil then + local ok, res = pcall(layout_class.should_null, rev, file_data.status, symbol) + nulled = ok and res or false + end + + return File({ + adapter = self.adapter, + path = symbol == "a" and file_data.oldpath or file_data.path, + kind = v.kind, + get_data = self.get_file_data, + rev = rev, + nulled = nulled, + }) + end + + table.insert(entries[v.kind], FileEntry({ adapter = self.adapter, path = file_data.path, oldpath = file_data.oldpath, @@ -161,8 +187,10 @@ function CDiffView:create_file_entries(files) a = v.left, b = v.right, }, - get_data = self.get_file_data, - --FIXME: left_null, right_null + layout = layout_class({ + a = create_file(v.left, "a"), + b = create_file(v.right, "b"), + }), })) end diff --git a/lua/diffview/async.lua b/lua/diffview/async.lua index 01996750..82557c5c 100644 --- a/lua/diffview/async.lua +++ b/lua/diffview/async.lua @@ -2,7 +2,7 @@ local ffi = require("diffview.ffi") local oop = require("diffview.oop") local fmt = string.format -local uv = vim.loop +local uv = vim.uv local DEFAULT_ERROR = "Unkown error." diff --git a/lua/diffview/bootstrap.lua b/lua/diffview/bootstrap.lua index 303669e1..71d26016 100644 --- a/lua/diffview/bootstrap.lua +++ b/lua/diffview/bootstrap.lua @@ -10,7 +10,7 @@ local config = lazy.require("diffview.config") ---@module "diffview.config" local diffview = lazy.require("diffview") ---@module "diffview" local utils = lazy.require("diffview.utils") ---@module "diffview.utils" -local uv = vim.loop +local uv = vim.uv local function err(msg) msg = msg:gsub("'", "''") @@ -24,11 +24,8 @@ _G.DiffviewGlobal = { bootstrap_ok = false, } -if vim.fn.has("nvim-0.7") ~= 1 then - err( - "Minimum required version is Neovim 0.7.0! Cannot continue." - .. " (See ':h diffview.changelog-137')" - ) +if vim.fn.has("nvim-0.10") ~= 1 then + err("Minimum required version is Neovim 0.10.0! Cannot continue.") return false end diff --git a/lua/diffview/config.lua b/lua/diffview/config.lua index 42a5dc09..1372ea04 100644 --- a/lua/diffview/config.lua +++ b/lua/diffview/config.lua @@ -47,6 +47,10 @@ M.defaults = { watch_index = true, hide_merge_artifacts = false, -- Hide merge artifact files (*.orig, *.BACKUP.*, etc.) auto_close_on_empty = false, -- Automatically close diffview when the last file is staged/resolved. + -- Override diffopt settings while diffview is open. Restored on close. + -- Keys: algorithm, context, indent_heuristic, iwhite, iwhiteall, iwhiteeol, iblank, icase. + -- Example: { algorithm = "histogram", indent_heuristic = true } + diffopt = {}, clean_up_buffers = false, -- Delete file buffers created by diffview on close (only buffers not open before diffview). icons = { folder_closed = "", diff --git a/lua/diffview/debounce.lua b/lua/diffview/debounce.lua index b228b192..a3ed4df8 100644 --- a/lua/diffview/debounce.lua +++ b/lua/diffview/debounce.lua @@ -2,7 +2,7 @@ local async = require("diffview.async") local utils = require("diffview.utils") local await = async.await -local uv = vim.loop +local uv = vim.uv local M = {} diff --git a/lua/diffview/ffi.lua b/lua/diffview/ffi.lua index 96f2f589..4c202808 100644 --- a/lua/diffview/ffi.lua +++ b/lua/diffview/ffi.lua @@ -4,8 +4,6 @@ local C = ffi.C local M = setmetatable({}, { __index = ffi }) -local HAS_NVIM_0_9 = vim.fn.has("nvim-0.9") == 1 - ---Check if the |textlock| is active. ---@return boolean function M.nvim_is_textlocked() @@ -17,12 +15,7 @@ end ---@return boolean function M.nvim_is_locked() if vim.in_fast_event() then return true end - - if HAS_NVIM_0_9 then - return C.textlock > 0 or C.allbuf_lock > 0 or C.expr_map_lock > 0 - end - - return C.textlock > 0 or C.allbuf_lock > 0 or C.ex_normal_lock > 0 + return C.textlock > 0 or C.allbuf_lock > 0 or C.expr_map_lock > 0 end ffi.cdef([[ @@ -33,18 +26,9 @@ ffi.cdef([[ /// Non-zero when no buffer name can be changed, no buffer can be deleted and /// current directory can't be changed. Used for SwapExists et al. extern int allbuf_lock; -]]) -if HAS_NVIM_0_9 then - ffi.cdef([[ - /// Running expr mapping, prevent use of ex_normal() and text changes - extern int expr_map_lock; - ]]) -else - ffi.cdef([[ - /// prevent use of ex_normal() - extern int ex_normal_lock; - ]]) -end + /// Running expr mapping, prevent use of ex_normal() and text changes + extern int expr_map_lock; +]]) return M diff --git a/lua/diffview/health.lua b/lua/diffview/health.lua index 3583c5c1..ae6f34b7 100644 --- a/lua/diffview/health.lua +++ b/lua/diffview/health.lua @@ -1,17 +1,6 @@ -local health = vim.health or require("health") +local health = vim.health local fmt = string.format --- Polyfill deprecated health api -if vim.fn.has("nvim-0.10") ~= 1 then - health = { - start = health.report_start, - ok = health.report_ok, - info = health.report_info, - warn = health.report_warn, - error = health.report_error, - } -end - local M = {} M.plugin_deps = { @@ -39,8 +28,8 @@ local function lualib_available(name) end function M.check() - if vim.fn.has("nvim-0.7") == 0 then - health.error("Diffview.nvim requires Neovim 0.7.0+") + if vim.fn.has("nvim-0.10") == 0 then + health.error("Diffview.nvim requires Neovim 0.10.0+") end -- LuaJIT diff --git a/lua/diffview/hl.lua b/lua/diffview/hl.lua index 767b04c1..24f7d501 100644 --- a/lua/diffview/hl.lua +++ b/lua/diffview/hl.lua @@ -40,10 +40,10 @@ local M = {} ---@field bold boolean ---@field italic boolean ---@field underline boolean ----@field underlineline boolean +---@field underdouble boolean ---@field undercurl boolean ----@field underdash boolean ----@field underdot boolean +---@field underdashed boolean +---@field underdotted boolean ---@field strikethrough boolean ---@field standout boolean ---@field reverse boolean @@ -52,9 +52,6 @@ local M = {} ---@alias hl.HlAttrValue integer|boolean -local HAS_NVIM_0_8 = vim.fn.has("nvim-0.8") == 1 -local HAS_NVIM_0_9 = vim.fn.has("nvim-0.9") == 1 - ---@enum HlAttribute M.HlAttribute = { fg = 1, @@ -66,10 +63,10 @@ M.HlAttribute = { bold = 7, italic = 8, underline = 9, - underlineline = 10, + underdouble = 10, undercurl = 11, - underdash = 12, - underdot = 13, + underdashed = 12, + underdotted = 13, strikethrough = 14, standout = 15, reverse = 16, @@ -80,40 +77,15 @@ local style_attrs = { "bold", "italic", "underline", - "underlineline", + "underdouble", "undercurl", - "underdash", - "underdot", + "underdashed", + "underdotted", "strikethrough", "standout", "reverse", } --- NOTE: Some atrtibutes have been renamed in v0.8.0 -if HAS_NVIM_0_8 then - M.HlAttribute.underdashed = M.HlAttribute.underdash - M.HlAttribute.underdash = nil - - M.HlAttribute.underdotted = M.HlAttribute.underdot - M.HlAttribute.underdot = nil - - M.HlAttribute.underdouble = M.HlAttribute.underlineline - M.HlAttribute.underlineline = nil - - style_attrs = { - "bold", - "italic", - "underline", - "underdouble", - "undercurl", - "underdashed", - "underdotted", - "strikethrough", - "standout", - "reverse", - } -end - utils.add_reverse_lookup(M.HlAttribute) utils.add_reverse_lookup(style_attrs) local hlattr = M.HlAttribute @@ -125,31 +97,16 @@ function M.get_hl(name, no_trans) local hl if no_trans then - if HAS_NVIM_0_9 then - hl = api.nvim_get_hl(0, { name = name, link = true }) - else - hl = api.nvim__get_hl_defs(0)[name] - end + hl = api.nvim_get_hl(0, { name = name, link = true }) else local id = api.nvim_get_hl_id_by_name(name) if id then - if HAS_NVIM_0_9 then - hl = api.nvim_get_hl(0, { id = id, link = false }) - else - hl = api.nvim_get_hl_by_id(id, true) - end + hl = api.nvim_get_hl(0, { id = id, link = false }) end end if hl then - if not HAS_NVIM_0_9 then - -- Handle renames - if hl.foreground then hl.fg = hl.foreground; hl.foreground = nil end - if hl.background then hl.bg = hl.background; hl.background = nil end - if hl.special then hl.sp = hl.special; hl.special = nil end - end - if hl.fg then hl.x_fg = string.format("#%06x", hl.fg) end if hl.bg then hl.x_bg = string.format("#%06x", hl.bg) end if hl.sp then hl.x_sp = string.format("#%06x", hl.sp) end @@ -274,25 +231,7 @@ function M.hi(groups, opt) end end - if not HAS_NVIM_0_9 and def_spec.link then - -- Pre 0.9 `nvim_set_hl()` could not set other attributes in combination - -- with `link`. Furthermore, setting non-link attributes would clear the - -- link, but this does *not* happen if you set the other attributes first - -- (???). However, if the value of `link` is `-1`, the group will be - -- cleared regardless (?????). - local link = def_spec.link - def_spec.link = nil - - if not def_spec.default then - api.nvim_set_hl(0, group, def_spec) - end - - if link ~= -1 then - api.nvim_set_hl(0, group, { link = link, default = def_spec.default }) - end - else - api.nvim_set_hl(0, group, def_spec) - end + api.nvim_set_hl(0, group, def_spec) end end @@ -313,13 +252,7 @@ function M.hi_link(from, to, opt) for _, f in ipairs(from) do if opt.clear then - if not HAS_NVIM_0_9 then - -- Pre 0.9 `nvim_set_hl()` did not clear other attributes when `link` was set. - api.nvim_set_hl(0, f, {}) - end - api.nvim_set_hl(0, f, { default = opt.default, link = to }) - else -- When `clear` is not set; use our `hi()` function such that other -- attributes are not affected. @@ -477,6 +410,8 @@ M.hl_links = { StatusDeleted = "diffRemoved", StatusBroken = "diffRemoved", StatusIgnored = "Comment", + CommitRemoteRef = "Function", + CommitLocalOnly = "WarningMsg", DiffAdd = "DiffAdd", DiffDelete = "DiffDelete", DiffChange = "DiffChange", diff --git a/lua/diffview/init.lua b/lua/diffview/init.lua index 6611b6ea..37188e4b 100644 --- a/lua/diffview/init.lua +++ b/lua/diffview/init.lua @@ -25,15 +25,8 @@ function M.init() -- Fix the strange behavior that "" expands non-files -- as file name in some cases. -- - -- Ref: - -- * sindrets/diffview.nvim#369 - -- * neovim/neovim#23943 local function get_tabnr(state) - if vim.fn.has("nvim-0.9.2") ~= 1 then - return tonumber(state.match) - else - return tonumber(state.file) - end + return tonumber(state.file) end local au = api.nvim_create_autocmd diff --git a/lua/diffview/job.lua b/lua/diffview/job.lua index da26a1d4..5bd62dca 100644 --- a/lua/diffview/job.lua +++ b/lua/diffview/job.lua @@ -8,7 +8,7 @@ local utils = lazy.require("diffview.utils") ---@module "diffview.utils" local await = async.await local fmt = string.format local logger = DiffviewGlobal.logger -local uv = vim.loop +local uv = vim.uv local M = {} diff --git a/lua/diffview/logger.lua b/lua/diffview/logger.lua index f7975d5b..8d3b3ab5 100644 --- a/lua/diffview/logger.lua +++ b/lua/diffview/logger.lua @@ -11,7 +11,7 @@ local api = vim.api local await, pawait = async.await, async.pawait local fmt = string.format local pl = lazy.access(utils, "path") ---@type PathLib -local uv = vim.loop +local uv = vim.uv local M = {} diff --git a/lua/diffview/path.lua b/lua/diffview/path.lua index 101e123c..1d50a92f 100644 --- a/lua/diffview/path.lua +++ b/lua/diffview/path.lua @@ -6,7 +6,7 @@ local utils = lazy.require("diffview.utils") ---@module "diffview.utils" local await = async.await local fmt = string.format -local uv = vim.loop +local uv = vim.uv local M = {} diff --git a/lua/diffview/perf.lua b/lua/diffview/perf.lua index d61d2248..b7af46d6 100644 --- a/lua/diffview/perf.lua +++ b/lua/diffview/perf.lua @@ -1,7 +1,7 @@ local oop = require("diffview.oop") local utils = require("diffview.utils") -local uv = vim.loop +local uv = vim.uv local M = {} diff --git a/lua/diffview/renderer.lua b/lua/diffview/renderer.lua index f5c18d18..7a2e1771 100644 --- a/lua/diffview/renderer.lua +++ b/lua/diffview/renderer.lua @@ -491,7 +491,7 @@ function M.render(bufid, data) return end - local last = vim.loop.hrtime() + local last = vim.uv.hrtime() local was_modifiable = vim.bo[bufid].modifiable vim.bo[bufid].modifiable = true @@ -524,7 +524,7 @@ function M.render(bufid, data) end vim.bo[bufid].modifiable = was_modifiable - M.last_draw_time = (vim.loop.hrtime() - last) / 1000000 + M.last_draw_time = (vim.uv.hrtime() - last) / 1000000 end M.RenderComponent = RenderComponent diff --git a/lua/diffview/scene/layouts/diff_1.lua b/lua/diffview/scene/layouts/diff_1.lua index 6a892b44..1ffe02f5 100644 --- a/lua/diffview/scene/layouts/diff_1.lua +++ b/lua/diffview/scene/layouts/diff_1.lua @@ -156,12 +156,26 @@ function Diff1:to_diff4(layout) }) end ----FIXME ---@override ---@param rev Rev ---@param status string Git status symbol. ---@param sym Diff1.WindowSymbol function Diff1.should_null(rev, status, sym) + assert(sym == "b") + + if rev.type == RevType.LOCAL then + -- Deleted files have no LOCAL content. + return status == "D" + + elseif rev.type == RevType.COMMIT then + -- Deleted files have no content on the newer side. + return status == "D" + + elseif rev.type == RevType.STAGE then + -- Deleted files have no staged content. + return status == "D" + end + return false end diff --git a/lua/diffview/scene/view.lua b/lua/diffview/scene/view.lua index c4f8b296..f0ad1f12 100644 --- a/lua/diffview/scene/view.lua +++ b/lua/diffview/scene/view.lua @@ -16,6 +16,59 @@ local utils = lazy.require("diffview.utils") ---@module "diffview.utils" local api = vim.api local M = {} +---@type string[]? Saved diffopt value before diffview applied overrides. +local saved_diffopt + +-- Boolean diffopt flags that can be toggled. +local diffopt_bool_flags = { + "indent-heuristic", "iwhite", "iwhiteall", "iwhiteeol", "iblank", "icase", +} + +---Apply configured diffopt overrides, saving the original value first. +local function apply_diffopt() + local conf = config.get_config().diffopt + if not conf or vim.tbl_isempty(conf) then return end + + if not saved_diffopt then + saved_diffopt = vim.opt.diffopt:get() + end + + if conf.algorithm then + -- Remove any existing algorithm:* entry and add the new one. + vim.opt.diffopt:remove( + vim.tbl_filter(function(v) return v:match("^algorithm:") end, vim.opt.diffopt:get()) + ) + vim.opt.diffopt:append({ "algorithm:" .. conf.algorithm }) + end + + if conf.context ~= nil then + vim.opt.diffopt:remove( + vim.tbl_filter(function(v) return v:match("^context:") end, vim.opt.diffopt:get()) + ) + vim.opt.diffopt:append({ "context:" .. conf.context }) + end + + for _, flag in ipairs(diffopt_bool_flags) do + -- Convert config key (underscore-separated) to diffopt flag (hyphenated). + local key = flag:gsub("-", "_") + if conf[key] ~= nil then + if conf[key] then + vim.opt.diffopt:append({ flag }) + else + vim.opt.diffopt:remove({ flag }) + end + end + end +end + +---Restore the original diffopt value. +local function restore_diffopt() + if saved_diffopt then + vim.opt.diffopt = saved_diffopt + saved_diffopt = nil + end +end + ---@enum LayoutMode local LayoutMode = oop.enum({ HORIZONTAL = 1, @@ -59,6 +112,14 @@ function View:init(opt) end wrap_event("view_closed") + + -- Apply/restore diffopt overrides on tab enter/leave. + self.emitter:on("tab_enter", function() + apply_diffopt() + end) + self.emitter:on("tab_leave", function() + restore_diffopt() + end) end function View:open() @@ -66,6 +127,7 @@ function View:open() self.tabpage = api.nvim_get_current_tabpage() self:init_layout() self:post_open() + apply_diffopt() DiffviewGlobal.emitter:emit("view_opened", self) DiffviewGlobal.emitter:emit("view_enter", self) end @@ -75,6 +137,7 @@ function View:close() if self.tabpage and api.nvim_tabpage_is_valid(self.tabpage) then DiffviewGlobal.emitter:emit("view_leave", self) + restore_diffopt() if #api.nvim_list_tabpages() == 1 then vim.cmd("tabnew") diff --git a/lua/diffview/scene/views/diff/diff_view.lua b/lua/diffview/scene/views/diff/diff_view.lua index de3d42e1..50e2bf69 100644 --- a/lua/diffview/scene/views/diff/diff_view.lua +++ b/lua/diffview/scene/views/diff/diff_view.lua @@ -90,7 +90,7 @@ function DiffView:post_open() }) if config.get_config().watch_index and self.adapter:instanceof(GitAdapter.__get()) then - self.watcher = vim.loop.new_fs_poll() + self.watcher = vim.uv.new_fs_poll() self.watcher:start( self.adapter.ctx.dir .. "/index", 1000, diff --git a/lua/diffview/scene/views/file_history/render.lua b/lua/diffview/scene/views/file_history/render.lua index 55ce2a75..4904b418 100644 --- a/lua/diffview/scene/views/file_history/render.lua +++ b/lua/diffview/scene/views/file_history/render.lua @@ -177,10 +177,16 @@ local formatters = { subject = "[empty message]" end - comp:add_text( - " " .. subject, - ctx.panel.cur_item[1] == entry and "DiffviewFilePanelSelected" or "DiffviewFilePanelFileName" - ) + local subject_hl + if ctx.panel.cur_item[1] == entry then + subject_hl = "DiffviewFilePanelSelected" + elseif entry.has_remote_ref then + subject_hl = "DiffviewCommitRemoteRef" + else + subject_hl = "DiffviewCommitLocalOnly" + end + + comp:add_text(" " .. subject, subject_hl) end, author = function(comp, entry, _ctx) diff --git a/lua/diffview/scene/window.lua b/lua/diffview/scene/window.lua index dc9604aa..2ae3a7f7 100644 --- a/lua/diffview/scene/window.lua +++ b/lua/diffview/scene/window.lua @@ -17,8 +17,6 @@ local logger = DiffviewGlobal.logger local M = {} -local HAS_NVIM_0_8 = vim.fn.has("nvim-0.8") == 1 - ---@class Window : diffview.Object ---@field id integer ---@field file vcs.File @@ -203,7 +201,7 @@ end) ---@return boolean function Window:show_winbar_info() - if self.file and self.file.winbar and HAS_NVIM_0_8 then + if self.file and self.file.winbar then local conf = config.get_config() local view = lib.get_current_view() @@ -290,9 +288,7 @@ function Window:_restore_winopts() local winid = utils.temp_win(self.file.bufnr) utils.set_local(winid, Window.winopt_store[self.file.bufnr]) - if HAS_NVIM_0_8 then - vim.wo[winid].winbar = nil - end + vim.wo[winid].winbar = nil api.nvim_win_close(winid, true) end) diff --git a/lua/diffview/utils.lua b/lua/diffview/utils.lua index 7e0bf180..43729ee3 100644 --- a/lua/diffview/utils.lua +++ b/lua/diffview/utils.lua @@ -20,7 +20,7 @@ end) ---@return number # Current time (ms) function M.now() - return vim.loop.hrtime() / 1000000 + return vim.uv.hrtime() / 1000000 end ---@param msg string|string[] @@ -337,6 +337,10 @@ end ---correctly with Option:set(), Option:append(), Option:prepend(), and ---Option:remove() (seemingly for legacy reasons). ---WARN: This map is incomplete! +local function get_option_info(name) + return api.nvim_get_option_info2(name, {}) +end + local list_like_options = { winhighlight = true, listchars = true, @@ -365,7 +369,7 @@ function M.set_local(winids, option_map, opt) api.nvim_win_call(id, function() for option, value in pairs(option_map) do local o = opt - local fullname = api.nvim_get_option_info(option).name + local fullname = get_option_info(option).name local is_list_like = list_like_options[fullname] local cur_value = vim.o[fullname] @@ -1334,8 +1338,7 @@ function M.merge_sort(t, comparator) split_merge(t, 1, #t, comparator) end ---- @diagnostic disable-next-line: deprecated -M.islist = vim.fn.has("nvim-0.10") == 1 and vim.islist or vim.tbl_islist +M.islist = vim.islist --- @param t table --- @return any[] diff --git a/lua/diffview/vcs/adapters/git/init.lua b/lua/diffview/vcs/adapters/git/init.lua index 1035a530..d4441a25 100644 --- a/lua/diffview/vcs/adapters/git/init.lua +++ b/lua/diffview/vcs/adapters/git/init.lua @@ -23,7 +23,7 @@ local await, pawait = async.await, async.pawait local fmt = string.format local logger = DiffviewGlobal.logger local pl = lazy.access(utils, "path") ---@type PathLib -local uv = vim.loop +local uv = vim.uv local M = {} @@ -247,7 +247,7 @@ function GitAdapter:init(opt) opt = opt or {} self:super(opt) - local cwd = opt.cpath or vim.loop.cwd() + local cwd = opt.cpath or uv.cwd() self.ctx = { toplevel = opt.toplevel, diff --git a/lua/diffview/vcs/adapters/hg/init.lua b/lua/diffview/vcs/adapters/hg/init.lua index 542abb72..964037b5 100644 --- a/lua/diffview/vcs/adapters/hg/init.lua +++ b/lua/diffview/vcs/adapters/hg/init.lua @@ -22,7 +22,7 @@ local await, pawait = async.await, async.pawait local fmt = string.format local logger = DiffviewGlobal.logger local pl = lazy.access(utils, "path") ---@type PathLib -local uv = vim.loop +local uv = vim.uv local M = {} diff --git a/lua/diffview/vcs/file.lua b/lua/diffview/vcs/file.lua index 88861fe7..276dd6a7 100644 --- a/lua/diffview/vcs/file.lua +++ b/lua/diffview/vcs/file.lua @@ -15,7 +15,6 @@ local pl = lazy.access(utils, "path") ---@type PathLib local api = vim.api local M = {} -local HAS_NVIM_0_10 = vim.fn.has("nvim-0.10") == 1 ---@alias git.FileDataProducer fun(kind: vcs.FileKind, path: string, pos: "left"|"right"): string[] @@ -414,19 +413,14 @@ function File:attach_buffer(force, opt) -- Diagnostics if state.disable_diagnostics then - if HAS_NVIM_0_10 then - vim.diagnostic.enable(false, { bufnr = self.bufnr }) - else - ---@diagnostic disable-next-line: deprecated - vim.diagnostic.disable(self.bufnr) - end + vim.diagnostic.enable(false, { bufnr = self.bufnr }) end -- Inlay hints: Always disable for non-LOCAL buffers to prevent -- "Invalid 'col': out of range" errors. Inlay hint positions are -- computed for the current file version, which may differ from the -- revision shown in the diff buffer. - if HAS_NVIM_0_10 and self.rev and self.rev.type ~= RevType.LOCAL then + if self.rev and self.rev.type ~= RevType.LOCAL then pcall(vim.lsp.inlay_hint.enable, false, { bufnr = self.bufnr }) end @@ -464,16 +458,11 @@ function File:detach_buffer() -- Diagnostics if state.disable_diagnostics then - if HAS_NVIM_0_10 then - vim.diagnostic.enable(true, { bufnr = self.bufnr }) - else - ---@diagnostic disable-next-line: param-type-mismatch - vim.diagnostic.enable(self.bufnr) - end + vim.diagnostic.enable(true, { bufnr = self.bufnr }) end - -- Re-enable inlay hints for non-LOCAL buffers (if they were disabled) - if HAS_NVIM_0_10 and self.rev and self.rev.type ~= RevType.LOCAL then + -- Re-enable inlay hints for non-LOCAL buffers (if they were disabled). + if self.rev and self.rev.type ~= RevType.LOCAL then pcall(vim.lsp.inlay_hint.enable, true, { bufnr = self.bufnr }) end diff --git a/lua/diffview/vcs/log_entry.lua b/lua/diffview/vcs/log_entry.lua index a012425f..c72c6255 100644 --- a/lua/diffview/vcs/log_entry.lua +++ b/lua/diffview/vcs/log_entry.lua @@ -16,6 +16,7 @@ local M = {} ---@field single_file boolean ---@field folded boolean ---@field nulled boolean +---@field has_remote_ref boolean Whether this commit has a remote ref decoration (e.g. a remote branch tip). local LogEntry = oop.create_class("LogEntry") function LogEntry:init(opt) @@ -25,6 +26,13 @@ function LogEntry:init(opt) self.folded = true self.single_file = opt.single_file self.nulled = utils.sate(opt.nulled, false) + -- NOTE: This only detects commits at remote branch/tag tips via %D + -- decorations, not full reachability from upstream. + self.has_remote_ref = opt.commit and opt.commit.ref_names + and (opt.commit.ref_names:find("origin/") + or opt.commit.ref_names:find("upstream/") + or opt.commit.ref_names:find("remotes/")) + and true or false self:update_status() self:update_stats() end From 03c74bb36a42ec4e47f6feb73cdf42bbe6d0cc59 Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Wed, 11 Feb 2026 08:56:14 +0100 Subject: [PATCH 76/77] docs(readme): add diffchar.vim recommendation, reorganize plugin sections (#12) Restructure Plugin Compatibility into Companion Plugins with Recommended and Known Issues subsections. Add links to all external plugins at first mention. Add character-level highlighting tip pointing to diffchar.vim. --- README.md | 113 ++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 75 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 8be201c4..20a9f458 100644 --- a/README.md +++ b/README.md @@ -568,7 +568,7 @@ end, { desc = 'Diff against main/master' }) - **Compare against merge-base (PR-style diff):** - `DiffviewOpen origin/main...HEAD --merge-base` - Shows only changes introduced since branching. -- **Use with Neogit:** +- **Use with [Neogit](https://github.com/NeogitOrg/neogit):** - Configure Neogit with `integrations = { diffview = true }` for seamless integration. - **Trace line evolution:** @@ -593,6 +593,11 @@ end, { desc = 'Diff against main/master' }) diagnostics. - Inlay hints are automatically disabled for non-working-tree buffers to prevent position mismatch errors. +- **VSCode-style character-level highlighting:** + - Pair diffview with + [diffchar.vim](https://github.com/rickhowe/diffchar.vim) for precise + character/word-level diff highlights. See + [Companion Plugins > Recommended](#recommended) for setup details. - **Customizing default keymaps to avoid conflicts:** - The default keymaps (`e`, `b`, `c*`) may conflict with your configuration. Override them in your setup: @@ -610,7 +615,73 @@ end, { desc = 'Diff against main/master' }) }) ``` -## Plugin Compatibility +## Companion Plugins + +### Recommended + +- **[diffchar.vim](https://github.com/rickhowe/diffchar.vim) (VSCode-style character-level highlighting):** + - diffchar.vim enhances diff mode with precise character and word-level + highlighting. It automatically activates in diff mode, adding a second layer + of highlights on top of Neovim's built-in line-level `DiffChange` + backgrounds. This gives VSCode-style dual-layer highlighting: light + backgrounds for changed lines plus fine-grained highlights for the exact + characters that differ. + - diffchar.vim works with diffview out of the box. Install the plugin and open + a diff — no additional configuration is needed. You may want to disable + diffchar's default keymaps (`g`, `p`) if they conflict with + your mappings: + ```lua + { + 'rickhowe/diffchar.vim', + config = function() + -- Disable diffchar default keymaps. + -- See: https://github.com/rickhowe/diffchar.vim/issues/21 + vim.cmd([[ + nmap g + nmap p + ]]) + end, + } + ``` + - diffchar supports multiple diff granularities via `g:DiffUnit`: `'Char'` + (character-level), `'Word1'` (words separated by non-word characters), + `'Word2'` (whitespace-delimited words), and custom delimiter patterns. It + also offers multi-colour matching via `g:DiffColors` to visually correlate + corresponding changed units across windows. + +- **[Telescope](https://github.com/nvim-telescope/telescope.nvim) integration:** + - You can use Telescope to select branches or commits for diffview: + ```lua + -- Diff against a branch selected via Telescope + vim.keymap.set('n', 'db', function() + require('telescope.builtin').git_branches({ + attach_mappings = function(_, map) + map('i', '', function(prompt_bufnr) + local selection = require('telescope.actions.state').get_selected_entry() + require('telescope.actions').close(prompt_bufnr) + vim.cmd('DiffviewOpen ' .. selection.value) + end) + return true + end, + }) + end, { desc = 'Diffview branch' }) + + -- File history for a commit selected via Telescope + vim.keymap.set('n', 'dC', function() + require('telescope.builtin').git_commits({ + attach_mappings = function(_, map) + map('i', '', function(prompt_bufnr) + local selection = require('telescope.actions.state').get_selected_entry() + require('telescope.actions').close(prompt_bufnr) + vim.cmd('DiffviewOpen ' .. selection.value .. '^!') + end) + return true + end, + }) + end, { desc = 'Diffview commit' }) + ``` + +### Known Issues Some plugins may conflict with diffview's window layout or keymaps. Here are known issues and workarounds: @@ -626,7 +697,7 @@ known issues and workarounds: } ``` -- **nvim-treesitter-context:** +- **[nvim-treesitter-context](https://github.com/nvim-treesitter/nvim-treesitter-context):** - Context plugins that show code context at the top of windows can cause visual scrollbind misalignment. - **Workaround:** Configure the plugin to disable itself for diffview buffers @@ -639,7 +710,7 @@ known issues and workarounds: }) ``` -- **vim-markdown (preservim/vim-markdown):** +- **[vim-markdown](https://github.com/preservim/vim-markdown) (preservim/vim-markdown):** - vim-markdown creates folds for markdown sections. Older versions of diffview set `foldlevel=0` which collapsed these sections, hiding diff content. This has been fixed by setting `foldlevel=99` by default. @@ -656,38 +727,4 @@ known issues and workarounds: }) ``` -## Telescope Integration - -You can use Telescope to select branches or commits for diffview: - -```lua --- Diff against a branch selected via Telescope -vim.keymap.set('n', 'db', function() - require('telescope.builtin').git_branches({ - attach_mappings = function(_, map) - map('i', '', function(prompt_bufnr) - local selection = require('telescope.actions.state').get_selected_entry() - require('telescope.actions').close(prompt_bufnr) - vim.cmd('DiffviewOpen ' .. selection.value) - end) - return true - end, - }) -end, { desc = 'Diffview branch' }) - --- File history for a commit selected via Telescope -vim.keymap.set('n', 'dC', function() - require('telescope.builtin').git_commits({ - attach_mappings = function(_, map) - map('i', '', function(prompt_bufnr) - local selection = require('telescope.actions.state').get_selected_entry() - require('telescope.actions').close(prompt_bufnr) - vim.cmd('DiffviewOpen ' .. selection.value .. '^!') - end) - return true - end, - }) -end, { desc = 'Diffview commit' }) -``` - From a8226b76f8492a41bf7bc9688131ec8b6281a42c Mon Sep 17 00:00:00 2001 From: David Yonge-Mallo Date: Thu, 12 Feb 2026 10:15:24 +0100 Subject: [PATCH 77/77] docs(readme): note diffchar.vim flag for character-level diff highlighting for deletions (#13) --- README.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 20a9f458..78233a55 100644 --- a/README.md +++ b/README.md @@ -627,13 +627,17 @@ end, { desc = 'Diff against main/master' }) backgrounds for changed lines plus fine-grained highlights for the exact characters that differ. - diffchar.vim works with diffview out of the box. Install the plugin and open - a diff — no additional configuration is needed. You may want to disable - diffchar's default keymaps (`g`, `p`) if they conflict with - your mappings: + a diff — no additional configuration is needed. You may want to enable + visual indicators next to deleted characters to get VSCode-style + character-level diffs, or disable diffchar's default keymaps (`g`, + `p`) if they conflict with your mappings: ```lua { 'rickhowe/diffchar.vim', config = function() + -- Use bold/underline on adjacent chars instead of virtual blank columns. + vim.g.DiffDelPosVisible = 1 + -- Disable diffchar default keymaps. -- See: https://github.com/rickhowe/diffchar.vim/issues/21 vim.cmd([[