diff --git a/README.md b/README.md index 9298e1f5..75cd8d65 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,14 @@ will have the index buffer on the left side, and the entries under "Staged changes" will have it on the right side). Once you write to an index buffer the index will be updated. +For operating on entire files, you can use the file panel with multi-file +selection. Press `w` on a file to toggle selection (marked with ✓), then press +`-` or `s` to stage/unstage, or `X` to restore/discard all selected files at +once. If no files are selected, operations will work on the file under the +cursor. After performing an operation, all selections are automatically cleared. +This is useful when you need to operate on multiple files that are not +consecutive in the list. + ### `:[range]DiffviewFileHistory [paths] [options]` Opens a new file history view that lists all commits that affected the given diff --git a/doc/diffview.txt b/doc/diffview.txt index 3a564c12..40b96511 100644 --- a/doc/diffview.txt +++ b/doc/diffview.txt @@ -91,6 +91,17 @@ COMMANDS *diffview-commands* entries under "Staged changes" will have it on the right side). Once you write to an index buffer the index will be updated. + For operating on entire files, you can use the file panel with multi- + file selection. Press `w` on a file to toggle selection (marked with + ✓), then press `-` or `s` to stage/unstage, or `X` to restore/discard + all selected files at once. If no files are selected, operations will + work on the file under the cursor. After performing an operation, all + selections are automatically cleared. This is useful when you need to + operate on multiple files that are not consecutive in the list. + + See also: |diffview-actions-toggle_select_entry|, + |diffview-actions-toggle_stage_entry|, |diffview-actions-restore_entry| + *diffview-merge-tool* If you call `:DiffviewOpen` during a merge or a rebase, the view will list the conflicted files in their own section. When opening a @@ -1129,7 +1140,10 @@ restore_entry *diffview-actions-restore_entry* Revert the selected file entry to the state from the left side of the diff. This only works if the right side of the diff is showing - the local state of the file. + the local state of the file. If multiple files are selected using + |diffview-actions-toggle_select_entry|, this will restore all + selected files at once. After restoring selected files, the + selection will be automatically cleared. NOTE: "Restoring" an untracked file will delete it from disk. It still gets backed up in the local git database, so it is @@ -1200,6 +1214,17 @@ stage_all *diffview-actions-stage_all* Stage all changes. +toggle_select_entry *diffview-actions-toggle_select_entry* + Contexts: `file_panel` + + Toggle file selection for multi-file operations. Selected files are + marked with a checkmark (✓) in the file panel. When multiple files are + selected, actions like |diffview-actions-toggle_stage_entry| and + |diffview-actions-restore_entry| will operate on all selected files at + once. After performing an operation, the selection is automatically + cleared. The cursor will automatically move to the next file entry + after toggling selection. + toggle_files *diffview-actions-toggle_files* Contexts: `view`, `file_panel`, `file_history_panel` @@ -1219,7 +1244,10 @@ toggle_fold *diffview-actions-toggle_fold* toggle_stage_entry *diffview-actions-toggle_stage_entry* Contexts: `diff_view`, `file_panel` - Stage / unstage the subject. + Stage / unstage the subject. If multiple files are selected using + |diffview-actions-toggle_select_entry|, this will stage/unstage all + selected files at once. After staging selected files, the selection + will be automatically cleared. unstage_all *diffview-actions-unstage_all* Contexts: `diff_view`, `file_panel` @@ -1381,8 +1409,15 @@ o Open the diff for the selected file entry. <2-LeftMouse> + *diffview-maps-toggle_select_entry* +w Toggle file selection for multi-file operations. Selected + files are marked with a checkmark (✓). When files are + selected, operations like staging (-/s) and restoring (X) + will apply to all selected files. + *diffview-maps-toggle_stage_entry* -- Stage/unstage the selected file entry. +- Stage/unstage the selected file entry. If multiple files + are selected, this will stage/unstage all selected files. *diffview-maps-stage_all* S Stage all entries. @@ -1391,12 +1426,12 @@ S Stage all entries. U Unstage all entries. *diffview-maps-restore_entry* -X Revert the selected file entry to the state from the - left side of the diff. This only works if the right - side of the diff is showing the local state of the - file. A command is echoed that shows how to undo the - change. Check |:messages| or |:DiffviewLog| to see it - again. +X Revert the selected file entry (or all selected files + if multiple are selected) to the state from the left + side of the diff. This only works if the right side of + the diff is showing the local state of the file. A + command is echoed that shows how to undo the change. + Check |:messages| or |:DiffviewLog| to see it again. *diffview-maps-refresh_files* R Update the stats and entries in the file list. diff --git a/doc/diffview_defaults.txt b/doc/diffview_defaults.txt index 1ff1f7db..57f70429 100644 --- a/doc/diffview_defaults.txt +++ b/doc/diffview_defaults.txt @@ -148,6 +148,7 @@ DEFAULT CONFIG *diffview.defaults* { "n", "o", actions.select_entry, { desc = "Open the diff for the selected entry" } }, { "n", "l", actions.select_entry, { desc = "Open the diff for the selected entry" } }, { "n", "<2-LeftMouse>", actions.select_entry, { desc = "Open the diff for the selected entry" } }, + { "n", "w", actions.toggle_select_entry, { desc = "Toggle file selection (for multi-file operations)" } }, { "n", "-", actions.toggle_stage_entry, { desc = "Stage / unstage the selected entry" } }, { "n", "s", actions.toggle_stage_entry, { desc = "Stage / unstage the selected entry" } }, { "n", "S", actions.stage_all, { desc = "Stage all entries" } }, diff --git a/lua/diffview/actions.lua b/lua/diffview/actions.lua index 6b89e1fa..a9d979dc 100644 --- a/lua/diffview/actions.lua +++ b/lua/diffview/actions.lua @@ -643,6 +643,7 @@ local action_names = { "toggle_files", "toggle_flatten_dirs", "toggle_fold", + "toggle_select_entry", "toggle_stage_entry", "unstage_all", } diff --git a/lua/diffview/config.lua b/lua/diffview/config.lua index f1f6d0f1..4795870f 100644 --- a/lua/diffview/config.lua +++ b/lua/diffview/config.lua @@ -192,6 +192,7 @@ M.defaults = { { "n", "", actions.scroll_view(0.25), { desc = "Scroll the view down" } }, { "n", "", actions.select_next_entry, { desc = "Open the diff for the next file" } }, { "n", "", actions.select_prev_entry, { desc = "Open the diff for the previous file" } }, + { "n", "w", actions.toggle_select_entry, { desc = "Toggle file selection (for multi-file operations)" } }, { "n", "[F", actions.select_first_entry, { desc = "Open the diff for the first file" } }, { "n", "]F", actions.select_last_entry, { desc = "Open the diff for the last file" } }, { "n", "gf", actions.goto_file_edit, { desc = "Open the file in the previous tabpage" } }, diff --git a/lua/diffview/scene/file_entry.lua b/lua/diffview/scene/file_entry.lua index 3e962cd3..9644501d 100644 --- a/lua/diffview/scene/file_entry.lua +++ b/lua/diffview/scene/file_entry.lua @@ -42,6 +42,7 @@ local fstat_cache = {} ---@field merge_ctx vcs.MergeContext? ---@field active boolean ---@field opened boolean +---@field selected boolean local FileEntry = oop.create_class("FileEntry") ---@class FileEntry.init.Opt @@ -75,6 +76,7 @@ function FileEntry:init(opt) self.merge_ctx = opt.merge_ctx self.active = false self.opened = false + self.selected = false end function FileEntry:destroy() diff --git a/lua/diffview/scene/views/diff/file_panel.lua b/lua/diffview/scene/views/diff/file_panel.lua index c2223266..65b8b184 100644 --- a/lua/diffview/scene/views/diff/file_panel.lua +++ b/lua/diffview/scene/views/diff/file_panel.lua @@ -22,6 +22,7 @@ local M = {} ---@field components CompStruct ---@field constrain_cursor function ---@field help_mapping string +---@field selected_files table local FilePanel = oop.create_class("FilePanel", Panel) FilePanel.winopts = vim.tbl_extend("force", Panel.winopts, { @@ -58,6 +59,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.selected_files = {} self:on_autocmd("BufNew", { callback = function() @@ -400,5 +402,35 @@ function FilePanel:render() require("diffview.scene.views.diff.render")(self) end +---Toggle selection for a file entry +---@param file FileEntry +function FilePanel:toggle_file_selection(file) + if self.selected_files[file] then + self.selected_files[file] = nil + file.selected = false + else + self.selected_files[file] = true + file.selected = true + end +end + +---Get all currently selected files as a list +---@return FileEntry[] +function FilePanel:get_selected_files() + local selected = {} + for file, _ in pairs(self.selected_files) do + table.insert(selected, file) + end + return selected +end + +---Clear all file selections +function FilePanel:clear_selections() + for file, _ in pairs(self.selected_files) do + file.selected = false + end + self.selected_files = {} +end + M.FilePanel = FilePanel return M diff --git a/lua/diffview/scene/views/diff/listeners.lua b/lua/diffview/scene/views/diff/listeners.lua index 9e6d63b1..e7798ec9 100644 --- a/lua/diffview/scene/views/diff/listeners.lua +++ b/lua/diffview/scene/views/diff/listeners.lua @@ -12,6 +12,21 @@ local await = async.await ---@param view DiffView return function(view) + -- Helper function to get selected files or fall back to current file + local function get_target_files() + local selected_files = view.panel:get_selected_files() + if #selected_files > 0 then + return selected_files + end + + local item = view:infer_cur_file(true) + if item then + return { item } + end + + return {} + end + return { tab_enter = function() local file = view.panel.cur_file @@ -102,6 +117,20 @@ return function(view) end end end, + toggle_select_entry = function() + if view.panel:is_open() then + ---@type any + local item = view.panel:get_item_at_cursor() + if item and type(item.collapsed) ~= "boolean" then + ---@cast item FileEntry + view.panel:toggle_file_selection(item) + view.panel:render() + view.panel:redraw() + -- Move cursor to next file + view.panel:highlight_next_file() + end + end + end, focus_entry = function() if view.panel:is_open() then ---@type any @@ -132,20 +161,51 @@ return function(view) return end - local item = view:infer_cur_file(true) - if item then - local success - if item.kind == "working" or item.kind == "conflicting" then - success = view.adapter:add_files({ item.path }) - elseif item.kind == "staged" then - success = view.adapter:reset_files({ item.path }) + local items_to_process = get_target_files() + if #items_to_process == 0 then + return + end + + -- Process each item + local files_to_stage = {} + local files_to_unstage = {} + + for _, item in ipairs(items_to_process) do + -- Skip directories + if type(item.collapsed) ~= "boolean" then + ---@cast item FileEntry + if item.kind == "working" or item.kind == "conflicting" then + table.insert(files_to_stage, item.path) + elseif item.kind == "staged" then + table.insert(files_to_unstage, item.path) + end + end + end + + -- Stage files + if #files_to_stage > 0 then + local success = view.adapter:add_files(files_to_stage) + if not success then + utils.err("Failed to stage files!") + return end + end + -- Unstage files + if #files_to_unstage > 0 then + local success = view.adapter:reset_files(files_to_unstage) if not success then - utils.err(("Failed to stage/unstage file: '%s'"):format(item.path)) + utils.err("Failed to unstage files!") return end + end + + -- Clear selections after staging + view.panel:clear_selections() + -- Navigate to next file if only one item was processed + if #items_to_process == 1 then + local item = items_to_process[1] if type(item.collapsed) == "boolean" then ---@cast item DirData ---@type FileTree @@ -180,14 +240,14 @@ return function(view) view.panel:set_cur_file(item) view:next_file() end - - view:update_files( - vim.schedule_wrap(function() - view.panel:highlight_cur_file() - end) - ) - view.emitter:emit(EventName.FILES_STAGED, view) end + + view:update_files( + vim.schedule_wrap(function() + view.panel:highlight_cur_file() + end) + ) + view.emitter:emit(EventName.FILES_STAGED, view) end, stage_all = function() local args = vim.tbl_map(function(file) @@ -226,22 +286,45 @@ return function(view) end local commit - if view.left.type ~= RevType.STAGE then commit = view.left.commit end - local file = view:infer_cur_file() - if not file then return end + local items_to_process = get_target_files() + if #items_to_process == 0 then + return + end - local bufid = utils.find_file_buffer(file.path) + -- Filter to only file entries (not directories) + local files_to_restore = {} + for _, item in ipairs(items_to_process) do + if type(item.collapsed) ~= "boolean" then + ---@cast item FileEntry + table.insert(files_to_restore, item) + end + end - if bufid and vim.bo[bufid].modified then - utils.err("The file is open with unsaved changes! Aborting file restoration.") + if #files_to_restore == 0 then return end - await(vcs_utils.restore_file(view.adapter, file.path, file.kind, commit)) + -- Check for unsaved changes in any of the files + for _, file in ipairs(files_to_restore) do + local bufid = utils.find_file_buffer(file.path) + if bufid and vim.bo[bufid].modified then + utils.err(("File '%s' is open with unsaved changes! Aborting file restoration."):format(file.path)) + return + end + end + + -- Restore all files + for _, file in ipairs(files_to_restore) do + await(vcs_utils.restore_file(view.adapter, file.path, file.kind, commit)) + end + + -- Clear selections after restoring + view.panel:clear_selections() + view:update_files() end), listing_style = function() diff --git a/lua/diffview/scene/views/diff/render.lua b/lua/diffview/scene/views/diff/render.lua index 3790a555..c02fa5a2 100644 --- a/lua/diffview/scene/views/diff/render.lua +++ b/lua/diffview/scene/views/diff/render.lua @@ -17,6 +17,11 @@ local function render_file(comp, show_path, depth) comp:add_text(string.rep(" ", depth * 2 + 2)) end + -- Add selection indicator + if file.selected then + comp:add_text("✓ ", "DiffviewFilePanelConflicts") + end + local icon, icon_hl = hl.get_file_icon(file.basename, file.extension) comp:add_text(icon, icon_hl) comp:add_text(file.basename, file.active and "DiffviewFilePanelSelected" or "DiffviewFilePanelFileName")