diff --git a/README.md b/README.md index eb376238..aef603a4 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/doc/diffview.txt b/doc/diffview.txt index 340b1a85..aea9e179 100644 --- a/doc/diffview.txt +++ b/doc/diffview.txt @@ -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 @@ -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` @@ -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. diff --git a/doc/diffview_defaults.txt b/doc/diffview_defaults.txt index 21e8674f..24b56515 100644 --- a/doc/diffview_defaults.txt +++ b/doc/diffview_defaults.txt @@ -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. @@ -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" } }, diff --git a/lua/diffview/actions.lua b/lua/diffview/actions.lua index 249da24e..6fdbeb02 100644 --- a/lua/diffview/actions.lua +++ b/lua/diffview/actions.lua @@ -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() @@ -1272,6 +1273,7 @@ local action_names = { "clear_select_entries", "toggle_stage_entry", "toggle_untracked", + "toggle_hide_selected", "unstage_all", } diff --git a/lua/diffview/api/selections.lua b/lua/diffview/api/selections.lua index 5fbc19a4..26af0239 100644 --- a/lua/diffview/api/selections.lua +++ b/lua/diffview/api/selections.lua @@ -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. @@ -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. @@ -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 @@ -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. @@ -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. @@ -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. diff --git a/lua/diffview/config.lua b/lua/diffview/config.lua index d026b8ce..df949791 100644 --- a/lua/diffview/config.lua +++ b/lua/diffview/config.lua @@ -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. @@ -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. @@ -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" } }, diff --git a/lua/diffview/scene/views/diff/diff_view.lua b/lua/diffview/scene/views/diff/diff_view.lua index f570d7af..56aa71dd 100644 --- a/lua/diffview/scene/views/diff/diff_view.lua +++ b/lua/diffview/scene/views/diff/diff_view.lua @@ -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() @@ -361,7 +364,7 @@ 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 @@ -369,7 +372,7 @@ function DiffView:_save_selections_now() 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 @@ -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 diff --git a/lua/diffview/scene/views/diff/file_panel.lua b/lua/diffview/scene/views/diff/file_panel.lua index e5c9484f..9f8fe4ea 100644 --- a/lua/diffview/scene/views/diff/file_panel.lua +++ b/lua/diffview/scene/views/diff/file_panel.lua @@ -19,6 +19,7 @@ local M = {} ---@field constrain_cursor function ---@field help_mapping string ---@field selected_files table +---@field hide_selected boolean ---@field on_selection_changed fun(selected_files: table)? local FilePanel = oop.create_class("FilePanel", Panel) @@ -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", { @@ -144,6 +146,7 @@ function FilePanel:update_components() staged_files, { name = "margin" }, }, + { name = "hidden_hint" }, { name = "info", { name = "title" }, @@ -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 @@ -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) @@ -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 diff --git a/lua/diffview/scene/views/diff/listeners.lua b/lua/diffview/scene/views/diff/listeners.lua index a69c76bf..827be2cc 100644 --- a/lua/diffview/scene/views/diff/listeners.lua +++ b/lua/diffview/scene/views/diff/listeners.lua @@ -12,6 +12,38 @@ local vcs_utils = lazy.require("diffview.vcs.utils") ---@module "diffview.vcs.ut local api = vim.api local await = async.await +---Find the nearest file that is not excluded, searching forward first. +---@param files FileEntry[] +---@param anchor FileEntry? +---@param excluded fun(file: FileEntry): boolean +---@return FileEntry? +local function find_visible_neighbor(files, anchor, excluded) + local idx = anchor and utils.vec_indexof(files, anchor) or -1 + if idx == -1 then + return + end + + for i = idx + 1, #files do + if not excluded(files[i]) then + return files[i] + end + end + for i = idx - 1, 1, -1 do + if not excluded(files[i]) then + return files[i] + end + end +end + +---Move the cursor to the panel's repository path when filtering leaves no +---selectable file rows. +---@param panel FilePanel +local function reconstrain_empty_panel(panel) + if #panel:ordered_file_list() == 0 then + panel:reconstrain_cursor() + end +end + ---@param view DiffView return function(view) -- Re-arm `auto_close_on_empty` retry after a deferred close. Set when the @@ -233,6 +265,8 @@ return function(view) if mode == "v" or mode == "V" or mode == "\22" then local start_line = vim.fn.line("v") local end_line = vim.fn.line(".") + local visible_files = view.panel.hide_selected and view.panel:ordered_file_list() or nil + local cur_file = view.panel.cur_file if start_line > end_line then start_line, end_line = end_line, start_line end @@ -251,6 +285,18 @@ return function(view) api.nvim_feedkeys(api.nvim_replace_termcodes("", true, false, true), "n", false) view.panel:render() view.panel:redraw() + if visible_files and cur_file and view.panel:is_selected(cur_file) then + local target = find_visible_neighbor(visible_files, cur_file, function(file) + return view.panel:is_selected(file) + end) + if target then + view:set_file(target, false, true) + else + reconstrain_empty_panel(view.panel) + end + else + reconstrain_empty_panel(view.panel) + end return end @@ -260,42 +306,110 @@ return function(view) return end + -- Resolve the affected leaves and whether this toggle will SELECT (hide) + -- or DESELECT (reveal) them. A directory selects-all unless every child + -- is already selected, in which case it deselects-all. + local leaves = {} ---@type FileEntry[] if type(item.collapsed) == "boolean" then - -- Directory: select all if any child is unselected, else deselect all. ---@cast item DirData local node = item._node if not node then return end - - local leaves = node:leaves() - local all_selected = true - for _, leaf in ipairs(leaves) do - if leaf.data and not view.panel:is_selected(leaf.data) then - all_selected = false - break + for _, leaf in ipairs(node:leaves()) do + if leaf.data then + leaves[#leaves + 1] = leaf.data end end + else + leaves[1] = item --[[@as FileEntry]] + end - view.panel:batch_selection(function() - for _, leaf in ipairs(leaves) do - if leaf.data then - if all_selected then - view.panel:deselect_file(leaf.data) - else - view.panel:select_file(leaf.data) - end + local will_hide = false + for _, f in ipairs(leaves) do + if not view.panel:is_selected(f) then + will_hide = true -- at least one unselected -> this toggle selects + break + end + end + + -- In hide mode, pre-compute which file the diff should show afterward, + -- while the affected files are still present in ordered_file_list(). + local target ---@type FileEntry? + if view.panel.hide_selected then + if will_hide then + -- The affected files vanish on render. Land on the nearest file that + -- stays visible (searching forward, then backward), skipping the + -- hidden set so we never land on a sibling that is also disappearing. + local files = view.panel:ordered_file_list() + local hidden = {} ---@type table + for _, f in ipairs(leaves) do + hidden[f] = true + end + -- Some directory children may already be selected and therefore + -- absent from `files`. Use the last affected child that is still + -- visible as the anchor, rather than blindly using the directory's + -- last leaf. + local anchor ---@type FileEntry? + for _, file in ipairs(files) do + if hidden[file] then + anchor = file end end - end) - else - ---@cast item FileEntry - view.panel:toggle_selection(item) + target = find_visible_neighbor(files, anchor, function(file) + return hidden[file] == true + end) + else + -- Unview: the toggled item reappears in the panel; show it in the + -- diff rather than jumping elsewhere. + target = leaves[1] + end end - view.panel:render() - view.panel:redraw() - view.panel:highlight_next_file() + view.panel:batch_selection(function() + for _, f in ipairs(leaves) do + if will_hide then + view.panel:select_file(f) + else + view.panel:deselect_file(f) + end + end + end) + + if view.panel.hide_selected then + -- Selection only changes which existing components render a line; + -- redraw them without rebuilding and retaining another component tree. + view.panel:render() + view.panel:redraw() + if target then + -- Open the target in the diff so the view never lingers on a file + -- that just disappeared. set_file() also syncs cur_file + highlight, + -- so / navigate from what's actually displayed (no + -- skipped entry). focus=false keeps the cursor in the panel; + -- highlight=true moves the panel cursor onto the opened entry. + view:set_file(target, false, true) + else + reconstrain_empty_panel(view.panel) + end + else + view.panel:render() + view.panel:redraw() + if will_hide then + -- Viewing: advance to the next entry (established behaviour). + view.panel:highlight_next_file() + -- Open the entry the cursor landed on so the panel, cur_file, and + -- displayed diff stay in sync. + local next_item = view.panel:get_item_at_cursor() + if next_item and type(next_item.collapsed) ~= "boolean" then + view:set_file(next_item --[[@as FileEntry]], false, true) + end + else + -- Unviewing: open the just-unmarked file in the diff and keep the + -- cursor on it (set_file syncs cur_file + highlight, focus stays in + -- the panel). + view:set_file(leaves[1], false, true) + end + end end, clear_select_entries = function() if not view.panel:is_open() then @@ -788,5 +902,31 @@ return function(view) view.panel:toggle_item_fold(dir) end end, + toggle_hide_selected = function() + if not view.panel:is_focused() then + return + end + local files = not view.panel.hide_selected and view.panel:ordered_file_list() or nil + local cur_file = view.panel.cur_file + view.panel:toggle_hide_selected() + view.panel:render() + view.panel:redraw() + if files and cur_file and view.panel:is_selected(cur_file) then + local target = find_visible_neighbor(files, cur_file, function(file) + return view.panel:is_selected(file) + end) + if target then + view:set_file(target, false, true) + else + reconstrain_empty_panel(view.panel) + end + else + reconstrain_empty_panel(view.panel) + end + local state = view.panel.hide_selected and "hidden" or "shown" + utils.info(("Reviewed files: %s"):format(state)) + -- Persist the new hide state immediately (if persistence is enabled). + view:_save_selections_now() + end, } end diff --git a/lua/diffview/scene/views/diff/render.lua b/lua/diffview/scene/views/diff/render.lua index 612c8953..9f673d5a 100644 --- a/lua/diffview/scene/views/diff/render.lua +++ b/lua/diffview/scene/views/diff/render.lua @@ -71,6 +71,29 @@ local function render_folder_count(comp, node, tree_options) end end +---Return true when every file leaf under a directory component is hidden. +---@param panel FilePanel +---@param comp RenderComponent +---@return boolean +local function dir_all_hidden(panel, comp) + local items = comp.components[2] + if not items then + return true + end + for _, child in ipairs(items.components) do + if child.name == "file" then + if not panel:is_selected(child.context) then + return false + end + elseif child.name == "directory" then + if not dir_all_hidden(panel, child) then + return false + end + end + end + return true +end + ---@param conf DiffviewConfig ---@param panel FilePanel ---@param comp RenderComponent @@ -81,6 +104,10 @@ local function render_file(conf, panel, comp, show_path, depth, sign_pad) ---@type FileEntry local file = comp.context + if panel.hide_selected and panel:is_selected(file) then + return + end + local show_marks = conf.file_panel.mark_placement ~= "sign_column" and (conf.file_panel.always_show_marks or panel:has_any_selections()) @@ -184,6 +211,10 @@ local function render_file_tree_recurse(conf, panel, depth, comp, sign_pad) return end + if panel.hide_selected and dir_all_hidden(panel, comp) then + return + end + -- Directory component structure: -- { -- name = "directory", @@ -427,7 +458,10 @@ local function render_panel(panel) if #panel.files.conflicting > 0 then comp = panel.components.conflicting.title.comp comp:add_text("Conflicts ", "DiffviewFilePanelTitle") - comp:add_text("(" .. #panel.files.conflicting .. ")", "DiffviewFilePanelCounter") + local vis_c, tot_c = panel:count_visible("conflicting") + local count_c = (panel.hide_selected and vis_c < tot_c) and (vis_c .. "/" .. tot_c) + or tostring(tot_c) + comp:add_text("(" .. count_c .. ")", "DiffviewFilePanelCounter") comp:ln() render_files(conf, panel, panel.listing_style, panel.components.conflicting.files.comp) @@ -442,7 +476,10 @@ local function render_panel(panel) 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") + local vis_w, tot_w = panel:count_visible("working") + local count_w = (panel.hide_selected and vis_w < tot_w) and (vis_w .. "/" .. tot_w) + or tostring(tot_w) + comp:add_text("(" .. count_w .. ")", "DiffviewFilePanelCounter") comp:ln() -- Show friendly message when working tree is clean. @@ -459,7 +496,10 @@ local function render_panel(panel) 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") + local vis_s, tot_s = panel:count_visible("staged") + local count_s = (panel.hide_selected and vis_s < tot_s) and (vis_s .. "/" .. tot_s) + or tostring(tot_s) + comp:add_text("(" .. count_s .. ")", "DiffviewFilePanelCounter") comp:ln() if #panel.files.staged == 0 then @@ -470,6 +510,19 @@ local function render_panel(panel) panel.components.staged.margin.comp:add_line() end + -- Footer hint, rendered below the file trees so it grows/shrinks at the + -- bottom instead of pushing the trees down when it appears. + if panel.hide_selected then + local hidden = panel:count_hidden() + if hidden > 0 then + comp = panel.components.hidden_hint.comp + comp:add_text("● ", "DiffviewFilePanelMarked") + comp:add_text(tostring(hidden), "DiffviewFilePanelCounter") + comp:add_line(" reviewed file(s) hidden", "DiffviewFilePanelPath") + comp:add_line() + end + end + if panel.rev_pretty_name or (panel.path_args and #panel.path_args > 0) then comp = panel.components.info.title.comp comp:add_line("Showing changes for:", "DiffviewFilePanelTitle") diff --git a/lua/diffview/selection_store.lua b/lua/diffview/selection_store.lua index 1c78fc01..5853e097 100644 --- a/lua/diffview/selection_store.lua +++ b/lua/diffview/selection_store.lua @@ -65,29 +65,32 @@ local function write_store(path, data) end end ----Load selection keys for a given scope. +---Load selections and hide state for a given scope. ---@param scope_key string ----@return string[] +---@return string[] selection_keys +---@return boolean hide function M.load(scope_key) local store = read_store(M.get_path()) local scope = store[scope_key] if type(scope) == "table" and type(scope.selections) == "table" then - return scope.selections + return scope.selections, scope.hide == true end - return {} + return {}, false end ----Save selection keys for a given scope. +---Save selection keys and hide state for a given scope. ---@param scope_key string ---@param selection_keys string[] -function M.save(scope_key, selection_keys) +---@param hide boolean? +function M.save(scope_key, selection_keys, hide) local path = M.get_path() local store = read_store(path) - if #selection_keys == 0 then + if #selection_keys == 0 and not hide then store[scope_key] = nil else store[scope_key] = { selections = selection_keys, + hide = hide or false, timestamp = os.time(), } end diff --git a/lua/diffview/tests/functional/config_spec.lua b/lua/diffview/tests/functional/config_spec.lua index fffb311e..5d13fc1b 100644 --- a/lua/diffview/tests/functional/config_spec.lua +++ b/lua/diffview/tests/functional/config_spec.lua @@ -78,11 +78,43 @@ describe("diffview.config default keymaps", function() assert.truthy(find_keymap(keymaps.file_panel, "s")) assert.falsy(find_keymap(keymaps.file_history_panel, "s")) + -- "H" toggles reviewed-file visibility only in the file panel. + local hide_map = find_keymap(keymaps.file_panel, "H") + assert.truthy(hide_map) + assert.equals(config.actions.toggle_hide_selected, hide_map[3]) + assert.equals("Toggle hiding reviewed (selected) files", hide_map[4].desc) + -- "g!" (options) is file_history_panel-only. assert.truthy(find_keymap(keymaps.file_history_panel, "g!")) assert.falsy(find_keymap(keymaps.file_panel, "g!")) end) + it("allows the hide-reviewed mapping to be changed", function() + local original = vim.deepcopy(config.get_config()) + local ok, err = pcall(function() + config.setup({ + keymaps = { + file_panel = { + ["H"] = false, + { "n", "h", config.actions.toggle_hide_selected }, + }, + }, + }) + + local keymaps = config.get_config().keymaps.file_panel + assert.falsy(find_keymap(keymaps, "H")) + + local replacement = find_keymap(keymaps, "h") + assert.truthy(replacement) + assert.equals(config.actions.toggle_hide_selected, replacement[3]) + end) + + config.setup(original) + if not ok then + error(err) + end + end) + it("conflict keymaps live on the merge-tool layouts, not the view section", function() local keymaps = config.defaults.keymaps -- They should be active in every layout that appears in the default diff --git a/lua/diffview/tests/functional/hide_selected_navigation_spec.lua b/lua/diffview/tests/functional/hide_selected_navigation_spec.lua new file mode 100644 index 00000000..bb71fa17 --- /dev/null +++ b/lua/diffview/tests/functional/hide_selected_navigation_spec.lua @@ -0,0 +1,279 @@ +local helpers = require("diffview.tests.helpers") +local listeners_factory = require("diffview.scene.views.diff.listeners") + +local eq = helpers.eq +local api = vim.api + +describe("reviewed-file navigation", function() + local original_get_mode + local original_feedkeys + local original_replace_termcodes + local original_line + + before_each(function() + original_get_mode = api.nvim_get_mode + original_feedkeys = api.nvim_feedkeys + original_replace_termcodes = api.nvim_replace_termcodes + original_line = vim.fn.line + api.nvim_get_mode = function() + return { mode = "n" } + end + end) + + after_each(function() + api.nvim_get_mode = original_get_mode + api.nvim_feedkeys = original_feedkeys + api.nvim_replace_termcodes = original_replace_termcodes + vim.fn.line = original_line + end) + + it("advances past a directory whose last children are already hidden", function() + local first = { path = "dir/a.lua" } + local hidden_middle = { path = "dir/b.lua" } + local hidden_last = { path = "dir/c.lua" } + local next_file = { path = "other.lua" } + local selected = { + [hidden_middle] = true, + [hidden_last] = true, + } + local directory = { + collapsed = false, + _node = { + leaves = function() + return { + { data = first }, + { data = hidden_middle }, + { data = hidden_last }, + } + end, + }, + } + + local panel = { + hide_selected = true, + is_open = function() + return true + end, + get_item_at_cursor = function() + return directory + end, + is_selected = function(_, file) + return selected[file] == true + end, + ordered_file_list = function() + return { first, next_file } + end, + batch_selection = function(_, callback) + callback() + end, + select_file = function(_, file) + selected[file] = true + end, + update_components = function() + error("selection filtering must not rebuild component trees") + end, + render = function() end, + redraw = function() end, + } + local opened + local view = { + panel = panel, + set_file = function(_, file, focus, highlight) + opened = { file, focus, highlight } + end, + } + + listeners_factory(view).toggle_select_entry() + + eq(true, selected[first]) + eq(true, selected[hidden_middle]) + eq(true, selected[hidden_last]) + eq({ next_file, false, true }, opened) + end) + + it("moves to the repository path after hiding the final visible file", function() + local current = { path = "only.lua" } + local selected = {} + local reconstrained = false + local panel = { + hide_selected = true, + is_open = function() + return true + end, + get_item_at_cursor = function() + return current + end, + is_selected = function(_, file) + return selected[file] == true + end, + ordered_file_list = function() + return selected[current] and {} or { current } + end, + batch_selection = function(_, callback) + callback() + end, + select_file = function(_, file) + selected[file] = true + end, + update_components = function() + error("selection filtering must not rebuild component trees") + end, + render = function() end, + redraw = function() end, + reconstrain_cursor = function() + reconstrained = true + end, + } + local view = { + panel = panel, + set_file = function() + error("no file should be opened") + end, + } + + listeners_factory(view).toggle_select_entry() + + eq(true, selected[current]) + eq(true, reconstrained) + end) + + it("opens the file selected by the panel after marking an entry", function() + local current = { path = "b.lua" } + local next_file = { path = "c.lua" } + local cursor_item = current + local selected = {} + local panel = { + hide_selected = false, + is_open = function() + return true + end, + get_item_at_cursor = function() + return cursor_item + end, + is_selected = function(_, file) + return selected[file] == true + end, + batch_selection = function(_, callback) + callback() + end, + select_file = function(_, file) + selected[file] = true + end, + highlight_next_file = function() + cursor_item = next_file + end, + render = function() end, + redraw = function() end, + } + local opened + local view = { + panel = panel, + set_file = function(_, file, focus, highlight) + opened = { file, focus, highlight } + end, + } + + listeners_factory(view).toggle_select_entry() + + eq(true, selected[current]) + eq({ next_file, false, true }, opened) + end) + + it("opens the next visible file when H hides the active file", function() + local previous = { path = "a.lua" } + local current = { path = "b.lua" } + local next_file = { path = "c.lua" } + local selected = { [current] = true } + local panel = { + cur_file = current, + hide_selected = false, + is_focused = function() + return true + end, + ordered_file_list = function() + return { previous, current, next_file } + end, + is_selected = function(_, file) + return selected[file] == true + end, + toggle_hide_selected = function(self) + self.hide_selected = not self.hide_selected + end, + update_components = function() + error("selection filtering must not rebuild component trees") + end, + render = function() end, + redraw = function() end, + } + local opened + local view = { + panel = panel, + set_file = function(_, file, focus, highlight) + opened = { file, focus, highlight } + end, + _save_selections_now = function() end, + } + + listeners_factory(view).toggle_hide_selected() + + eq(true, panel.hide_selected) + eq({ next_file, false, true }, opened) + end) + + it("opens the next visible file when a visual selection hides the active file", function() + local previous = { path = "a.lua" } + local current = { path = "b.lua" } + local next_file = { path = "c.lua" } + local selected = {} + api.nvim_get_mode = function() + return { mode = "V" } + end + api.nvim_feedkeys = function() end + api.nvim_replace_termcodes = function(keys) + return keys + end + vim.fn.line = function() + return 1 + end + + local panel = { + cur_file = current, + hide_selected = true, + is_open = function() + return true + end, + ordered_file_list = function() + return { previous, current, next_file } + end, + get_item_at_line = function() + return current + end, + toggle_selection = function(_, file) + selected[file] = not selected[file] + end, + is_selected = function(_, file) + return selected[file] == true + end, + batch_selection = function(_, callback) + callback() + end, + update_components = function() + error("selection filtering must not rebuild component trees") + end, + render = function() end, + redraw = function() end, + } + local opened + local view = { + panel = panel, + set_file = function(_, file, focus, highlight) + opened = { file, focus, highlight } + end, + } + + listeners_factory(view).toggle_select_entry() + + eq(true, selected[current]) + eq({ next_file, false, true }, opened) + end) +end) diff --git a/lua/diffview/tests/functional/panel_spec.lua b/lua/diffview/tests/functional/panel_spec.lua index c684c4cb..9c374a9b 100644 --- a/lua/diffview/tests/functional/panel_spec.lua +++ b/lua/diffview/tests/functional/panel_spec.lua @@ -1077,6 +1077,43 @@ describe("diffview.ui.panel", function() end) end) + it("reconstrain_cursor uses the repository path when all files are hidden", function() + local panel = make_panel({ make_entry("a.lua") }) + local original_set_cursor = vim.api.nvim_win_set_cursor + local cursor_writes = {} + + local ok, err = pcall(function() + panel.is_open = function() + return true + end + panel.buf_loaded = function() + return true + end + panel.ordered_file_list = function() + return {} + end + panel.cursor_winids = function() + return { 11, 12 } + end + panel.components = { path = { comp = { lstart = 3 } } } + panel.constrain_cursor = function() + error("file-row constraint should not be used") + end + vim.api.nvim_win_set_cursor = function(winid, cursor) + cursor_writes[#cursor_writes + 1] = { winid, cursor } + end + + panel:reconstrain_cursor() + + eq({ { 11, { 4, 0 } }, { 12, { 4, 0 } } }, cursor_writes) + end) + + vim.api.nvim_win_set_cursor = original_set_cursor + if not ok then + error(err) + end + end) + it("ordered_file_list works without components", function() local f1 = make_entry("a.lua") local f2 = make_entry("b.lua") diff --git a/lua/diffview/tests/functional/selection_store_spec.lua b/lua/diffview/tests/functional/selection_store_spec.lua index f217d846..38b2fc26 100644 --- a/lua/diffview/tests/functional/selection_store_spec.lua +++ b/lua/diffview/tests/functional/selection_store_spec.lua @@ -74,6 +74,52 @@ describe("diffview.selection_store", function() eq({}, SelectionStore.load(scope)) end) + it("defaults hide to false for a fresh load", function() + local _, hide = SelectionStore.load("nonexistent:scope") + eq(false, hide) + end) + + it("round-trips the hide flag alongside selections", function() + local scope = "/repo:HEAD" + SelectionStore.save(scope, { "working:a.lua" }, true) + + local loaded, hide = SelectionStore.load(scope) + eq({ "working:a.lua" }, loaded) + eq(true, hide) + end) + + it("keeps the scope when hide is true even with no selections", function() + local scope = "/repo:" + SelectionStore.save(scope, {}, true) + + local loaded, hide = SelectionStore.load(scope) + eq({}, loaded) + eq(true, hide) + end) + + it("clears the scope only when selections are empty and hide is false", function() + local scope = "/repo:" + SelectionStore.save(scope, { "working:a.lua" }, true) + eq(true, (select(2, SelectionStore.load(scope)))) + + -- Empty selections but hide still on: scope must survive. + SelectionStore.save(scope, {}, true) + eq(true, (select(2, SelectionStore.load(scope)))) + + -- Both empty/off: scope is removed and hide resets to the default. + SelectionStore.save(scope, {}, false) + local loaded, hide = SelectionStore.load(scope) + eq({}, loaded) + eq(false, hide) + end) + + it("treats a missing hide field as false (backward compat)", function() + local scope = "/repo:" + -- Saving without the hide arg must not persist hide=true. + SelectionStore.save(scope, { "working:a.lua" }) + eq(false, (select(2, SelectionStore.load(scope)))) + end) + it("handles corrupt file gracefully", function() local path = tmpdir .. "/test_selections.json" vim.fn.writefile({ "not valid json{{{" }, path) @@ -262,4 +308,146 @@ describe("diffview.selection_store", function() eq(1, called) end) end) + + describe("hide_selected filtering", function() + local FilePanel = require("diffview.scene.views.diff.file_panel").FilePanel + + -- Mock `files` object exposing both the iterator used by ordered_file_list + -- and the per-kind buckets used by count_visible(). + local function make_mock_files(entries) + local all = entries or {} + local files = { conflicting = {}, working = {}, staged = {} } + for _, f in ipairs(all) do + local bucket = files[f.kind] + if bucket then + bucket[#bucket + 1] = f + end + end + function files:iter() + local i = 0 + return function() + i = i + 1 + if i <= #all then + return i, all[i] + end + end + end + function files:len() + return #all + end + return files + end + + local function make_panel(entries) + local adapter = { ctx = { toplevel = "/tmp", dir = "/tmp/.git" } } + local panel = FilePanel(adapter, make_mock_files(entries), {}) + -- Force list mode so ordered_file_list() uses the flat iterator and we + -- don't need to build tree structures. + panel.listing_style = "list" + return panel + end + + it("defaults to disabled", function() + local panel = make_panel({}) + eq(false, panel.hide_selected) + end) + + it("toggle_hide_selected flips the flag", function() + local panel = make_panel({}) + panel:toggle_hide_selected() + eq(true, panel.hide_selected) + panel:toggle_hide_selected() + eq(false, panel.hide_selected) + end) + + it("ordered_file_list keeps all files when disabled", function() + local a = { path = "a.lua", kind = "working" } + local b = { path = "b.lua", kind = "working" } + local panel = make_panel({ a, b }) + panel:select_file(a) + + eq({ a, b }, panel:ordered_file_list()) + end) + + it("ordered_file_list omits selected files when enabled", function() + local a = { path = "a.lua", kind = "working" } + local b = { path = "b.lua", kind = "working" } + local panel = make_panel({ a, b }) + panel:select_file(a) + panel.hide_selected = true + + eq({ b }, panel:ordered_file_list()) + end) + + it("toggling hide back reveals previously hidden files", function() + local a = { path = "a.lua", kind = "working" } + local b = { path = "b.lua", kind = "working" } + local panel = make_panel({ a, b }) + panel:select_file(a) + + panel.hide_selected = true + eq({ b }, panel:ordered_file_list()) + + panel.hide_selected = false + eq({ a, b }, panel:ordered_file_list()) + end) + + it("count_hidden counts selected files regardless of kind", function() + local a = { path = "a.lua", kind = "working" } + local b = { path = "b.lua", kind = "staged" } + local c = { path = "c.lua", kind = "working" } + local panel = make_panel({ a, b, c }) + + eq(0, panel:count_hidden()) + panel:select_file(a) + panel:select_file(b) + eq(2, panel:count_hidden()) + end) + + it("count_visible returns total/total when filter is off", function() + local a = { path = "a.lua", kind = "working" } + local b = { path = "b.lua", kind = "working" } + local panel = make_panel({ a, b }) + panel:select_file(a) + + local visible, total = panel:count_visible("working") + eq(2, visible) + eq(2, total) + end) + + it("count_visible subtracts hidden files when filter is on", function() + local a = { path = "a.lua", kind = "working" } + local b = { path = "b.lua", kind = "working" } + local c = { path = "c.lua", kind = "working" } + local panel = make_panel({ a, b, c }) + panel:select_file(a) + panel:select_file(b) + panel.hide_selected = true + + local visible, total = panel:count_visible("working") + eq(1, visible) + eq(3, total) + end) + + it("count_visible is per-kind", function() + local w = { path = "w.lua", kind = "working" } + local s = { path = "s.lua", kind = "staged" } + local panel = make_panel({ w, s }) + panel:select_file(s) + panel.hide_selected = true + + eq(1, (panel:count_visible("working"))) + + local vis_s, tot_s = panel:count_visible("staged") + eq(0, vis_s) + eq(1, tot_s) + end) + + it("count_visible returns 0,0 for an unknown kind", function() + local panel = make_panel({}) + local visible, total = panel:count_visible("conflicting") + eq(0, visible) + eq(0, total) + end) + end) end) diff --git a/lua/diffview/tests/functional/selections_api_spec.lua b/lua/diffview/tests/functional/selections_api_spec.lua index bc4134de..4dad137f 100644 --- a/lua/diffview/tests/functional/selections_api_spec.lua +++ b/lua/diffview/tests/functional/selections_api_spec.lua @@ -59,6 +59,32 @@ describe("diffview.api.selections", function() return view, panel end + ---Assert that a mutation refreshes a loaded panel without rebuilding its + ---component tree. + ---@param panel FilePanel + ---@param mutate fun() + local function assert_panel_refresh(panel, mutate) + local renders = 0 + local redraws = 0 + panel.buf_loaded = function() + return true + end + panel.update_components = function() + error("selection API must not rebuild component trees") + end + panel.render = function() + renders = renders + 1 + end + panel.redraw = function() + redraws = redraws + 1 + end + + mutate() + + eq(1, renders) + eq(1, redraws) + end + describe("get", function() it("returns empty list when no view is given and none is current", function() eq({}, selections.get()) @@ -201,6 +227,18 @@ describe("diffview.api.selections", function() eq(1, called) end) + it("refreshes a loaded panel after changing selections", function() + local a = make_entry("a.lua") + local view, panel = make_view({ a }) + panel.hide_selected = true + + assert_panel_refresh(panel, function() + selections.select({ "a.lua" }, { view = view }) + end) + + eq(true, panel:is_selected(a)) + end) + it("is safe when no view exists", function() assert.has_no.errors(function() selections.select({ "a.lua" }) @@ -243,6 +281,19 @@ describe("diffview.api.selections", function() eq(false, view.panel:is_selected(working)) eq(true, view.panel:is_selected(staged)) end) + + it("refreshes a loaded panel after changing selections", function() + local a = make_entry("a.lua") + local view, panel = make_view({ a }) + panel:select_file(a) + panel.hide_selected = true + + assert_panel_refresh(panel, function() + selections.deselect({ "a.lua" }, { view = view }) + end) + + eq(false, panel:is_selected(a)) + end) end) describe("set", function() @@ -315,6 +366,21 @@ describe("diffview.api.selections", function() eq(true, view.panel:is_selected(b)) eq(true, view.panel:is_selected(working)) end) + + it("refreshes a loaded panel after changing selections", function() + local a = make_entry("a.lua") + local b = make_entry("b.lua") + local view, panel = make_view({ a, b }) + panel:select_file(a) + panel.hide_selected = true + + assert_panel_refresh(panel, function() + selections.set({ "b.lua" }, { view = view }) + end) + + eq(false, panel:is_selected(a)) + eq(true, panel:is_selected(b)) + end) end) describe("clear", function() @@ -331,6 +397,34 @@ describe("diffview.api.selections", function() eq(false, view.panel:is_selected(b)) end) + it("refreshes a loaded panel after changing selections", function() + local a = make_entry("a.lua") + local view, panel = make_view({ a }) + panel:select_file(a) + panel.hide_selected = true + + assert_panel_refresh(panel, function() + selections.clear(view) + end) + + eq(false, panel:is_selected(a)) + end) + + it("does not refresh when selections are already empty", function() + local view, panel = make_view({ make_entry("a.lua") }) + panel.buf_loaded = function() + return true + end + panel.render = function() + error("no-op selection changes must not render") + end + panel.redraw = function() + error("no-op selection changes must not redraw") + end + + selections.clear(view) + end) + it("is safe when no view exists", function() assert.has_no.errors(function() selections.clear()