Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,10 @@ and committing workflows.

- **Multi-file Selection** — Select multiple files in the file panel
(`w` to toggle, `C` to clear) for batch stage / unstage / restore.
Selections persist across Neovim restarts.
Press `H` to hide or reveal reviewed (selected) files and focus on the
remaining changes. These mappings can be overridden through
`keymaps.file_panel`. When `persist_selections` is enabled, selections and
the hide-reviewed state survive Neovim restarts.

- **Pin Local in File History** — Run `:DiffviewFileHistory --pin-local`
to keep the working tree on one side while cycling commits on the
Expand Down
20 changes: 16 additions & 4 deletions doc/diffview.txt
Original file line number Diff line number Diff line change
Expand Up @@ -822,13 +822,16 @@ signs *diffview-config-signs*
persist_selections *diffview-config-persist_selections*
Type: `table`, Default: `{ enabled = false, path = nil }`

Persist file selections to disk so they survive Neovim restarts.
Selections are scoped per repository and revision, debounced,
and auto-pruned when the file list changes.
Persist file selections and the hide-reviewed state to disk so they
survive Neovim restarts. Both are scoped per repository and revision.
Selection writes are debounced, and stale selections are auto-pruned
when the file list changes. Toggling the hide-reviewed state is saved
immediately.

Fields: ~
{enabled} (boolean)
Enable or disable selection persistence.
Enable or disable selection and hide-reviewed state
persistence.

{path} (string|nil)
Path to the JSON storage file. When `nil`, defaults to
Expand Down Expand Up @@ -2073,6 +2076,12 @@ toggle_fold *diffview-actions-toggle_fold*

Collapse / expand the fold of the subject.

toggle_hide_selected *diffview-actions-toggle_hide_selected*
Contexts: `file_panel`

Toggle whether reviewed (selected) files are hidden in the file
panel. The selection state is preserved when files are hidden.

toggle_select_entry *diffview-actions-toggle_select_entry*
Contexts: `file_panel`

Expand Down Expand Up @@ -2442,6 +2451,9 @@ w Toggle file selection for multi-file operations.
*diffview-maps-clear_select_entries*
C Clear all file selections.

*diffview-maps-toggle_hide_selected*
H Toggle hiding reviewed (selected) files.

