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 9298e1f5..0707129d 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,8 @@ 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 +- 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 @@ -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,170 @@ 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](https://github.com/NeogitOrg/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` +- **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. +- **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 +- **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. +- **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: + ```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 }, + }, + }, + }) + ``` + +## 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 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([[ + 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: + +- **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](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 + 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](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. + - 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, + }, + }) + ``` diff --git a/doc/diffview.txt b/doc/diffview.txt index 3a564c12..900b40d7 100644 --- a/doc/diffview.txt +++ b/doc/diffview.txt @@ -18,12 +18,13 @@ for any git rev. USAGE *diffview-usage* -Quick-start: `:DiffviewOpen` 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 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 +371,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/doc/diffview_defaults.txt b/doc/diffview_defaults.txt index 1ff1f7db..0287efc1 100644 --- a/doc/diffview_defaults.txt +++ b/doc/diffview_defaults.txt @@ -7,13 +7,28 @@ 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. + 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 = "", }, + 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 = "", fold_open = "", @@ -60,6 +75,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| @@ -81,6 +97,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/actions.lua b/lua/diffview/actions.lua index 6b89e1fa..636b5502 100644 --- a/lua/diffview/actions.lua +++ b/lua/diffview/actions.lua @@ -6,7 +6,9 @@ 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" local utils = lazy.require("diffview.utils") ---@module "diffview.utils" local vcs_utils = lazy.require("diffview.vcs.utils") ---@module "diffview.vcs.utils" @@ -164,6 +166,89 @@ 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 + +---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 + +---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 @@ -489,19 +574,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,6 +661,63 @@ function M.cycle_layout() end end +---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 } @@ -620,16 +784,20 @@ local action_names = { "close_all_folds", "close_fold", "copy_hash", + "diff_against_head", "focus_entry", "focus_files", "listing_style", "next_entry", + "next_entry_in_commit", "open_all_folds", + "open_commit_in_browser", "open_commit_log", "open_fold", "open_in_diffview", "options", "prev_entry", + "prev_entry_in_commit", "refresh_files", "restore_entry", "select_entry", @@ -644,6 +812,7 @@ local action_names = { "toggle_flatten_dirs", "toggle_fold", "toggle_stage_entry", + "toggle_untracked", "unstage_all", } 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 f1f6d0f1..1372ea04 100644 --- a/lua/diffview/config.lua +++ b/lua/diffview/config.lua @@ -41,13 +41,34 @@ 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, + 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 = "", folder_open = "", }, + status_icons = { + ["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 = "", fold_open = "", @@ -69,9 +90,15 @@ 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", + 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" @@ -81,8 +108,15 @@ M.defaults = { width = 35, 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. + 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". + -- 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 = { @@ -105,6 +139,8 @@ M.defaults = { height = 16, 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 = { @@ -129,9 +165,11 @@ 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", "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" } }, @@ -197,6 +235,8 @@ 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", "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" } }, @@ -215,6 +255,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" } }, @@ -241,6 +282,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" } }, @@ -255,6 +297,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 @@ -576,7 +622,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", diff --git a/lua/diffview/debounce.lua b/lua/diffview/debounce.lua index 74fc51f5..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 = {} @@ -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 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 9c67c42b..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 = { @@ -19,6 +8,10 @@ M.plugin_deps = { name = "nvim-web-devicons", optional = true, }, + { + name = "mini.icons", + optional = true, + }, } ---@param cmd string|string[] @@ -35,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 693dd358..24f7d501 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 = {} @@ -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. @@ -349,14 +282,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 +307,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 @@ -403,6 +344,13 @@ function M.get_git_hl(status) return git_status_hl_map[status] end +---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.get_config().status_icons[status] or status +end + function M.get_colors() return { white = M.get_fg("Normal") or "White", @@ -423,7 +371,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" }, @@ -462,6 +410,8 @@ M.hl_links = { StatusDeleted = "diffRemoved", StatusBroken = "diffRemoved", StatusIgnored = "Comment", + CommitRemoteRef = "Function", + CommitLocalOnly = "WarningMsg", DiffAdd = "DiffAdd", DiffDelete = "DiffDelete", DiffChange = "DiffChange", @@ -482,6 +432,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) diff --git a/lua/diffview/init.lua b/lua/diffview/init.lua index 4063f71e..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 @@ -128,7 +121,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,6 +150,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] @@ -203,21 +206,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 @@ -229,13 +232,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/job.lua b/lua/diffview/job.lua index baa20cd4..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 = {} @@ -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) diff --git a/lua/diffview/lib.lua b/lua/diffview/lib.lua index ae8ea605..13e43a2d 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 @@ -16,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 })) @@ -40,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/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 3fc4b20f..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 = {} @@ -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/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 6519e81e..7a2e1771 100644 --- a/lua/diffview/renderer.lua +++ b/lua/diffview/renderer.lua @@ -491,9 +491,9 @@ function M.render(bufid, data) return 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 last = vim.uv.hrtime() + local was_modifiable = vim.bo[bufid].modifiable + vim.bo[bufid].modifiable = true local lines, hl_data local line_idx = 0 @@ -523,8 +523,8 @@ function M.render(bufid, data) end end - api.nvim_buf_set_option(bufid, "modifiable", was_modifiable) - M.last_draw_time = (vim.loop.hrtime() - last) / 1000000 + vim.bo[bufid].modifiable = was_modifiable + M.last_draw_time = (vim.uv.hrtime() - last) / 1000000 end M.RenderComponent = RenderComponent diff --git a/lua/diffview/scene/layout.lua b/lua/diffview/scene/layout.lua index cba2b129..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) @@ -305,7 +308,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 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 84d99413..50e2bf69 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 @@ -29,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 @@ -58,6 +60,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) @@ -82,12 +85,12 @@ 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"), }) 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, @@ -182,6 +185,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 @@ -325,10 +350,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 @@ -362,8 +393,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 @@ -394,55 +425,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 @@ -480,8 +531,27 @@ 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) + -- 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/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/listeners.lua b/lua/diffview/scene/views/diff/listeners.lua index 9e6d63b1..d79c4b7a 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" @@ -19,6 +21,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 +36,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 @@ -48,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() @@ -184,6 +205,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) @@ -204,6 +232,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 @@ -231,17 +266,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 + + 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) - 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 + end - if bufid and vim.bo[bufid].modified then - utils.err("The file is open with unsaved changes! Aborting file restoration.") - return + 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() @@ -260,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/diff/render.lua b/lua/diffview/scene/views/diff/render.lua index 3790a555..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,20 +91,25 @@ 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)) 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") + 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 @@ -147,12 +152,25 @@ 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") 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") @@ -164,26 +182,38 @@ 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") 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") + 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 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..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 @@ -40,7 +42,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"), }) @@ -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/scene/views/file_history/listeners.lua b/lua/diffview/scene/views/file_history/listeners.lua index c10a4ff3..a456ba7a 100644 --- a/lua/diffview/scene/views/file_history/listeners.lua +++ b/lua/diffview/scene/views/file_history/listeners.lua @@ -3,10 +3,12 @@ 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" +local api = vim.api local await = async.await ---@param view FileHistoryView @@ -17,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 @@ -53,6 +71,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, @@ -91,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, @@ -208,11 +268,39 @@ 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 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/scene/views/file_history/render.lua b/lua/diffview/scene/views/file_history/render.lua index 24b13970..4904b418 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") @@ -23,7 +69,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 @@ -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 @@ -50,12 +94,134 @@ local function render_files(comp, files) perf:lap("files") 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, + + 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 + end, + + 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 + + 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, + ctx.conf.file_history_panel.commit_subject_max_length + ) + + if subject == "" then + subject = "[empty message]" + end + + 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) + if entry.commit then + 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 @@ -76,6 +242,14 @@ local function render_entries(panel, parent, entries, updating) 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 @@ -85,82 +259,14 @@ 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") - end - - if entry.status then - comp:add_text(entry.status, hl.get_git_hl(entry.status)) - else - comp:add_text("-", "DiffviewNonText") + comp:add_text((entry.folded and c.signs.fold_closed or c.signs.fold_open) .. " ", "DiffviewFolderSign") end - if not entry.single_file then - local s_num_files = tostring(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 - end - - if max_len_stats ~= -1 then - local adds = { "-", "DiffviewNonText" } - local dels = { "-", "DiffviewNonText" } - - if entry.stats and entry.stats.additions then - adds = { tostring(entry.stats.additions), "DiffviewFilePanelInsertions" } + for _, part in ipairs(commit_format) do + local formatter = formatters[part] + if formatter then + formatter(comp, entry, ctx) end - - 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(" ", max_len_stats - (#adds[1] + #dels[1]))) - comp:add_text(unpack(dels)) - comp:add_text(" |", "DiffviewNonText") - end - - if entry.commit.hash then - comp:add_text(" " .. entry.commit.hash:sub(1, 8), "DiffviewHash") - end - - if (entry.commit --[[@as GitCommit ]]).reflog_selector then - comp:add_text((" %s"):format((entry.commit --[[@as GitCommit ]]).reflog_selector), "DiffviewReflogSelector") - end - - if entry.commit.ref_names then - comp:add_text((" (%s)"):format(entry.commit.ref_names), "DiffviewReference") - end - - local subject = utils.str_trunc(entry.commit.subject, 72) - - if subject == "" then - subject = "[empty message]" - end - - comp:add_text( - " " .. subject, - panel.cur_item[1] == entry and "DiffviewFilePanelSelected" or "DiffviewFilePanelFileName" - ) - - 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 - ) - comp:add_text(" " .. entry.commit.author .. ", " .. date, "DiffviewFilePanelPath") end comp:ln() 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 diff --git a/lua/diffview/scene/window.lua b/lua/diffview/scene/window.lua index 3081b098..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 @@ -146,6 +144,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) @@ -192,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() @@ -222,8 +231,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 @@ -241,15 +258,24 @@ 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] = {} - api.nvim_win_call(self.id, function() - for option, _ in pairs(self.file.winopts) do + for option, _ in pairs(self.file.winopts) do + 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 end function Window:_restore_winopts() @@ -262,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) @@ -312,17 +336,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 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) diff --git a/lua/diffview/ui/models/file_tree/file_tree.lua b/lua/diffview/ui/models/file_tree/file_tree.lua index 7825e801..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 @@ -150,6 +151,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/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() diff --git a/lua/diffview/ui/panel.lua b/lua/diffview/ui/panel.lua index ccd640f7..f08721ba 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 @@ -353,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/ui/panels/commit_log_panel.lua b/lua/diffview/ui/panels/commit_log_panel.lua index 1160a261..06ffd4b0 100644 --- a/lua/diffview/ui/panels/commit_log_panel.lua +++ b/lua/diffview/ui/panels/commit_log_panel.lua @@ -49,9 +49,10 @@ end ---@field args string[] ---@field name string +---@param parent StandardView ---@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 +66,25 @@ function CommitLogPanel:init(adapter, opt) vim.bo[self.bufid].bufhidden = "wipe" 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 } + + 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 end ---@param self CommitLogPanel 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("#")) diff --git a/lua/diffview/utils.lua b/lua/diffview/utils.lua index 4cbddcc8..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] @@ -840,6 +844,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 @@ -1321,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 3a137bc9..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, @@ -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", "--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, "--" }, 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], @@ -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] }, 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 @@ -570,7 +574,12 @@ function GitAdapter:stream_fh_data(state) "-c", "core.quotePath=false", "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, @@ -649,6 +658,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 +730,7 @@ function GitAdapter:file_history_dry_run(log_opt) else cmd = utils.vec_join( "log", + "--no-show-signature", "--pretty=format:%H", "--name-status", options, @@ -1033,6 +1044,7 @@ 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", @@ -1268,6 +1280,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 @@ -1287,6 +1300,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} @@ -1308,6 +1322,113 @@ 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 + +---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? @@ -1445,10 +1566,25 @@ 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 + 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 @@ -1670,8 +1806,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 + -- 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 @@ -1699,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(), @@ -1707,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 @@ -1721,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 @@ -2120,12 +2260,14 @@ 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) 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 94d25942..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 = {} @@ -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} @@ -861,6 +862,28 @@ 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 + +---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), diff --git a/lua/diffview/vcs/file.lua b/lua/diffview/vcs/file.lua index 53e04ac5..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[] @@ -52,6 +51,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", @@ -87,13 +90,19 @@ 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, + -- 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" }, }, } @@ -167,6 +176,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. @@ -280,7 +292,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 @@ -292,6 +304,12 @@ File.create_buffer = async.wrap(function(self, callback) vim.cmd("filetype detect") end) + -- Disable context plugins that interfere with scrollbind alignment. + -- 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 self:post_buf_created() @@ -329,8 +347,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) @@ -348,21 +399,29 @@ 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 -- 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 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 @@ -375,7 +434,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] } @@ -387,14 +446,24 @@ 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 - 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 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 @@ -432,7 +501,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" @@ -469,6 +538,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 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() 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 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) 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