*diffview-maps-toggle_stage_entry*
- Stage/unstage the selected file entry. If files are
selected, operates on all selected files as a batch.
Expand Down
3 changes: 2 additions & 1 deletion doc/diffview_defaults.txt
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ DEFAULT CONFIG *diffview.defaults*
clean_up_buffers = false, -- Delete file buffers created by diffview on close.
restore_session = true, -- Restore open Diffview/FileHistory views from a sourced Vim session.
persist_selections = {
enabled = false, -- Persist file selections to disk across Neovim restarts.
enabled = false, -- Persist file selections and hide-reviewed state across Neovim restarts.
path = nil, -- Storage path. Nil uses stdpath("data") .. "/diffview_selections.json".
},
icons = { -- Only applies when use_icons is true.
Expand Down Expand Up @@ -266,6 +266,7 @@ DEFAULT CONFIG *diffview.defaults*
{ "n", "<2-LeftMouse>", actions.select_entry, { desc = "Open the diff for the selected entry" } },
{ { "n", "x" }, "w", actions.toggle_select_entry, { desc = "Toggle file selection for multi-file operations" } },
{ "n", "C", actions.clear_select_entries, { desc = "Clear all file selections" } },
{ "n", "H", actions.toggle_hide_selected, { desc = "Toggle hiding reviewed (selected) files" } },
{ "n", "-", actions.toggle_stage_entry, { desc = "Stage / unstage the selected entry (jj: save & advance)" } },
{ "n", "s", actions.toggle_stage_entry, { desc = "Stage / unstage the selected entry (jj: save & advance)" } },
{ "n", "S", actions.stage_all, { desc = "Stage all entries" } },
Expand Down
2 changes: 2 additions & 0 deletions lua/diffview/actions.lua
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ local pl = lazy.access(utils, "path") --[[@as PathLib ]]
---@field toggle_fold fun()
---@field toggle_select_entry fun()
---@field clear_select_entries fun()
---@field toggle_hide_selected fun()
---@field toggle_stage_entry fun()
---@field toggle_untracked fun()
---@field unstage_all fun()
Expand Down Expand Up @@ -1272,6 +1273,7 @@ local action_names = {
"clear_select_entries",
"toggle_stage_entry",
"toggle_untracked",
"toggle_hide_selected",
"unstage_all",
}

Expand Down
23 changes: 23 additions & 0 deletions lua/diffview/api/selections.lua
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,17 @@ local function get_panel(view)
return view.panel
end

---Refresh a loaded file panel after a selection mutation. Selection changes
---only affect rendered content, so the existing component tree can be reused.
---@param panel FilePanel
---@param changed boolean
local function refresh_panel(panel, changed)
if changed and panel:buf_loaded() then
panel:render()
panel:redraw()
end
end

---Get all currently selected files.
---
---Returns a list of tables with `path` and `kind` fields.
Expand Down Expand Up @@ -102,15 +113,18 @@ function M.select(paths, opts)
for _, p in ipairs(paths) do
path_set[p] = true
end
local changed = false
panel:batch_selection(function()
for _, file in panel.files:iter() do
if path_set[file.path] and (opts.kind == nil or file.kind == opts.kind) then
if not panel:is_selected(file) then
panel:select_file(file)
changed = true
end
end
end
end)
refresh_panel(panel, changed)
end

---Deselect files by path. Paths that do not match any file entry are ignored.
Expand All @@ -126,15 +140,18 @@ function M.deselect(paths, opts)
for _, p in ipairs(paths) do
path_set[p] = true
end
local changed = false
panel:batch_selection(function()
for _, file in panel.files:iter() do
if path_set[file.path] and (opts.kind == nil or file.kind == opts.kind) then
if panel:is_selected(file) then
panel:deselect_file(file)
changed = true
end
end
end
end)
refresh_panel(panel, changed)
end

---Replace the selection set for the targeted files. Only the given paths
Expand All @@ -153,6 +170,7 @@ function M.set(paths, opts)
for _, p in ipairs(paths) do
path_set[p] = true
end
local changed = false
panel:batch_selection(function()
for _, file in panel.files:iter() do
-- When a kind filter is active, skip files of other kinds entirely.
Expand All @@ -163,12 +181,15 @@ function M.set(paths, opts)
local have = panel:is_selected(file)
if want and not have then
panel:select_file(file)
changed = true
elseif not want and have then
panel:deselect_file(file)
changed = true
end
::continue::
end
end)
refresh_panel(panel, changed)
end

---Clear all selections.
Expand All @@ -178,7 +199,9 @@ function M.clear(view)
if not panel then
return
end
local changed = panel:has_any_selections()
panel:clear_selections()
refresh_panel(panel, changed)
end

---Return true when at least one file is selected.
Expand Down
7 changes: 4 additions & 3 deletions lua/diffview/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ local conflict_keymaps = {
---@field large_file_threshold? integer Line count above which treesitter is disabled on non-LOCAL diff buffers. 0 disables this behaviour.
---@field diffopt? table Override `diffopt` while diffview is open. Restored on close.
---@field clean_up_buffers? boolean Delete file buffers created by diffview on close.
---@field persist_selections? DiffviewPersistSelectionsConfig.user Persist file selections across Neovim restarts.
---@field persist_selections? DiffviewPersistSelectionsConfig.user Persist file selections and hide-reviewed state across Neovim restarts.
---@field restore_session? boolean Restore open Diffview/FileHistory views from a sourced Vim session.
---@field icons? DiffviewIcons.user Folder icons; only applies when `use_icons` is true.
---@field status_icons? DiffviewStatusIcons.user Icons for git status letters.
Expand Down Expand Up @@ -223,10 +223,10 @@ M.defaults = {
---@field path? string

---@class DiffviewPersistSelectionsConfig.user
---@field enabled? boolean Persist file selections to disk across Neovim restarts.
---@field enabled? boolean Persist file selections and hide-reviewed state across Neovim restarts.
---@field path? string Storage path. Nil uses `stdpath("data") .. "/diffview_selections.json"`.
persist_selections = {
enabled = false, -- Persist file selections to disk across Neovim restarts.
enabled = false, -- Persist file selections and hide-reviewed state across Neovim restarts.
path = nil, -- Storage path. Nil uses stdpath("data") .. "/diffview_selections.json".
},
restore_session = true, -- Restore open Diffview/FileHistory views from a sourced Vim session.
Expand Down Expand Up @@ -716,6 +716,7 @@ M.defaults = {
file_panel = utils.vec_join(common_panel_keymaps, common_nav_keymaps, {
{ { "n", "x" }, "w", actions.toggle_select_entry, { desc = "Toggle file selection for multi-file operations" } },
{ "n", "C", actions.clear_select_entries, { desc = "Clear all file selections" } },
{ "n", "H", actions.toggle_hide_selected, { desc = "Toggle hiding reviewed (selected) files" } },
{ "n", "-", actions.toggle_stage_entry, { desc = "Stage / unstage the selected entry (jj: save & advance)" } },
{ "n", "s", actions.toggle_stage_entry, { desc = "Stage / unstage the selected entry (jj: save & advance)" } },
{ "n", "S", actions.stage_all, { desc = "Stage all entries" } },
Expand Down
14 changes: 9 additions & 5 deletions lua/diffview/scene/views/diff/diff_view.lua
Original file line number Diff line number Diff line change
Expand Up @@ -339,11 +339,14 @@ function DiffView:_init_selection_events()
local selection_store = require("diffview.selection_store")
self._selection_scope_key = selection_store.scope_key(self.adapter.ctx.toplevel, self.rev_arg)

-- Load previously saved selections.
local saved = selection_store.load(self._selection_scope_key)
-- Load previously saved selections and hide state.
local saved, saved_hide = selection_store.load(self._selection_scope_key)
for _, key in ipairs(saved) do
self.panel.selected_files[key] = true
end
if saved_hide then
self.panel.hide_selected = true
end

-- Debounced save (500ms trailing).
self._save_selections = debounce.debounce_trailing(500, false, function()
Expand All @@ -361,15 +364,15 @@ function DiffView:_init_selection_events()
end
end

---Immediately persist current selections to disk.
---Immediately persist current selections and hide state to disk.
function DiffView:_save_selections_now()
if not self._selection_scope_key then
return
end
local selection_store = require("diffview.selection_store")
local keys = vim.tbl_keys(self.panel.selected_files)
table.sort(keys)
selection_store.save(self._selection_scope_key, keys)
selection_store.save(self._selection_scope_key, keys, self.panel.hide_selected)
end

---Replace the revision range for this view in-place and refresh the file
Expand Down Expand Up @@ -411,10 +414,11 @@ function DiffView:set_revs(new_rev_arg, opts)
-- If the new scope already has saved selections, merge them with the
-- current in-memory set so nothing is lost.
if old_scope ~= self._selection_scope_key then
local saved = selection_store.load(self._selection_scope_key)
local saved, saved_hide = selection_store.load(self._selection_scope_key)
for _, key in ipairs(saved) do
self.panel.selected_files[key] = true
end
self.panel.hide_selected = saved_hide

-- Persist under the new scope key so selections survive a restart.
-- The update_files() machinery will trigger another save via
Expand Down
68 changes: 63 additions & 5 deletions lua/diffview/scene/views/diff/file_panel.lua
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ local M = {}
---@field constrain_cursor function
---@field help_mapping string
---@field selected_files table<string, true>
---@field hide_selected boolean
---@field on_selection_changed fun(selected_files: table<string, true>)?
local FilePanel = oop.create_class("FilePanel", Panel)

Expand Down Expand Up @@ -57,6 +58,7 @@ function FilePanel:init(adapter, files, path_args, rev_pretty_name)
self.listing_style = conf.file_panel.listing_style
self.tree_options = conf.file_panel.tree_options
self.selected_files = {}
self.hide_selected = false
self.is_loading = true

self:on_autocmd("BufNew", {
Expand Down Expand Up @@ -144,6 +146,7 @@ function FilePanel:update_components()
staged_files,
{ name = "margin" },
},
{ name = "hidden_hint" },
{
name = "info",
{ name = "title" },
Expand All @@ -164,7 +167,9 @@ function FilePanel:ordered_file_list()
local list = {}

for _, file in self.files:iter() do
list[#list + 1] = file
if not (self.hide_selected and self:is_selected(file)) then
list[#list + 1] = file
end
end

return list
Expand All @@ -175,12 +180,55 @@ function FilePanel:ordered_file_list()
self.files.staged_tree.root:leaves()
)

return vim.tbl_map(function(node)
return node.data
end, nodes) --[[@as vector ]]
local result = {}
for _, node in ipairs(nodes) do
if node.data and not (self.hide_selected and self:is_selected(node.data)) then
result[#result + 1] = node.data
end
end
return result --[[@as vector ]]
end
end

---Toggle the hide-selected filter.
---When active, marked (reviewed) files are hidden from the file panel.
function FilePanel:toggle_hide_selected()
self.hide_selected = not self.hide_selected
end

---Count files currently hidden by the hide-selected filter.
---@return integer
function FilePanel:count_hidden()
local count = 0
for _, file in self.files:iter() do
if self:is_selected(file) then
count = count + 1
end
end
return count
end

---Count visible (non-hidden) files for a given kind.
---@param kind "conflicting"|"working"|"staged"
---@return integer visible, integer total
function FilePanel:count_visible(kind)
local files = self.files[kind]
if not files then
return 0, 0
end
local total = #files
if not self.hide_selected then
return total, total
end
local hidden = 0
for _, file in ipairs(files) do
if self:is_selected(file) then
hidden = hidden + 1
end
end
return total - hidden, total
end

function FilePanel:set_cur_file(file)
if self.cur_file then
self.cur_file:set_active(false)
Expand Down Expand Up @@ -384,7 +432,17 @@ function FilePanel:reconstrain_cursor()
return
end

local target_row = self.constrain_cursor(self:cursor_winid(), 0)
local target_row
if #self:ordered_file_list() == 0 then
-- All files may still exist while the hide-reviewed filter leaves no
-- selectable rows. In that case, use the repository path at the top of
-- the panel as the stable resting position for the cursor.
local path_comp = self.components and self.components.path and self.components.path.comp
target_row = path_comp and math.max(path_comp.lstart + 1, 1) or 1
else
target_row = self.constrain_cursor(self:cursor_winid(), 0)
end

for _, w in ipairs(self:cursor_winids()) do
pcall(api.nvim_win_set_cursor, w, { target_row, 0 })
end
Expand Down
Loading