From c2c8f9dd706b2eaaf090beb19634ff32f3869b89 Mon Sep 17 00:00:00 2001 From: ldhnam Date: Sun, 9 Aug 2026 16:27:42 +0700 Subject: [PATCH 1/2] feat: add configurable keymaps for all views via opts.keymaps Introduce a central keymap registry (lua/fugit2/view/keymaps.lua) as the single source of truth for default bindings across all views. Every view now binds its keymaps through keymaps.bind/bind_buf, and users can remap, disable (false) or no-op () any binding via opts.keymaps... Groups: file_tree, commit_log, patch_unstaged, patch_staged, rebase, graph_log, graph_branch, graph_select, diff, stash_list, pick, input, confirm, blame, blame_file, blame_popup, patch. Backward compatibility: the deprecated file_tree_maps.menu option is translated into keymaps.file_tree.menu_ in config.merge; the legacy file_tree_maps.direct handling is preserved. Adds keymaps_spec and config_spec covering defaults, overrides, disable, no-op, mode handling and legacy translation. 205 tests pass. --- README.md | 34 +- lua/fugit2/config.lua | 41 +- lua/fugit2/view/components/menus.lua | 61 +- lua/fugit2/view/components/patch_view.lua | 16 +- .../view/components/stash_list_view.lua | 52 +- lua/fugit2/view/git_blame.lua | 29 +- lua/fugit2/view/git_blame_file.lua | 40 +- lua/fugit2/view/git_diff.lua | 45 +- lua/fugit2/view/git_graph.lua | 104 +-- lua/fugit2/view/git_pick.lua | 51 +- lua/fugit2/view/git_rebase.lua | 177 +++-- lua/fugit2/view/git_status.lua | 653 ++++++++---------- lua/fugit2/view/keymaps.lua | 274 ++++++++ spec/fugit2/config_spec.lua | 66 ++ spec/fugit2/view/keymaps_spec.lua | 235 +++++++ 15 files changed, 1253 insertions(+), 625 deletions(-) create mode 100644 lua/fugit2/view/keymaps.lua create mode 100644 spec/fugit2/config_spec.lua create mode 100644 spec/fugit2/view/keymaps_spec.lua diff --git a/README.md b/README.md index e5d1ed0..823cd9b 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Git plugin for Neovim (based on libgit2). - ✔ Git blame. - ✔ Interactive in-memory rebase. - ✔ Stash management. -- ☐ TODO: Allow remap default key binding. +- ✔ Remappable key bindings (`opts.keymaps`). - ☐ TODO: Proper help menu. ## 📦 Installation @@ -139,6 +139,7 @@ TODO: add later ---@field blame_info_height integer height of blame hunk detail popup ---@field command_timeout integer timeout in milisecond of command like git pull / git push ---@field colorscheme string? custom color scheme override +---@field keymaps table> keybindings per view local opts = { width = 100, min_width = 50, @@ -154,6 +155,37 @@ local opts = { } ``` +### Remapping keybindings + +All keybindings across every view can be remapped through `opts.keymaps`, grouped by +view. Each action accepts a single key string, a list of keys, `false` to disable, or +`""` to bind as a no-op: + +```lua +opts = { + keymaps = { + file_tree = { + stage_file = "S", -- remap stage from "s" + unstage_file = false, -- disable unstage binding + discard = { "X", "D" }, -- multiple keys + menu_commit = "C", -- menu actions use menu_ ids + }, + commit_log = { + copy_oid = "yY", + }, + rebase = { + drop = { "x", "d" }, + }, + -- groups: file_tree, commit_log, patch_unstaged, patch_staged, rebase, + -- graph_log, graph_branch, graph_select, diff, stash_list, + -- pick, input, confirm, blame, blame_file, blame_popup, patch + }, +} +``` + +> **Note:** The legacy `file_tree_maps.menu` option still works and is translated into +> `keymaps.file_tree.menu_` for backward compatibility, but is deprecated. + ## Tested colorschemes - [Catppuccin](https://github.com/catppuccin/nvim) diff --git a/lua/fugit2/config.lua b/lua/fugit2/config.lua index 91a5a35..6b4294d 100644 --- a/lua/fugit2/config.lua +++ b/lua/fugit2/config.lua @@ -30,7 +30,8 @@ ---@field blame_info_height integer height of blame hunk detail popup ---@field command_timeout integer timeout in milisecond of command like git pull / git push ---@field colorscheme string? custom colorscheme specification ----@field file_tree_maps FileTreeMaps keymaps for file tree +---@field file_tree_maps FileTreeMaps keymaps for file tree (deprecated, use keymaps) +---@field keymaps table> keymaps for all views local DEFAULT_CONFIG = { width = 100, min_width = 50, @@ -61,12 +62,39 @@ local DEFAULT_CONFIG = { local M = {} M.config = DEFAULT_CONFIG +-- Legacy file_tree_maps action names to registry `menu_*` action ids. +local FILE_TREE_MENU_ACTIONS = { + commit = "menu_commit", + diff = "menu_diff", + branch = "menu_branch", + push = "menu_push", + fetch = "menu_fetch", + pull = "menu_pull", + forge = "menu_forge", + stash = "menu_stash", + cherry_pick = "menu_cherry_pick", +} + -- Usually configurations can be merged, -- accepting outside params and some validation here. function M.merge(args) -- TODO: validate args M.config = vim.tbl_deep_extend("force", M.config, args or {}) + -- Backward compatibility: translate the deprecated file_tree_maps.menu into the + -- unified keymaps.file_tree. namespace so existing configs keep + -- working. New keymaps entries take precedence. + M.config.keymaps = M.config.keymaps or {} + M.config.keymaps.file_tree = M.config.keymaps.file_tree or {} + + local legacy_menu = args and args.file_tree_maps and args.file_tree_maps.menu or {} + for action, key in pairs(legacy_menu) do + local registry_action = FILE_TREE_MENU_ACTIONS[action] + if registry_action and M.config.keymaps.file_tree[registry_action] == nil then + M.config.keymaps.file_tree[registry_action] = key + end + end + return M.config end @@ -76,6 +104,17 @@ function M.get() return M.config end +-- Returns the keymaps config for a group, always a table. +---@param group string? view group name +---@return table +function M.get_keymaps(group) + local keymaps = M.config.keymaps or {} + if group then + return keymaps[group] or {} + end + return keymaps +end + -- Returns Fugit2 config setting as string ---@param setting string setting key ---@return string? diff --git a/lua/fugit2/view/components/menus.lua b/lua/fugit2/view/components/menus.lua index 8064a0d..b0f8572 100644 --- a/lua/fugit2/view/components/menus.lua +++ b/lua/fugit2/view/components/menus.lua @@ -58,18 +58,24 @@ function Confirm:init(ns_id, msg_line) self._popup:hide() end self._popup:on(event.BufLeave, exit_fn) - self._popup:map("n", { "q", "n", "" }, exit_fn, opts) - self._popup:map("n", "l", function() - vim.api.nvim_win_set_cursor(self._popup.winid, { 2, self._no_pos }) - end, opts) - self._popup:map("n", "h", function() - vim.api.nvim_win_set_cursor(self._popup.winid, { 2, self._yes_pos }) - end, opts) - self._popup:map("n", "", function() - local pos = vim.api.nvim_win_get_cursor(self._popup.winid) - local new_pos = (pos[2] < self._yes_pos + 4) and self._no_pos or self._yes_pos - vim.api.nvim_win_set_cursor(self._popup.winid, { 2, new_pos }) - end, opts) + local keymaps = require "fugit2.view.keymaps" + local fugit2_config = require "fugit2.config" + local user_confirm_keymaps = fugit2_config.get_keymaps "confirm" + local handlers = { + exit = exit_fn, + move_no = function() + vim.api.nvim_win_set_cursor(self._popup.winid, { 2, self._no_pos }) + end, + move_yes = function() + vim.api.nvim_win_set_cursor(self._popup.winid, { 2, self._yes_pos }) + end, + toggle = function() + local pos = vim.api.nvim_win_get_cursor(self._popup.winid) + local new_pos = (pos[2] < self._yes_pos + 4) and self._no_pos or self._yes_pos + vim.api.nvim_win_set_cursor(self._popup.winid, { 2, new_pos }) + end, + } + keymaps.bind(self._popup, "confirm", handlers, user_confirm_keymaps, opts) end ---@parm text NuiLine @@ -105,17 +111,28 @@ end ---@param callback function function Confirm:on_yes(callback) - self._popup:map("n", "y", function() - self._popup:hide() - callback() - end, { noremap = true, nowait = true }) - self._popup:map("n", "", function() - local pos = vim.api.nvim_win_get_cursor(self._popup.winid) - self._popup:hide() - if pos[1] == 2 and pos[2] < self._yes_pos + 4 then + local keymaps = require "fugit2.view.keymaps" + local fugit2_config = require "fugit2.config" + local user_confirm_keymaps = fugit2_config.get_keymaps "confirm" + + local yes_keys = keymaps.resolve_keys("confirm", "yes", user_confirm_keymaps) + if yes_keys and yes_keys ~= false then + self._popup:map("n", yes_keys, function() + self._popup:hide() callback() - end - end, { noremap = true, nowait = true }) + end, { noremap = true, nowait = true }) + end + + local enter_keys = keymaps.resolve_keys("confirm", "yes_enter", user_confirm_keymaps) + if enter_keys and enter_keys ~= false then + self._popup:map("n", enter_keys, function() + local pos = vim.api.nvim_win_get_cursor(self._popup.winid) + self._popup:hide() + if pos[1] == 2 and pos[2] < self._yes_pos + 4 then + callback() + end + end, { noremap = true, nowait = true }) + end end ---@param callback function diff --git a/lua/fugit2/view/components/patch_view.lua b/lua/fugit2/view/components/patch_view.lua index 493992b..3606cfe 100644 --- a/lua/fugit2/view/components/patch_view.lua +++ b/lua/fugit2/view/components/patch_view.lua @@ -7,6 +7,8 @@ local event = require("nui.utils.autocmd").event local strings = require "plenary.strings" local diff_utils = require "fugit2.diff" +local fugit2_config = require "fugit2.config" +local keymaps = require "fugit2.view.keymaps" local utils = require "fugit2.utils" ---@class Fugit2PatchView @@ -71,14 +73,12 @@ function PatchView:init(ns_id, title, title_color) -- keymaps local opts = { noremap = true, nowait = true } - -- self.popup:map("n", "=", "za", opts) - self.popup:map("n", "J", self:next_hunk_handler(), opts) - self.popup:map("n", "K", self:prev_hunk_handler(), opts) - -- local expand_collapse_handler = self:expand_collapse_handler() - -- self.popup:map("n", "", expand_collapse_handler, opts) - -- self.popup:map("n", "l", self:expand_handler(), opts) - -- self.popup:map("n", "H", self:collapse_all_handler(), opts) - -- self.popup:map("n", "L", self:expand_all_handler(), opts) + local user_patch_keymaps = fugit2_config.get_keymaps "patch" + local handlers = { + next_hunk = self:next_hunk_handler(), + prev_hunk = self:prev_hunk_handler(), + } + keymaps.bind(self.popup, "patch", handlers, user_patch_keymaps, opts) end function PatchView:winid() diff --git a/lua/fugit2/view/components/stash_list_view.lua b/lua/fugit2/view/components/stash_list_view.lua index 65689a2..4fba14e 100644 --- a/lua/fugit2/view/components/stash_list_view.lua +++ b/lua/fugit2/view/components/stash_list_view.lua @@ -4,6 +4,8 @@ local NuiLine = require "nui.line" local NuiPopup = require "nui.popup" local NuiText = require "nui.text" local Object = require "nui.object" +local fugit2_config = require "fugit2.config" +local keymaps = require "fugit2.view.keymaps" ---@class Fugit2StashListView ---@field ns_id integer @@ -129,31 +131,31 @@ function StashListView:mount() self:render() local opts = { noremap = true, nowait = true } - - self.popup:map("n", { "q", "" }, function() - self:close() - end, opts) - - self.popup:map("n", "a", function() - local entry = self:get_entry() - if entry and self._action_fn then - self._action_fn("apply", entry) - end - end, opts) - - self.popup:map("n", "p", function() - local entry = self:get_entry() - if entry and self._action_fn then - self._action_fn("pop", entry) - end - end, opts) - - self.popup:map("n", "d", function() - local entry = self:get_entry() - if entry and self._action_fn then - self._action_fn("drop", entry) - end - end, opts) + local user_stash_keymaps = fugit2_config.get_keymaps "stash_list" + local handlers = { + exit = function() + self:close() + end, + apply = function() + local entry = self:get_entry() + if entry and self._action_fn then + self._action_fn("apply", entry) + end + end, + pop = function() + local entry = self:get_entry() + if entry and self._action_fn then + self._action_fn("pop", entry) + end + end, + drop = function() + local entry = self:get_entry() + if entry and self._action_fn then + self._action_fn("drop", entry) + end + end, + } + keymaps.bind(self.popup, "stash_list", handlers, user_stash_keymaps, opts) end return StashListView diff --git a/lua/fugit2/view/git_blame.lua b/lua/fugit2/view/git_blame.lua index 354b3a4..2cfe6a2 100644 --- a/lua/fugit2/view/git_blame.lua +++ b/lua/fugit2/view/git_blame.lua @@ -1,7 +1,6 @@ -- GitBlame split view local Object = require "nui.object" -local keymap = require "nui.utils.keymap" local table_new = require "table.new" local uv = vim.uv or vim.loop local NuiLine = require "nui.line" @@ -12,6 +11,8 @@ local strings = require "plenary.strings" local event = require("nui.utils.autocmd").event local blame = require "fugit2.core.blame" +local fugit2_config = require "fugit2.config" +local keymaps = require "fugit2.view.keymaps" local notifier = require "fugit2.notifier" local utils = require "fugit2.utils" @@ -359,18 +360,20 @@ function GitBlame:setup_handlers() end, }) - -- quit event - keymap.set(self.bufnr, "n", { "q", "" }, function() - self:unmount() - end, opts) - - -- jump events - keymap.set(self.bufnr, "n", { "J", "]c" }, function() - self:next_hunk() - end, opts) - keymap.set(self.bufnr, "n", { "K", "[c" }, function() - self:prev_hunk() - end, opts) + -- keymaps + local user_blame_keymaps = fugit2_config.get_keymaps "blame" + local handlers = { + exit = function() + self:unmount() + end, + next_hunk = function() + self:next_hunk() + end, + prev_hunk = function() + self:prev_hunk() + end, + } + keymaps.bind_buf(self.bufnr, "blame", handlers, user_blame_keymaps, opts) end return GitBlame diff --git a/lua/fugit2/view/git_blame_file.lua b/lua/fugit2/view/git_blame_file.lua index 1cffe57..8c164c8 100644 --- a/lua/fugit2/view/git_blame_file.lua +++ b/lua/fugit2/view/git_blame_file.lua @@ -6,12 +6,12 @@ local NuiText = require "nui.text" local Object = require "nui.object" local Path = require "plenary.path" local PlenaryJob = require "plenary.job" -local keymap = require "nui.utils.keymap" local strings = require "plenary.strings" local event = require("nui.utils.autocmd").event local blame = require "fugit2.core.blame" local config = require "fugit2.config" +local keymaps = require "fugit2.view.keymaps" local notifier = require "fugit2.notifier" local pendulum = require "fugit2.core.pendulum" local utils = require "fugit2.utils" @@ -237,7 +237,9 @@ function GitBlameFile:show_blame_popup() states.popup = nil blame_detail:unmount() end - blame_detail:map("n", { "q", "" }, exit_fn, { noremap = true, nowait = true }) + keymaps.bind(blame_detail, "blame_popup", { + exit = exit_fn, + }, config.get_keymaps "blame_popup", { noremap = true, nowait = true }) blame_detail:on(event.BufLeave, exit_fn, { once = true }) states.cursor_move_handler = vim.api.nvim_create_autocmd({ event.CursorMoved, event.WinScrolled }, { @@ -403,23 +405,31 @@ end function GitBlameFile:setup_handlers() local opts = { noremap = true, nowait = true } local bufnr = self.file_bufnr - - -- quit event - keymap.set(bufnr, "n", { "q", "" }, function() - self:destroy() - end, opts) - - -- show detail - keymap.set(bufnr, "n", { "c" }, function() - self:toggle_blame_popup() - end, opts) + local user_blame_file_keymaps = config.get_keymaps "blame_file" + + local handlers = { + exit = function() + self:destroy() + end, + show_detail = function() + self:toggle_blame_popup() + end, + } + keymaps.bind_buf(bufnr, "blame_file", handlers, user_blame_file_keymaps, opts) end -- Clears handlers we setup before. function GitBlameFile:clear_handlers() - vim.api.nvim_buf_del_keymap(self.file_bufnr, "n", "q") - vim.api.nvim_buf_del_keymap(self.file_bufnr, "n", "") - vim.api.nvim_buf_del_keymap(self.file_bufnr, "n", "c") + local bufnr = self.file_bufnr + local user_blame_file_keymaps = config.get_keymaps "blame_file" + local keymap = require "nui.utils.keymap" + + for action in pairs(keymaps.defs "blame_file") do + local keys = keymaps.resolve_keys("blame_file", action, user_blame_file_keymaps) + if keys and keys ~= false then + keymap._del(bufnr, "n", keys, true) + end + end end -- Loads Fugit2Blame in file buffer information. diff --git a/lua/fugit2/view/git_diff.lua b/lua/fugit2/view/git_diff.lua index 61d5a49..1cd4931 100644 --- a/lua/fugit2/view/git_diff.lua +++ b/lua/fugit2/view/git_diff.lua @@ -15,7 +15,9 @@ local GitStatusDiffBase = require "fugit2.view.git_base_view" local SourceTree = require "fugit2.view.components.source_tree_view" local TreeBase = require "fugit2.view.components.base_tree_view" local UI = require "fugit2.view.components.menus" +local fugit2_config = require "fugit2.config" local git2 = require "fugit2.core.git2" +local keymaps = require "fugit2.view.keymaps" local notifier = require "fugit2.notifier" local GIT_OID_LENGTH = 8 @@ -682,18 +684,6 @@ function GitDiff:_setup_handlers() local opts = { noremap = true, nowait = true } local source_tree = self._views.files - source_tree:map("n", { "q", "" }, function() - self:unmount() - end, opts) - - source_tree:map("n", { "l", "" }, function() - if self._states.pane == Pane.TWO and vim.api.nvim_win_is_valid(self._windows[1]) then - vim.api.nvim_set_current_win(self._windows[1]) - elseif self._states.pane == Pane.THREE and vim.api.nvim_win_is_valid(self._windows[3]) then - vim.api.nvim_set_current_win(self._windows[3]) - end - end, opts) - -- SourceTree handlers -- source_tree:on(event.BufWinLeave, function() -- self:unmount() @@ -703,16 +693,27 @@ function GitDiff:_setup_handlers() self:_refresh_views() end) - -- Stage/unstaged/discard - source_tree:map("n", "s", self:_index_add_reset_handler(false, TreeBase.IndexAction.ADD), opts) - source_tree:map("n", "u", self:_index_add_reset_handler(false, TreeBase.IndexAction.RESET), opts) - source_tree:map("n", { "-", "" }, self:_index_add_reset_handler(false, TreeBase.IndexAction.ADD_RESET), opts) - - -- Refresh - source_tree:map("n", "r", function() - self:update() - self:_refresh_views() - end, opts) + local user_diff_keymaps = fugit2_config.get_keymaps "diff" + local source_tree_handlers = { + exit = function() + self:unmount() + end, + focus_pane = function() + if self._states.pane == Pane.TWO and vim.api.nvim_win_is_valid(self._windows[1]) then + vim.api.nvim_set_current_win(self._windows[1]) + elseif self._states.pane == Pane.THREE and vim.api.nvim_win_is_valid(self._windows[3]) then + vim.api.nvim_set_current_win(self._windows[3]) + end + end, + stage_file = self:_index_add_reset_handler(false, TreeBase.IndexAction.ADD), + unstage_file = self:_index_add_reset_handler(false, TreeBase.IndexAction.RESET), + stage_toggle = self:_index_add_reset_handler(false, TreeBase.IndexAction.ADD_RESET), + refresh = function() + self:update() + self:_refresh_views() + end, + } + keymaps.bind(source_tree, "diff", source_tree_handlers, user_diff_keymaps, opts) end return GitDiff diff --git a/lua/fugit2/view/git_graph.lua b/lua/fugit2/view/git_graph.lua index 38a232d..10fb1e4 100644 --- a/lua/fugit2/view/git_graph.lua +++ b/lua/fugit2/view/git_graph.lua @@ -8,7 +8,9 @@ local iter = require "plenary.iterators" local BranchView = require "fugit2.view.components.branch_tree_view" local LogView = require "fugit2.view.components.commit_log_view" +local fugit2_config = require "fugit2.config" local git2 = require "fugit2.core.git2" +local keymaps = require "fugit2.view.keymaps" local notifier = require "fugit2.notifier" local utils = require "fugit2.utils" @@ -429,13 +431,17 @@ function GitGraph:on_commit_select(callback) self._commit_select_fn = callback -- commit select - log_view:map("n", { "", "" }, function() - local commit = self._views.log:get_commit() - if commit then - self:unmount() - callback(commit) - end - end, { noremap = true, nowait = true }) + local user_select_keymaps = fugit2_config.get_keymaps "graph_select" + local keys = keymaps.resolve_keys("graph_select", "select_commit", user_select_keymaps) + if keys and keys ~= false then + log_view:map("n", keys, function() + local commit = self._views.log:get_commit() + if commit then + self:unmount() + callback(commit) + end + end, { noremap = true, nowait = true }) + end end ---Set call be called when user select branch @@ -445,14 +451,18 @@ function GitGraph:on_branch_select(callback) self._branch_select_fn = callback -- branch select - branch_view:unmap("n", { "", "" }) - branch_view:map("n", { "", "" }, function() - local node, _ = branch_view:get_child_node_linenr() - if node and node.id then - self:unmount() - callback(node.id) - end - end, { noremap = true, nowait = true }) + local user_select_keymaps = fugit2_config.get_keymaps "graph_select" + local keys = keymaps.resolve_keys("graph_select", "select_branch", user_select_keymaps) + if keys and keys ~= false then + branch_view:unmap("n", keys) + branch_view:map("n", keys, function() + local node, _ = branch_view:get_child_node_linenr() + if node and node.id then + self:unmount() + callback(node.id) + end + end, { noremap = true, nowait = true }) + end end -- Setups keymap handlers @@ -466,28 +476,45 @@ function GitGraph:setup_handlers() self.repo:free_walker() -- free cached walker self:unmount() end - log_view:map("n", "q", exit_fn, map_options) - log_view:map("n", "", exit_fn, map_options) - branch_view:map("n", "q", exit_fn, map_options) - branch_view:map("n", "", exit_fn, map_options) -- refresh local update_fn = function() self:update() self:render() end - log_view:map("n", "r", update_fn, map_options) - branch_view:map("n", "r", update_fn, map_options) - - --movement - log_view:map("n", "j", "2j", map_options) - log_view:map("n", "k", "2k", map_options) - log_view:map("n", "h", function() - vim.api.nvim_set_current_win(branch_view:winid()) - end, map_options) - branch_view:map("n", { "l", "", "" }, function() - vim.api.nvim_set_current_win(log_view:winid()) - end, map_options) + + local log_handlers = { + exit = exit_fn, + refresh = update_fn, + focus_branch = function() + vim.api.nvim_set_current_win(branch_view:winid()) + end, + quick_jump_down = "2j", + quick_jump_up = "2k", + copy_oid = function() + local commit, _ = log_view:get_commit() + if commit then + vim.fn.setreg("0", commit.oid) + end + end, + copy_oid_clipboard = function() + local commit, _ = log_view:get_commit() + if commit then + vim.fn.setreg("+", commit.oid) + end + end, + } + local user_graph_keymaps = fugit2_config.get_keymaps() + keymaps.bind(log_view, "graph_log", log_handlers, user_graph_keymaps.graph_log, map_options) + + local branch_handlers = { + exit = exit_fn, + refresh = update_fn, + focus_log = function() + vim.api.nvim_set_current_win(log_view:winid()) + end, + } + keymaps.bind(branch_view, "graph_branch", branch_handlers, user_graph_keymaps.graph_branch, map_options) -- move cursor branch_view:on(event.CursorMoved, function() @@ -499,21 +526,6 @@ function GitGraph:setup_handlers() end end) - -- copy commit id - log_view:map("n", "yy", function() - local commit, _ = log_view:get_commit() - if commit then - vim.fn.setreg("0", commit.oid) - end - end, map_options) - - log_view:map("n", "yc", function() - local commit, _ = log_view:get_commit() - if commit then - vim.fn.setreg("+", commit.oid) - end - end, map_options) - -- log lazy load handling log_view:on(event.WinScrolled, function(ev) local winid = tonumber(ev.file or ev.match) diff --git a/lua/fugit2/view/git_pick.lua b/lua/fugit2/view/git_pick.lua index 3e64479..4ebc0a3 100644 --- a/lua/fugit2/view/git_pick.lua +++ b/lua/fugit2/view/git_pick.lua @@ -5,7 +5,9 @@ local NuiLayout = require "nui.layout" local NuiText = require "nui.text" local GitGraph = require "fugit2.view.git_graph" +local fugit2_config = require "fugit2.config" local fzf = require "fugit2.core.fzf" +local keymaps = require "fugit2.view.keymaps" local ENTITY = GitGraph.ENTITY local BRANCH_WINDOW_WIDTH = 36 @@ -164,35 +166,30 @@ function GitPick:setup_handlers() local input = self._views.input local branch_view = self._views.branch - input:map("i", "", function() - local pos = branch_view:get_cursor() - branch_view:set_cursor(pos[1] + 1, pos[2]) - - local node, linenr = branch_view:get_child_node_linenr() - if node and linenr and linenr ~= states.last_branch_linenr then - states.last_branch_linenr = linenr - states.last_ref = node.id - self:update_log(node.id) - self._views.log:render() - end - end, opts) - - input:map("i", "", function() - local pos = branch_view:get_cursor() - branch_view:set_cursor(pos[1] - 1, pos[2]) - - local node, linenr = branch_view:get_child_node_linenr() - if node and linenr and linenr ~= states.last_branch_linenr then - states.last_branch_linenr = linenr - states.last_ref = node.id - self:update_log(node.id) - self._views.log:render() + local move_fn = function(delta) + return function() + local pos = branch_view:get_cursor() + branch_view:set_cursor(pos[1] + delta, pos[2]) + + local node, linenr = branch_view:get_child_node_linenr() + if node and linenr and linenr ~= states.last_branch_linenr then + states.last_branch_linenr = linenr + states.last_ref = node.id + self:update_log(node.id) + self._views.log:render() + end end - end, opts) + end - input:map("i", { "", "" }, function() - self:unmount() - end, opts) + local user_input_keymaps = fugit2_config.get_keymaps "pick" + local input_handlers = { + exit = function() + self:unmount() + end, + next_item = move_fn(1), + prev_item = move_fn(-1), + } + keymaps.bind(input, "pick", input_handlers, user_input_keymaps, opts) end GitPick.ENTITY = ENTITY diff --git a/lua/fugit2/view/git_rebase.lua b/lua/fugit2/view/git_rebase.lua index 1b70c93..19a5e9c 100644 --- a/lua/fugit2/view/git_rebase.lua +++ b/lua/fugit2/view/git_rebase.lua @@ -10,6 +10,8 @@ local event = require("nui.utils.autocmd").event local LogView = require "fugit2.view.components.commit_log_view" local Menu = require "fugit2.view.components.menus" +local fugit2_config = require "fugit2.config" +local keymaps = require "fugit2.view.keymaps" local git2 = require "fugit2.core.git2" local git_rebase_helper = require "fugit2.core.git_rebase_helper" @@ -354,16 +356,20 @@ function RebaseView:_init_input_popup() self.layout:update(self.boxes.main) end - input_popup:map("n", { "", "q" }, exit_fn, opts) - input_popup:map("i", "", function() - vim.cmd.stopinsert() - exit_fn() - end, opts) - input_popup:map("n", "", enter_fn, opts) - input_popup:map("i", "", function() - vim.cmd.stopinsert() - enter_fn() - end, opts) + local user_input_keymaps = fugit2_config.get_keymaps "input" + local input_handlers = { + exit = exit_fn, + exit_insert = function() + vim.cmd.stopinsert() + exit_fn() + end, + enter = enter_fn, + enter_insert = function() + vim.cmd.stopinsert() + enter_fn() + end, + } + keymaps.bind(input_popup, "input", input_handlers, user_input_keymaps, opts) return input_popup end @@ -545,24 +551,20 @@ function RebaseView:rebase_start() -- remove mapping local commit_view = self.views.commits - commit_view:unmap("n", { - "r", - "w", - "x", - "d", - "b", - "e", - "s", - "f", - "p", - "gj", - "", - "gk", - "", - }) - commit_view:map("n", "", function() - self:rebase_continue() - end, { noremap = true, nowait = true }) + local user_rebase_keymaps = fugit2_config.get_keymaps "rebase" + for action in pairs(keymaps.defaults.rebase) do + local keys = keymaps.resolve_keys("rebase", action, user_rebase_keymaps) + if keys and keys ~= false then + commit_view:unmap("n", keys) + end + end + + local continue_keys = keymaps.resolve_keys("rebase", "continue", user_rebase_keymaps) + if continue_keys and continue_keys ~= false then + commit_view:map("n", continue_keys, function() + self:rebase_continue() + end, { noremap = true, nowait = true }) + end -- call rebase self:rebase_continue() @@ -1012,53 +1014,6 @@ function RebaseView:setup_handlers() commit_view:render() end - -- drop commit - commit_view:map("n", { "x", "d" }, function() - action_fn(RebaseAction.DROP) - end, opts) - - -- break commit - commit_view:map("n", "b", function() - action_fn(RebaseAction.BREAK) - end, opts) - - -- edit commit - if not self._git.inmemory then - commit_view:map("n", "e", function() - action_fn(RebaseAction.EDIT) - end, opts) - else - commit_view:map("n", "e", function() - notifier.warn "Inmemory rebase doens't not support EDIT!" - end, opts) - end - - --squash commit - commit_view:map("n", "s", function() - action_fn(RebaseAction.SQUASH) - end, opts) - commit_view:map("v", "s", function() - fixup_fn(true) - end, opts) - - --fixup - commit_view:map("n", "f", function() - action_fn(RebaseAction.FIXUP) - end, opts) - commit_view:map("v", "f", function() - fixup_fn(false) - end, opts) - - -- reword - commit_view:map("n", { "r", "w" }, function() - action_fn(RebaseAction.REWORD) - end, opts) - - -- pick - commit_view:map("n", "p", function() - action_fn(RebaseAction.PICK) - end, opts) - -- Reorder actions local reorder_fn = function(is_down) local _, commit_idx = commit_view:get_commit() @@ -1105,26 +1060,58 @@ function RebaseView:setup_handlers() commit_view:render() end - commit_view:map("n", { "gj", "" }, function() - reorder_fn(true) - end, opts) - - commit_view:map("n", { "gk", "" }, function() - reorder_fn(false) - end, opts) - - -- Move cursor - commit_view:map("n", "j", "2j", opts) - commit_view:map("n", "k", "2k", opts) - commit_view:map("v", "j", "2j", opts) - commit_view:map("v", "k", "2k", opts) - - commit_view:map("n", "", function() - self:rebase_start() - end, opts) - commit_view:map("n", { "", "q" }, function() - self:unmount() - end, opts) + -- commit view keymaps + local commit_handlers = { + exit = function() + self:unmount() + end, + start = function() + self:rebase_start() + end, + drop = function() + action_fn(RebaseAction.DROP) + end, + break_commit = function() + action_fn(RebaseAction.BREAK) + end, + edit = function() + if not self._git.inmemory then + action_fn(RebaseAction.EDIT) + else + notifier.warn "Inmemory rebase doens't not support EDIT!" + end + end, + squash = function() + action_fn(RebaseAction.SQUASH) + end, + fixup = function() + action_fn(RebaseAction.FIXUP) + end, + reword = function() + action_fn(RebaseAction.REWORD) + end, + pick = function() + action_fn(RebaseAction.PICK) + end, + squash_visual = function() + fixup_fn(true) + end, + fixup_visual = function() + fixup_fn(false) + end, + move_down = function() + reorder_fn(true) + end, + move_up = function() + reorder_fn(false) + end, + quick_jump_down = "2j", + quick_jump_up = "2k", + quick_jump_down_visual = "2j", + quick_jump_up_visual = "2k", + } + local user_rebase_keymaps = fugit2_config.get_keymaps "rebase" + keymaps.bind(commit_view, "rebase", commit_handlers, user_rebase_keymaps, opts) end ---Registers a callback to be called after rebase completes successfully. diff --git a/lua/fugit2/view/git_status.lua b/lua/fugit2/view/git_status.lua index e29675e..df4f7cf 100644 --- a/lua/fugit2/view/git_status.lua +++ b/lua/fugit2/view/git_status.lua @@ -20,6 +20,7 @@ local fugit2_config = require "fugit2.config" local git2 = require "fugit2.core.git2" local git_gpg = require "fugit2.core.git_gpg" local git_hooks = require "fugit2.core.git_hooks" +local keymaps = require "fugit2.view.keymaps" local notifier = require "fugit2.notifier" local utils = require "fugit2.utils" @@ -590,55 +591,15 @@ function GitStatus:_init_patch_views() self:focus_file() vim.api.nvim_feedkeys("q", "m", true) end - patch_unstaged:map("n", { "q", "" }, exit_fn, opts) self._prompts.discard_hunk_confirm = UI.Confirm(self.ns_id, NuiLine { NuiText "󰮈 Discard this hunk?" }) self._prompts.discard_line_confirm = UI.Confirm(self.ns_id, NuiLine { NuiText "󰮈 Discard these lines?" }) -- Commit menu local commit_menu_handler = self:_menu_handlers(Menu.COMMIT) - patch_unstaged:map("n", "c", commit_menu_handler, opts) - patch_staged:map("n", "c", commit_menu_handler, opts) - - -- Diff menu - -- local diff_menu_handler = self:_menu_handlers(Menu.DIFF) - -- patch_unstaged:map("n", "d", diff_menu_handler, opts) - -- patch_staged:map("n", "d", diff_menu_handler, opts) -- Branch menu local branch_menu_handler = self:_menu_handlers(Menu.BRANCH) - patch_unstaged:map("n", "b", branch_menu_handler, opts) - patch_staged:map("n", "b", branch_menu_handler, opts) - - -- [h]: move left - patch_unstaged:map("n", "h", function() - self:focus_file() - end, opts) - patch_staged:map("n", "h", function() - if states.patch_unstaged_shown then - patch_unstaged:focus() - else - self:focus_file() - end - end, opts) - - -- [l]: move right - patch_unstaged.popup:map("n", "l", function() - if states.patch_staged_shown then - patch_staged:focus() - else - vim.cmd "normal! l" - end - end, opts) - - -- [=]: turn off - local turn_off_patch_fn = function() - self:focus_file() - vim.api.nvim_feedkeys("=", "m", true) - end - patch_unstaged:map("n", "=", turn_off_patch_fn, opts) - patch_staged:map("n", "=", turn_off_patch_fn, opts) local diff_apply_fn = function(diff_str, is_index) local diff, err = git2.Diff.from_buffer(diff_str) @@ -687,23 +648,7 @@ function GitStatus:_init_patch_views() end end - -- [-]/[s]: Stage handling - patch_unstaged:map("n", { "-", "s" }, function() - local diff_str = patch_unstaged:get_diff_hunk() - if not diff_str then - notifier.error "Failed to get hunk" - return - end - - if diff_apply_fn(diff_str, true) == 0 then - local node, _ = tree:get_child_node_linenr() - if node then - diff_update_fn(node) - end - end - end, opts) - - -- [x]/[d]: Discard handling + -- discard confirmations self._prompts.discard_hunk_confirm:on_yes(function() local diff_str = patch_unstaged:get_diff_hunk_reversed() if not diff_str then @@ -721,77 +666,6 @@ function GitStatus:_init_patch_views() end end end) - patch_unstaged:map("n", { "d", "x" }, function() - self._prompts.discard_hunk_confirm:show() - end, opts) - - -- [-]/[u]: Unstage handling - patch_staged:map("n", { "-", "u" }, function() - local node, _ = tree:get_child_node_linenr() - if not node then - return - end - - local err = 0 - if node.istatus == "A" then - err = self.repo:reset_default { node.id } - else - local diff_str = patch_staged:get_diff_hunk_reversed() - if not diff_str then - notifier.error "Failed to get revere hunk" - return - end - err = diff_apply_fn(diff_str, true) - end - - if err == 0 then - diff_update_fn(node) - end - end, opts) - - -- [-]/[s]: Visual selected staging - patch_unstaged:map("v", { "-", "s" }, function() - local cursor_start = vim.fn.getpos("v")[2] - local cursor_end = vim.fn.getpos(".")[2] - - local diff_str = patch_unstaged:get_diff_hunk_range(cursor_start, cursor_end) - if not diff_str then - -- do nothing - return - end - - vim.api.nvim_feedkeys(utils.KEY_ESC, "n", false) - - if diff_apply_fn(diff_str, true) == 0 then - local node, _ = tree:get_child_node_linenr() - if node then - diff_update_fn(node) - end - end - end, opts) - - -- [-]/[u]: Visual selected unstage - patch_staged:map("v", { "-", "u" }, function() - local cursor_start = vim.fn.getpos("v")[2] - local cursor_end = vim.fn.getpos(".")[2] - - local diff_str = patch_staged:get_diff_hunk_range_reversed(cursor_start, cursor_end) - if not diff_str then - -- do nothing - return - end - - vim.api.nvim_feedkeys(utils.KEY_ESC, "n", false) - - if diff_apply_fn(diff_str, true) == 0 then - local node, _ = tree:get_child_node_linenr() - if node then - diff_update_fn(node) - end - end - end, opts) - - -- [d]/[x]: Visual selected discard self._prompts.discard_line_confirm:on_yes(function() local cursor_start = vim.fn.getpos("v")[2] local cursor_end = vim.fn.getpos(".")[2] @@ -813,9 +687,6 @@ function GitStatus:_init_patch_views() end end end) - patch_unstaged:map("v", { "d", "x" }, function() - self._prompts.discard_line_confirm:show() - end, opts) -- Enter to jump to file local jump_file_fn = function(v) @@ -826,12 +697,133 @@ function GitStatus:_init_patch_views() open_file(self._git.path, node.id, linenr) end end - patch_unstaged:map("n", "", function() - jump_file_fn(patch_unstaged) - end, opts) - patch_staged:map("n", "", function() - jump_file_fn(patch_staged) - end, opts) + + -- [=]: turn off + local turn_off_patch_fn = function() + self:focus_file() + vim.api.nvim_feedkeys("=", "m", true) + end + + -- patch_unstaged keymaps + local patch_unstaged_handlers = { + exit = exit_fn, + menu_commit = commit_menu_handler, + menu_branch = branch_menu_handler, + focus_file_tree = function() + self:focus_file() + end, + focus_staged = function() + if states.patch_staged_shown then + patch_staged:focus() + else + vim.cmd "normal! l" + end + end, + toggle_off = turn_off_patch_fn, + stage_hunk = function() + local diff_str = patch_unstaged:get_diff_hunk() + if not diff_str then + notifier.error "Failed to get hunk" + return + end + + if diff_apply_fn(diff_str, true) == 0 then + local node, _ = tree:get_child_node_linenr() + if node then + diff_update_fn(node) + end + end + end, + discard_hunk = function() + self._prompts.discard_hunk_confirm:show() + end, + stage_visual = function() + local cursor_start = vim.fn.getpos("v")[2] + local cursor_end = vim.fn.getpos(".")[2] + + local diff_str = patch_unstaged:get_diff_hunk_range(cursor_start, cursor_end) + if not diff_str then + -- do nothing + return + end + + vim.api.nvim_feedkeys(utils.KEY_ESC, "n", false) + + if diff_apply_fn(diff_str, true) == 0 then + local node, _ = tree:get_child_node_linenr() + if node then + diff_update_fn(node) + end + end + end, + discard_visual = function() + self._prompts.discard_line_confirm:show() + end, + jump_file = function() + jump_file_fn(patch_unstaged) + end, + } + keymaps.bind(patch_unstaged, "patch_unstaged", patch_unstaged_handlers, self.opts.keymaps.patch_unstaged, opts) + + -- patch_staged keymaps + local patch_staged_handlers = { + exit = exit_fn, + menu_commit = commit_menu_handler, + menu_branch = branch_menu_handler, + focus_file_tree = function() + if states.patch_unstaged_shown then + patch_unstaged:focus() + else + self:focus_file() + end + end, + toggle_off = turn_off_patch_fn, + unstage_hunk = function() + local node, _ = tree:get_child_node_linenr() + if not node then + return + end + + local err = 0 + if node.istatus == "A" then + err = self.repo:reset_default { node.id } + else + local diff_str = patch_staged:get_diff_hunk_reversed() + if not diff_str then + notifier.error "Failed to get revere hunk" + return + end + err = diff_apply_fn(diff_str, true) + end + + if err == 0 then + diff_update_fn(node) + end + end, + unstage_visual = function() + local cursor_start = vim.fn.getpos("v")[2] + local cursor_end = vim.fn.getpos(".")[2] + + local diff_str = patch_staged:get_diff_hunk_range_reversed(cursor_start, cursor_end) + if not diff_str then + -- do nothing + return + end + + vim.api.nvim_feedkeys(utils.KEY_ESC, "n", false) + + if diff_apply_fn(diff_str, true) == 0 then + local node, _ = tree:get_child_node_linenr() + if node then + diff_update_fn(node) + end + end + end, + jump_file = function() + jump_file_fn(patch_staged) + end, + } + keymaps.bind(patch_staged, "patch_staged", patch_staged_handlers, self.opts.keymaps.patch_staged, opts) end -- Read git config @@ -1897,12 +1889,6 @@ function GitStatus:_init_input_popup() local opts = { noremap = true, nowait = true } local states = self._states - input_popup:map("n", { "q", "" }, function() - self:hide_input(false) - end, opts) - - input_popup:map("i", "", "q", { nowait = true }) - local input_enter_fn = function() local message = vim.trim(table.concat(vim.api.nvim_buf_get_lines(self.input_popup.bufnr, 0, -1, true), "\n")) if states.commit_mode == CommitMode.CREATE then @@ -1915,11 +1901,23 @@ function GitStatus:_init_input_popup() states.commit_args = nil end - input_popup:map("n", "", input_enter_fn, opts) - input_popup:map("i", "", function() - vim.cmd.stopinsert() - input_enter_fn() - end, opts) + + local user_input_keymaps = fugit2_config.get_keymaps "input" + local input_handlers = { + exit = function() + self:hide_input(false) + end, + exit_insert = function() + vim.cmd.stopinsert() + self:hide_input(false) + end, + enter = input_enter_fn, + enter_insert = function() + vim.cmd.stopinsert() + input_enter_fn() + end, + } + keymaps.bind(input_popup, "input", input_handlers, user_input_keymaps, opts) return input_popup end @@ -2022,7 +2020,9 @@ function GitStatus:_init_branch_input() local opts = { nowait = true, noremap = true } vim.fn.prompt_setinterrupt(input.bufnr, exit_fn) - input:map("n", { "", "q" }, exit_fn, opts) + keymaps.bind(input, "input", { + exit = exit_fn, + }, fugit2_config.get_keymaps "input", opts) return input end @@ -2813,108 +2813,156 @@ function GitStatus:setup_handlers() end -- exit - file_tree:map("n", { "q", "" }, exit_fn, map_options) - file_tree:map("i", "", exit_fn, map_options) - commit_log:map("n", { "q", "" }, exit_fn, map_options) file_tree:on(event.BufUnload, function() self.closed = true end) -- popup:on(event.BufLeave, exit_fn) - -- refresh - file_tree:map("n", "g", function() - self:update_then_render() - end, map_options) - - -- Rebase menu - file_tree:map("n", "r", self:_menu_handlers(Menu.REBASE), map_options) - - -- collapse - file_tree:map("n", "h", function() - local node = file_tree.tree:get_node() - - if node and node:collapse() then - file_tree:render() - end - end, map_options) - - -- collapse all - file_tree:map("n", "H", function() - local updated = false + -- file tree handlers + local file_tree_handlers = { + exit = exit_fn, + exit_insert = exit_fn, + refresh = function() + self:update_then_render() + end, + menu_rebase = self:_menu_handlers(Menu.REBASE), + collapse = function() + local node = file_tree.tree:get_node() - for _, node in pairs(file_tree.tree.nodes.by_id) do - updated = node:collapse() or updated - end + if node and node:collapse() then + file_tree:render() + end + end, + collapse_all = function() + local updated = false - if updated then - file_tree:render() - end - end, map_options) + for _, node in pairs(file_tree.tree.nodes.by_id) do + updated = node:collapse() or updated + end - -- Expand and move right - file_tree:map("n", "l", function() - local node = file_tree.tree:get_node() - if node then - if node:expand() then + if updated then file_tree:render() end - if not node:has_children() and states.side_panel == SidePanel.PATCH_VIEW then - if states.patch_unstaged_shown then - self._views.patch_unstaged:focus() - elseif states.patch_staged_shown then - self._views.patch_staged:focus() + end, + expand = function() + local node = file_tree.tree:get_node() + if node then + if node:expand() then + file_tree:render() + end + if not node:has_children() and states.side_panel == SidePanel.PATCH_VIEW then + if states.patch_unstaged_shown then + self._views.patch_unstaged:focus() + elseif states.patch_staged_shown then + self._views.patch_staged:focus() + end end end - end - end, map_options) - - -- Move to commit view - file_tree:map("n", { "J", "" }, function() - if states.side_panel == SidePanel.NONE then - commit_log:focus() - end - end, map_options) - file_tree:map("n", "K", "", map_options) - - -- Move back to file popup - commit_log:map("n", { "K", "" }, function() - if states.side_panel == SidePanel.NONE then - file_tree:focus() - end - end, map_options) - commit_log:map("n", "J", "", map_options) - - -- Quick jump commit - commit_log:map("n", "j", "2j", map_options) - commit_log:map("n", "k", "2k", map_options) - - -- copy commit id - commit_log:map("n", "yy", function() - local commit, _ = commit_log:get_commit() - if commit then - vim.api.nvim_call_function("setreg", { '"', commit.oid }) - end - end, map_options) - - commit_log:map("n", "yc", function() - local commit, _ = commit_log:get_commit() - if commit then - vim.api.nvim_call_function("setreg", { "+", commit.oid }) - end - end, map_options) - - -- expand all - file_tree:map("n", "L", function() - local updated = false + end, + focus_commit_log = function() + if states.side_panel == SidePanel.NONE then + commit_log:focus() + end + end, + focus_commit_log_disable = "", + expand_all = function() + local updated = false - for _, node in pairs(file_tree.tree.nodes.by_id) do - updated = node:expand() or updated - end + for _, node in pairs(file_tree.tree.nodes.by_id) do + updated = node:expand() or updated + end - if updated then - file_tree:render() - end - end, map_options) + if updated then + file_tree:render() + end + end, + toggle_patch = function() + if states.side_panel == SidePanel.PATCH_VIEW then + self:hide_patch_view() + elseif states.side_panel == SidePanel.NONE then + self:show_patch_for_current_file() + end + end, + open_file = function() + local node = file_tree.tree:get_node() + if node and node:has_children() then + if node:is_expanded() then + node:collapse() + else + node:expand() + end + file_tree:render() + elseif node then + exit_fn() + open_file(self._git.path, node.id) + end + end, + stage_all = utils.wrap(GitStatus._index_add_reset_discard_all, self, TreeBase.IndexAction.ADD_RESET), + stage_toggle = utils.wrap(GitStatus._index_add_reset_discard, self, TreeBase.IndexAction.ADD_RESET), + stage_file = utils.wrap(GitStatus._index_add_reset_discard, self, TreeBase.IndexAction.ADD), + unstage_file = utils.wrap(GitStatus._index_add_reset_discard, self, TreeBase.IndexAction.RESET), + discard = function() + local node = file_tree.tree:get_node() + if node then + self._prompts.discard_confirm:set_text(NuiLine { + NuiText("󰮈 Discard ", "Fugit2Unstaged"), + NuiText(node.id, "Fugit2MenuHead"), + NuiText "?", + }) + end + self._prompts.discard_confirm:show() + end, + write_index = function() + if self.index:write() == 0 then + notifier.info "Index saved" + end + end, + stage_toggle_visual = utils.wrap(GitStatus._index_add_reset_discard_visual, self, TreeBase.IndexAction.ADD_RESET), + stage_visual = utils.wrap(GitStatus._index_add_reset_discard_visual, self, TreeBase.IndexAction.ADD), + unstage_visual = utils.wrap(GitStatus._index_add_reset_discard_visual, self, TreeBase.IndexAction.RESET), + discard_visual = function() + self._prompts.discard_confirm:set_text(NuiLine { + NuiText("󰮈 Discard selected changes?", "Fugit2Unstaged"), + }) + self._prompts.discard_confirm:show() + end, + menu_commit = self:_menu_handlers(Menu.COMMIT), + menu_diff = self:_menu_handlers(Menu.DIFF), + menu_branch = self:_menu_handlers(Menu.BRANCH), + menu_push = self:_menu_handlers(Menu.PUSH), + menu_fetch = self:_menu_handlers(Menu.FETCH), + menu_pull = self:_menu_handlers(Menu.PULL), + menu_forge = self:_menu_handlers(Menu.FORGE), + menu_stash = self:_menu_handlers(Menu.STASH), + menu_cherry_pick = self:_menu_handlers(Menu.CHERRY_PICK), + } + keymaps.bind(file_tree, "file_tree", file_tree_handlers, self.opts.keymaps.file_tree, map_options) + + -- commit log handlers + local commit_log_handlers = { + exit = exit_fn, + focus_file_tree = function() + if states.side_panel == SidePanel.NONE then + file_tree:focus() + end + end, + focus_file_tree_disable = "", + quick_jump_down = "2j", + quick_jump_up = "2k", + copy_oid = function() + local commit, _ = commit_log:get_commit() + if commit then + vim.api.nvim_call_function("setreg", { '"', commit.oid }) + end + end, + copy_oid_clipboard = function() + local commit, _ = commit_log:get_commit() + if commit then + vim.api.nvim_call_function("setreg", { "+", commit.oid }) + end + end, + } + keymaps.bind(commit_log, "commit_log", commit_log_handlers, self.opts.keymaps.commit_log, map_options) -- Patch view & move cursor states.last_patch_line = -1 @@ -2934,122 +2982,22 @@ function GitStatus:setup_handlers() end end) - ---- Toggle patch views - file_tree:map("n", "=", function() - if states.side_panel == SidePanel.PATCH_VIEW then - self:hide_patch_view() - elseif states.side_panel == SidePanel.NONE then - self:show_patch_for_current_file() - end - end, map_options) - - ---- Enter: collapse expand toggle, move to file buffer and diff - file_tree:map("n", "", function() - local node = file_tree.tree:get_node() - if node and node:has_children() then - if node:is_expanded() then - node:collapse() - else - node:expand() - end - file_tree:render() - -- elseif states.patch_shown then - -- if states.patch_unstaged_shown then - -- self._patch_unstaged:focus() - -- elseif states.patch_staged_shown then - -- self._patch_staged:focus() - -- end - elseif node then - exit_fn() - open_file(self._git.path, node.id) - end - end, map_options) - - file_tree:map( - "n", - "a", - utils.wrap(GitStatus._index_add_reset_discard_all, self, TreeBase.IndexAction.ADD_RESET), - map_options - ) - - --- Space/[-]: Add or remove index - file_tree:map( - "n", - { "-", "" }, - utils.wrap(GitStatus._index_add_reset_discard, self, TreeBase.IndexAction.ADD_RESET), - map_options - ) - - --- [s]: stage file - file_tree:map("n", "s", utils.wrap(GitStatus._index_add_reset_discard, self, TreeBase.IndexAction.ADD), map_options) - - --- [u]: unstage file - file_tree:map("n", "u", utils.wrap(GitStatus._index_add_reset_discard, self, TreeBase.IndexAction.RESET), map_options) - - --- [D]/[x]: discard file changes - -- file_tree:map("n", {"D", "x"}, self:index_add_reset_handler(false, false, false, true), map_options) + -- Write index self._prompts.discard_confirm:on_yes( utils.wrap(GitStatus._index_add_reset_discard, self, TreeBase.IndexAction.DISCARD) ) - file_tree:map("n", { "D", "x" }, function() - local node = file_tree.tree:get_node() - if node then - self._prompts.discard_confirm:set_text(NuiLine { - NuiText("󰮈 Discard ", "Fugit2Unstaged"), - NuiText(node.id, "Fugit2MenuHead"), - NuiText "?", - }) - end - self._prompts.discard_confirm:show() - end, map_options) - - --- Visual Space/[-]: Add remove for range - file_tree:map( - "v", - { "-", "" }, - utils.wrap(GitStatus._index_add_reset_discard_visual, self, TreeBase.IndexAction.ADD_RESET), - map_options - ) - - --- Visual [s]: stage files in range - file_tree:map( - "v", - "s", - utils.wrap(GitStatus._index_add_reset_discard_visual, self, TreeBase.IndexAction.ADD), - map_options - ) - - --- Visual [u]: unstage files in range - file_tree:map( - "v", - "u", - utils.wrap(GitStatus._index_add_reset_discard_visual, self, TreeBase.IndexAction.RESET), - map_options - ) - - --- Visual [x][d]: discard files in range - file_tree:map("v", { "x", "d" }, function() - self._prompts.discard_confirm:set_text(NuiLine { - NuiText("󰮈 Discard selected changes?", "Fugit2Unstaged"), - }) - self._prompts.discard_confirm:show() - end, map_options) - - ---- Write index - file_tree:map("n", "w", function() - if self.index:write() == 0 then - notifier.info "Index saved" - end - end, map_options) -- Command popup - self.command_popup:map("n", { "q", "" }, function() - self:quit_command() - end, map_options) + keymaps.bind(self.command_popup, "input", { + exit = function() + self:quit_command() + end, + }, fugit2_config.get_keymaps "input", map_options) -- Amend confirm self._prompts.amend_confirm:on_yes(self:amend_confirm_yes_handler()) + -- Deprecated: direct file tree maps (legacy file_tree_maps.direct) local action_enum_remap = { commit = Menu.COMMIT, diff = Menu.DIFF, @@ -3063,12 +3011,17 @@ function GitStatus:setup_handlers() } local keymaps_used = {} - local tree_keymaps = self.opts.file_tree_maps.menu - for action, key in pairs(tree_keymaps) do - if action_enum_remap[action] then - local action_enum = action_enum_remap[action] - file_tree:map("n", key, self:_menu_handlers(action_enum), map_options) - keymaps_used[key] = true + local ft_keymaps = self.opts.keymaps.file_tree or {} + for action in pairs(action_enum_remap) do + local registry_action = "menu_" .. action + local def = keymaps.get("file_tree", registry_action) + local keys = ft_keymaps[registry_action] or (def and def.keys) or nil + if type(keys) == "table" then + for _, k in ipairs(keys) do + keymaps_used[k] = true + end + elseif keys then + keymaps_used[keys] = true end end diff --git a/lua/fugit2/view/keymaps.lua b/lua/fugit2/view/keymaps.lua new file mode 100644 index 0000000..c9150ee --- /dev/null +++ b/lua/fugit2/view/keymaps.lua @@ -0,0 +1,274 @@ +--- Central keymap registry for all Fugit2 views. +--- +--- This module is the single source of truth for default keybindings. Views bind +--- their keymaps by calling `bind` with a handlers table; user overrides from +--- `opts.keymaps` are resolved here. The same registry feeds the help menu +--- (see docs/feature/help-menu.md). + +---@class Fugit2KeymapDef +---@field keys string|string[] Default key binding(s). An empty string "" disables the +--- mapping (bound as a no-op). `false` disables entirely (not bound). +---@field desc string Human-readable description for the help menu. +---@field mode string Mapping mode, defaults to "n". + +---@alias Fugit2KeymapHandlers table action -> handler. +--- A handler of "" maps the keys to a no-op (clears the mapping). + +local M = {} + +---@type table> +M.defaults = { + file_tree = { + exit = { keys = { "q", "" }, desc = "Close window" }, + exit_insert = { keys = "", mode = "i", desc = "Close window" }, + refresh = { keys = "g", desc = "Refresh status" }, + menu_rebase = { keys = "r", desc = "Open rebase menu" }, + collapse = { keys = "h", desc = "Collapse folder" }, + collapse_all = { keys = "H", desc = "Collapse all folders" }, + expand = { keys = "l", desc = "Expand folder / open patch view" }, + expand_all = { keys = "L", desc = "Expand all folders" }, + focus_commit_log = { keys = { "J", "" }, desc = "Move to commit log" }, + focus_commit_log_disable = { keys = "K", desc = "No-op: disable mirror pane move" }, + toggle_patch = { keys = "=", desc = "Toggle patch view" }, + open_file = { keys = "", desc = "Open file" }, + stage_all = { keys = "a", desc = "Stage/unstage all" }, + stage_toggle = { keys = { "-", "" }, desc = "Stage/unstage file" }, + stage_file = { keys = "s", desc = "Stage file" }, + unstage_file = { keys = "u", desc = "Unstage file" }, + discard = { keys = { "D", "x" }, desc = "Discard changes" }, + write_index = { keys = "w", desc = "Write index" }, + stage_toggle_visual = { keys = { "-", "" }, mode = "v", desc = "Stage/unstage selection" }, + stage_visual = { keys = "s", mode = "v", desc = "Stage selection" }, + unstage_visual = { keys = "u", mode = "v", desc = "Unstage selection" }, + discard_visual = { keys = { "x", "d" }, mode = "v", desc = "Discard selection" }, + menu_commit = { keys = "c", desc = "Open commit menu" }, + menu_diff = { keys = "d", desc = "Open diff menu" }, + menu_branch = { keys = "b", desc = "Open branch menu" }, + menu_push = { keys = "P", desc = "Open push menu" }, + menu_fetch = { keys = "f", desc = "Open fetch menu" }, + menu_pull = { keys = "p", desc = "Open pull menu" }, + menu_forge = { keys = "N", desc = "Open forge menu" }, + menu_stash = { keys = "z", desc = "Open stash menu" }, + menu_cherry_pick = { keys = "A", desc = "Open cherry-pick menu" }, + }, + commit_log = { + exit = { keys = { "q", "" }, desc = "Close window" }, + focus_file_tree = { keys = { "K", "" }, desc = "Move to file tree" }, + focus_file_tree_disable = { keys = "J", desc = "No-op: disable mirror pane move" }, + quick_jump_down = { keys = "j", desc = "Jump down (2 lines)" }, + quick_jump_up = { keys = "k", desc = "Jump up (2 lines)" }, + copy_oid = { keys = "yy", desc = "Copy commit id" }, + copy_oid_clipboard = { keys = "yc", desc = "Copy commit id to clipboard" }, + }, + patch_unstaged = { + exit = { keys = { "q", "" }, desc = "Close window" }, + menu_commit = { keys = "c", desc = "Open commit menu" }, + menu_branch = { keys = "b", desc = "Open branch menu" }, + focus_file_tree = { keys = "h", desc = "Move to file tree" }, + focus_staged = { keys = "l", desc = "Move to staged patch" }, + toggle_off = { keys = "=", desc = "Turn off patch view" }, + stage_hunk = { keys = { "-", "s" }, desc = "Stage hunk" }, + discard_hunk = { keys = { "d", "x" }, desc = "Discard hunk" }, + stage_visual = { keys = { "-", "s" }, mode = "v", desc = "Stage selection" }, + discard_visual = { keys = { "d", "x" }, mode = "v", desc = "Discard selection" }, + jump_file = { keys = "", desc = "Jump to file" }, + }, + patch_staged = { + exit = { keys = { "q", "" }, desc = "Close window" }, + menu_commit = { keys = "c", desc = "Open commit menu" }, + menu_branch = { keys = "b", desc = "Open branch menu" }, + focus_file_tree = { keys = "h", desc = "Move to file tree" }, + toggle_off = { keys = "=", desc = "Turn off patch view" }, + unstage_hunk = { keys = { "-", "u" }, desc = "Unstage hunk" }, + unstage_visual = { keys = { "-", "u" }, mode = "v", desc = "Unstage selection" }, + jump_file = { keys = "", desc = "Jump to file" }, + }, + rebase = { + exit = { keys = { "", "q" }, desc = "Abort / close" }, + start = { keys = "", desc = "Start rebase" }, + continue = { keys = "", desc = "Continue rebase" }, + drop = { keys = { "x", "d" }, desc = "Drop commit" }, + break_commit = { keys = "b", desc = "Break" }, + edit = { keys = "e", desc = "Edit commit" }, + squash = { keys = "s", desc = "Squash commit" }, + fixup = { keys = "f", desc = "Fixup commit" }, + reword = { keys = { "r", "w" }, desc = "Reword commit" }, + pick = { keys = "p", desc = "Pick commit" }, + squash_visual = { keys = "s", mode = "v", desc = "Squash commits" }, + fixup_visual = { keys = "f", mode = "v", desc = "Fixup commits" }, + move_down = { keys = { "gj", "" }, desc = "Move commit down" }, + move_up = { keys = { "gk", "" }, desc = "Move commit up" }, + quick_jump_down = { keys = "j", desc = "Jump down (2 lines)" }, + quick_jump_up = { keys = "k", desc = "Jump up (2 lines)" }, + quick_jump_down_visual = { keys = "j", mode = "v", desc = "Jump down (2 lines)" }, + quick_jump_up_visual = { keys = "k", mode = "v", desc = "Jump up (2 lines)" }, + }, + graph_log = { + exit = { keys = { "q", "" }, desc = "Close window" }, + refresh = { keys = "r", desc = "Refresh" }, + focus_branch = { keys = "h", desc = "Move to branch view" }, + quick_jump_down = { keys = "j", desc = "Jump down (2 lines)" }, + quick_jump_up = { keys = "k", desc = "Jump up (2 lines)" }, + copy_oid = { keys = "yy", desc = "Copy commit id" }, + copy_oid_clipboard = { keys = "yc", desc = "Copy commit id to clipboard" }, + }, + graph_branch = { + exit = { keys = { "q", "" }, desc = "Close window" }, + refresh = { keys = "r", desc = "Refresh" }, + focus_log = { keys = { "l", "", "" }, desc = "Move to commit log" }, + }, + graph_select = { + select_commit = { keys = { "", "" }, desc = "Select commit" }, + select_branch = { keys = { "", "" }, desc = "Select branch" }, + }, + diff = { + exit = { keys = { "q", "" }, desc = "Close window" }, + focus_pane = { keys = { "l", "" }, desc = "Focus diff pane" }, + stage_file = { keys = "s", desc = "Stage file" }, + unstage_file = { keys = "u", desc = "Unstage file" }, + stage_toggle = { keys = { "-", "" }, desc = "Stage/unstage file" }, + refresh = { keys = "r", desc = "Refresh" }, + }, + stash_list = { + exit = { keys = { "q", "" }, desc = "Close window" }, + apply = { keys = "a", desc = "Apply stash" }, + pop = { keys = "p", desc = "Pop stash" }, + drop = { keys = "d", desc = "Drop stash" }, + }, + pick = { + exit = { keys = { "", "" }, mode = "i", desc = "Close window" }, + next_item = { keys = "", mode = "i", desc = "Move to next item" }, + prev_item = { keys = "", mode = "i", desc = "Move to previous item" }, + }, + input = { + exit = { keys = { "q", "" }, desc = "Cancel input" }, + exit_insert = { keys = "", mode = "i", desc = "Cancel input" }, + enter = { keys = "", desc = "Confirm input" }, + enter_insert = { keys = "", mode = "i", desc = "Confirm input" }, + }, + confirm = { + exit = { keys = { "q", "n", "" }, desc = "No / close" }, + move_no = { keys = "l", desc = "Move to No" }, + move_yes = { keys = "h", desc = "Move to Yes" }, + toggle = { keys = "", desc = "Toggle Yes/No" }, + yes = { keys = "y", desc = "Confirm Yes" }, + yes_enter = { keys = "", desc = "Confirm Yes" }, + }, + blame = { + exit = { keys = { "q", "" }, desc = "Close window" }, + next_hunk = { keys = { "J", "]c" }, desc = "Next hunk" }, + prev_hunk = { keys = { "K", "[c" }, desc = "Previous hunk" }, + }, + blame_file = { + exit = { keys = { "q", "" }, desc = "Close window" }, + show_detail = { keys = "c", desc = "Toggle blame hunk detail" }, + }, + blame_popup = { + exit = { keys = { "q", "" }, desc = "Close blame detail" }, + }, + patch = { + next_hunk = { keys = "J", desc = "Next hunk" }, + prev_hunk = { keys = "K", desc = "Previous hunk" }, + }, +} + +---Resolves the effective key binding for an action. +---@param group string View group name. +---@param action string Action id. +---@return Fugit2KeymapDef? +function M.get(group, action) + local defs = M.defaults[group] + return defs and defs[action] or nil +end + +---Resolves the effective key(s) for an action given user overrides. +---@param group string View group name. +---@param action string Action id. +---@param user table? User overrides for the group. +---@return string|string[]|false|nil Effective keys; `false` means disabled. +function M.resolve_keys(group, action, user) + user = user or {} + local keys = user[action] + if keys == nil then + local def = M.get(group, action) + if def then + keys = def.keys + end + end + return keys +end + +---Binds every action in a group to a view. +--- +---Iterates `M.defaults[group]`. The effective keys come from `user[action]` when set +---(`false` disables the mapping entirely, `""` maps it to a no-op), otherwise the +---default. Actions with no handler in `handlers` are skipped. +---@param view table A NUI view exposing `map(mode, keys, fn, opts)`. +---@param group string View group name. +---@param handlers Fugit2KeymapHandlers action -> handler function or "" (no-op). +---@param user table? User overrides for the group. +---@param opts table? Mapping options, defaults to `{ noremap = true, nowait = true }`. +function M.bind(view, group, handlers, user, opts) + user = user or {} + opts = opts or { noremap = true, nowait = true } + + local defs = M.defaults[group] + if not defs then + return + end + + for action, def in pairs(defs) do + local keys = user[action] + if keys == nil then + keys = def.keys + end + + if keys ~= false and keys ~= nil then + local handler = handlers[action] + if handler ~= nil then + view:map(def.mode or "n", keys, handler, opts) + end + end + end +end + +---Binds every action in a group to a raw buffer via `nui.utils.keymap.set`. +---Used by views that do not mount NUI components (e.g. blame split view). +---@param bufnr integer Target buffer. +---@param group string View group name. +---@param handlers Fugit2KeymapHandlers action -> handler function or "" (no-op). +---@param user table? User overrides for the group. +---@param opts table? Mapping options, defaults to `{ noremap = true, nowait = true }`. +function M.bind_buf(bufnr, group, handlers, user, opts) + user = user or {} + opts = opts or { noremap = true, nowait = true } + local keymap = require "nui.utils.keymap" + + local defs = M.defaults[group] + if not defs then + return + end + + for action, def in pairs(defs) do + local keys = user[action] + if keys == nil then + keys = def.keys + end + + if keys ~= false and keys ~= nil then + local handler = handlers[action] + if handler ~= nil then + keymap.set(bufnr, def.mode or "n", keys, handler, opts) + end + end + end +end + +---Returns all keymap defs for a group, used by the help menu. +---@param group string View group name. +---@return table? action -> def +function M.defs(group) + return M.defaults[group] +end + +return M diff --git a/spec/fugit2/config_spec.lua b/spec/fugit2/config_spec.lua new file mode 100644 index 0000000..1015e45 --- /dev/null +++ b/spec/fugit2/config_spec.lua @@ -0,0 +1,66 @@ +local function fresh_config() + package.loaded["fugit2.config"] = nil + return require "fugit2.config" +end + +describe("config", function() + describe("merge", function() + it("merges partial args into defaults", function() + local config = fresh_config() + local cfg = config.merge { width = 120 } + assert.are.equal(120, cfg.width) + assert.are.equal("60%", cfg.height) + end) + + it("deep merges nested tables", function() + local config = fresh_config() + local cfg = config.merge { file_tree_maps = { menu = { commit = "C" } } } + assert.are.equal("C", cfg.file_tree_maps.menu.commit) + assert.are.equal("z", cfg.file_tree_maps.menu.stash) + end) + + it("translates legacy file_tree_maps.menu into keymaps.file_tree.menu_*", function() + local config = fresh_config() + local cfg = config.merge { + file_tree_maps = { menu = { commit = "C", stash = "Z" } }, + } + assert.are.equal("C", cfg.keymaps.file_tree.menu_commit) + assert.are.equal("Z", cfg.keymaps.file_tree.menu_stash) + end) + + it("does not translate defaults (they live in the keymap registry)", function() + local config = fresh_config() + local cfg = config.merge {} + assert.is_nil(cfg.keymaps.file_tree.menu_commit) + end) + + it("new keymaps take precedence over legacy file_tree_maps", function() + local config = fresh_config() + local cfg = config.merge { + file_tree_maps = { menu = { commit = "C" } }, + keymaps = { file_tree = { menu_commit = "X" } }, + } + assert.are.equal("X", cfg.keymaps.file_tree.menu_commit) + end) + + it("keeps user keymaps groups intact", function() + local config = fresh_config() + local cfg = config.merge { + keymaps = { + file_tree = { stage_file = "S", unstage_file = false }, + rebase = { drop = { "x", "d" } }, + }, + } + assert.are.equal("S", cfg.keymaps.file_tree.stage_file) + assert.are.equal(false, cfg.keymaps.file_tree.unstage_file) + assert.are.same({ "x", "d" }, cfg.keymaps.rebase.drop) + end) + + it("always populates keymaps.file_tree", function() + local config = fresh_config() + local cfg = config.merge {} + assert.is_not_nil(cfg.keymaps) + assert.is_not_nil(cfg.keymaps.file_tree) + end) + end) +end) diff --git a/spec/fugit2/view/keymaps_spec.lua b/spec/fugit2/view/keymaps_spec.lua new file mode 100644 index 0000000..1d0b2e7 --- /dev/null +++ b/spec/fugit2/view/keymaps_spec.lua @@ -0,0 +1,235 @@ +local keymaps = require "fugit2.view.keymaps" + +local ALL_GROUPS = { + "file_tree", + "commit_log", + "patch_unstaged", + "patch_staged", + "rebase", + "graph_log", + "graph_branch", + "graph_select", + "diff", + "stash_list", + "pick", + "input", + "confirm", + "blame", + "blame_file", + "blame_popup", + "patch", +} + +describe("keymaps", function() + describe("defaults", function() + it("defines all view groups", function() + for _, group in ipairs(ALL_GROUPS) do + assert.is_not_nil(keymaps.defaults[group], "missing group: " .. group) + end + end) + + it("every def has keys and desc", function() + for group, defs in pairs(keymaps.defaults) do + for action, def in pairs(defs) do + assert.is_not_nil(def.keys, string.format("%s.%s missing keys", group, action)) + assert.is_not_nil(def.desc, string.format("%s.%s missing desc", group, action)) + assert.is_true(def.mode == nil or def.mode == "n" or def.mode == "v" or def.mode == "i") + end + end + end) + + it("file_tree menu actions exist", function() + local file_tree = keymaps.defaults.file_tree + assert.are.equal("c", file_tree.menu_commit.keys) + assert.are.equal("A", file_tree.menu_cherry_pick.keys) + assert.are.equal("z", file_tree.menu_stash.keys) + assert.are.equal("r", file_tree.menu_rebase.keys) + end) + end) + + describe("get", function() + it("returns def for known action", function() + local def = keymaps.get("file_tree", "stage_file") + assert.are.equal("s", def.keys) + assert.is_not_nil(def.desc) + end) + + it("returns nil for unknown action", function() + assert.is_nil(keymaps.get("file_tree", "nope")) + end) + + it("returns nil for unknown group", function() + assert.is_nil(keymaps.get("nope", "stage_file")) + end) + end) + + describe("resolve_keys", function() + it("returns default when no user override", function() + assert.are.equal("s", keymaps.resolve_keys("file_tree", "stage_file", {})) + end) + + it("returns user override", function() + assert.are.equal("S", keymaps.resolve_keys("file_tree", "stage_file", { stage_file = "S" })) + end) + + it("returns table user override", function() + local keys = keymaps.resolve_keys("file_tree", "discard", { discard = { "X", "Y" } }) + assert.are.same({ "X", "Y" }, keys) + end) + + it("returns false when disabled", function() + assert.are.equal(false, keymaps.resolve_keys("file_tree", "stage_file", { stage_file = false })) + end) + + it("returns empty string when disabled via empty string", function() + assert.are.equal("", keymaps.resolve_keys("file_tree", "stage_file", { stage_file = "" })) + end) + + it("returns nil for unknown action", function() + assert.is_nil(keymaps.resolve_keys("file_tree", "nope", {})) + end) + end) + + describe("bind", function() + local calls = {} + + ---@type table + local mock_view = {} + + local function reset() + calls = {} + mock_view = { + map = function(_, mode, keys, handler, opts) + calls[#calls + 1] = { mode = mode, keys = keys, handler = handler, opts = opts } + end, + } + end + + before_each(reset) + + it("binds all handlers with defaults", function() + local handlers = { + stage_file = function() end, + unstage_file = function() end, + } + keymaps.bind(mock_view, "file_tree", handlers) + + local stage = vim.tbl_filter(function(call) + return call.keys == "s" + end, calls) + assert.are.equal(1, #stage) + assert.are.equal("n", stage[1].mode) + end) + + it("binds user overrides instead of defaults", function() + local handlers = { + stage_file = function() end, + } + keymaps.bind(mock_view, "file_tree", handlers, { stage_file = "S" }) + + local stage = vim.tbl_filter(function(call) + return call.keys == "S" + end, calls) + assert.are.equal(1, #stage) + local default_stage = vim.tbl_filter(function(call) + return call.keys == "s" + end, calls) + assert.are.equal(0, #default_stage) + end) + + it("skips actions disabled with false", function() + local handlers = { + stage_file = function() end, + } + keymaps.bind(mock_view, "file_tree", handlers, { stage_file = false }) + assert.are.equal(0, #calls) + end) + + it("binds no-op when handler is empty string", function() + local handlers = { + focus_commit_log_disable = "", + } + keymaps.bind(mock_view, "file_tree", handlers) + assert.are.equal(1, #calls) + assert.are.equal("", calls[1].handler) + end) + + it("skips actions without handlers", function() + local handlers = {} + keymaps.bind(mock_view, "file_tree", handlers, {}) + assert.are.equal(0, #calls) + end) + + it("does nothing for unknown group", function() + keymaps.bind(mock_view, "nope", {}) + assert.are.equal(0, #calls) + end) + + it("binds visual mode actions with mode v", function() + local handlers = { + stage_visual = function() end, + } + keymaps.bind(mock_view, "file_tree", handlers, {}) + local vis = vim.tbl_filter(function(call) + return call.mode == "v" + end, calls) + assert.are.equal(1, #vis) + assert.are.equal("s", vis[1].keys) + end) + + it("binds insert mode actions with mode i", function() + local handlers = { + exit_insert = function() end, + } + keymaps.bind(mock_view, "file_tree", handlers, {}) + local ins = vim.tbl_filter(function(call) + return call.mode == "i" + end, calls) + assert.are.equal(1, #ins) + end) + end) + + describe("bind_buf", function() + local calls = {} + + local function reset() + calls = {} + local keymap = require "nui.utils.keymap" + keymap.set = function(bufnr, mode, keys, handler, opts) + calls[#calls + 1] = { mode = mode, keys = keys, handler = handler, opts = opts } + end + end + + before_each(reset) + + it("delegates to nui keymap.set", function() + local handlers = { + exit = function() end, + } + keymaps.bind_buf(1, "blame", handlers, {}) + assert.are.equal(1, #calls) + assert.are.equal("n", calls[1].mode) + assert.are.same({ "q", "" }, calls[1].keys) + end) + + it("applies user overrides", function() + local handlers = { + exit = function() end, + } + keymaps.bind_buf(1, "blame", handlers, { exit = "Q" }) + assert.are.equal(1, #calls) + assert.are.equal("Q", calls[1].keys) + end) + end) + + describe("defs", function() + it("returns group defs", function() + assert.is_not_nil(keymaps.defs "file_tree") + assert.are.equal("s", keymaps.defs("file_tree").stage_file.keys) + end) + + it("returns nil for unknown group", function() + assert.is_nil(keymaps.defs "nope") + end) + end) +end) From 7fde472ce37580be8c13c6a7cc9757d7ffdaf163 Mon Sep 17 00:00:00 2001 From: ldhnam Date: Sun, 9 Aug 2026 16:36:13 +0700 Subject: [PATCH 2/2] docs: add keymap remapping documentation Document the opts.keymaps configuration surface: the per-view groups, default keybindings, disable/no-op semantics, and backward compatibility with the deprecated file_tree_maps.menu. Link it from the README and drop the stale docs/feature reference in the keymap registry comment. --- README.md | 3 + docs/keymap-remapping.md | 362 ++++++++++++++++++++++++++++++++++++ lua/fugit2/view/keymaps.lua | 3 +- 3 files changed, 366 insertions(+), 2 deletions(-) create mode 100644 docs/keymap-remapping.md diff --git a/README.md b/README.md index 823cd9b..9746ae1 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,9 @@ opts = { > **Note:** The legacy `file_tree_maps.menu` option still works and is translated into > `keymaps.file_tree.menu_` for backward compatibility, but is deprecated. +See [docs/keymap-remapping.md](docs/keymap-remapping.md) for the full list of view +groups and default keybindings. + ## Tested colorschemes - [Catppuccin](https://github.com/catppuccin/nvim) diff --git a/docs/keymap-remapping.md b/docs/keymap-remapping.md new file mode 100644 index 0000000..9cc4b44 --- /dev/null +++ b/docs/keymap-remapping.md @@ -0,0 +1,362 @@ +# Keymap Remapping + +Fugit2 exposes **every** keybinding across **all** views as configurable through the +`opts.keymaps` setup option. A central keymap registry +(`lua/fugit2/view/keymaps.lua`) is the single source of truth for defaults, user +overrides, and the upcoming help menu. + +## Configuring Keymaps + +Pass `keymaps` in your `setup`/`opts` table, grouped by view. Each action accepts: + +| Value | Effect | +|-------|--------| +| `"key"` | Remap to a single key. | +| `{ "k1", "k2" }` | Bind multiple keys. | +| `false` | Disable the binding entirely (not mapped). | +| `""` | Bind as a no-op (consumes the key, does nothing). | + +```lua +opts = { + keymaps = { + file_tree = { + stage_file = "S", -- stage now on S (default s) + unstage_file = false, -- remove the default u binding + discard = { "X", "D" }, -- discard on X or D + menu_commit = "C", -- menu actions use menu_ ids + }, + commit_log = { + copy_oid = "yY", + }, + rebase = { + drop = { "x", "d" }, + move_down = "", + }, + }, +} +``` + +Keys are resolved per-view at mount time, so a change to `opts.keymaps` takes effect +the next time a view is opened. + +## View Groups + +| Group | Applies to | Notable actions | +|-------|------------|-----------------| +| `file_tree` | Status file tree | `stage_file`, `unstage_file`, `discard`, `menu_*`, `exit` | +| `commit_log` | Status commit log | `copy_oid`, `quick_jump_down`, `focus_file_tree` | +| `patch_unstaged` | Unstaged patch panel | `stage_hunk`, `discard_hunk`, `jump_file` | +| `patch_staged` | Staged patch panel | `unstage_hunk`, `jump_file` | +| `rebase` | Interactive rebase view | `pick`, `squash`, `fixup`, `reword`, `drop`, `move_up`/`down` | +| `graph_log` | Commit graph log pane | `copy_oid`, `focus_branch` | +| `graph_branch` | Commit graph branch pane | `focus_log` | +| `graph_select` | Graph commit/branch selection | `select_commit`, `select_branch` | +| `diff` | Diff view source tree | `stage_file`, `unstage_file`, `focus_pane` | +| `stash_list` | Stash list popup | `apply`, `pop`, `drop` | +| `pick` | Branch/ref picker input | `next_item`, `prev_item` | +| `input` | Commit/branch/reword input prompts | `enter`, `exit` | +| `confirm` | Yes/No confirmation popups | `yes`, `move_yes`, `move_no`, `exit` | +| `blame` | Blame split view | `next_hunk`, `prev_hunk` | +| `blame_file` | Inline blame (virtual text) | `show_detail`, `exit` | +| `blame_popup` | Blame hunk detail popup | `exit` | +| `patch` | Hunk navigation in patch panels | `next_hunk`, `prev_hunk` | + +## Default Keybindings + +### file_tree + +| Action | Keys | Description | +|--------|------|-------------| +| `exit` | `q`, `` | Close window | +| `exit_insert` | `` | Close window (insert mode) | +| `refresh` | `g` | Refresh status | +| `menu_rebase` | `r` | Open rebase menu | +| `collapse` | `h` | Collapse folder | +| `collapse_all` | `H` | Collapse all folders | +| `expand` | `l` | Expand folder / open patch view | +| `expand_all` | `L` | Expand all folders | +| `focus_commit_log` | `J`, `` | Move to commit log | +| `focus_commit_log_disable` | `K` | No-op: disable mirror pane move | +| `toggle_patch` | `=` | Toggle patch view | +| `open_file` | `` | Open file | +| `stage_all` | `a` | Stage/unstage all | +| `stage_toggle` | `-`, `` | Stage/unstage file | +| `stage_file` | `s` | Stage file | +| `unstage_file` | `u` | Unstage file | +| `discard` | `D`, `x` | Discard changes | +| `write_index` | `w` | Write index | +| `stage_toggle_visual` | `-`, `` | Stage/unstage selection (visual) | +| `stage_visual` | `s` | Stage selection (visual) | +| `unstage_visual` | `u` | Unstage selection (visual) | +| `discard_visual` | `x`, `d` | Discard selection (visual) | +| `menu_commit` | `c` | Open commit menu | +| `menu_diff` | `d` | Open diff menu | +| `menu_branch` | `b` | Open branch menu | +| `menu_push` | `P` | Open push menu | +| `menu_fetch` | `f` | Open fetch menu | +| `menu_pull` | `p` | Open pull menu | +| `menu_forge` | `N` | Open forge menu | +| `menu_stash` | `z` | Open stash menu | +| `menu_cherry_pick` | `A` | Open cherry-pick menu | + +### commit_log + +| Action | Keys | Description | +|--------|------|-------------| +| `exit` | `q`, `` | Close window | +| `focus_file_tree` | `K`, `` | Move to file tree | +| `focus_file_tree_disable` | `J` | No-op: disable mirror pane move | +| `quick_jump_down` | `j` | Jump down (2 lines) | +| `quick_jump_up` | `k` | Jump up (2 lines) | +| `copy_oid` | `yy` | Copy commit id | +| `copy_oid_clipboard` | `yc` | Copy commit id to clipboard | + +### patch_unstaged + +| Action | Keys | Description | +|--------|------|-------------| +| `exit` | `q`, `` | Close window | +| `menu_commit` | `c` | Open commit menu | +| `menu_branch` | `b` | Open branch menu | +| `focus_file_tree` | `h` | Move to file tree | +| `focus_staged` | `l` | Move to staged patch | +| `toggle_off` | `=` | Turn off patch view | +| `stage_hunk` | `-`, `s` | Stage hunk | +| `discard_hunk` | `d`, `x` | Discard hunk | +| `stage_visual` | `-`, `s` | Stage selection (visual) | +| `discard_visual` | `d`, `x` | Discard selection (visual) | +| `jump_file` | `` | Jump to file | + +### patch_staged + +| Action | Keys | Description | +|--------|------|-------------| +| `exit` | `q`, `` | Close window | +| `menu_commit` | `c` | Open commit menu | +| `menu_branch` | `b` | Open branch menu | +| `focus_file_tree` | `h` | Move to file tree | +| `toggle_off` | `=` | Turn off patch view | +| `unstage_hunk` | `-`, `u` | Unstage hunk | +| `unstage_visual` | `-`, `u` | Unstage selection (visual) | +| `jump_file` | `` | Jump to file | + +### rebase + +| Action | Keys | Description | +|--------|------|-------------| +| `exit` | ``, `q` | Abort / close | +| `start` | `` | Start rebase | +| `continue` | `` | Continue rebase | +| `drop` | `x`, `d` | Drop commit | +| `break_commit` | `b` | Break | +| `edit` | `e` | Edit commit | +| `squash` | `s` | Squash commit | +| `fixup` | `f` | Fixup commit | +| `reword` | `r`, `w` | Reword commit | +| `pick` | `p` | Pick commit | +| `squash_visual` | `s` | Squash commits (visual) | +| `fixup_visual` | `f` | Fixup commits (visual) | +| `move_down` | `gj`, `` | Move commit down | +| `move_up` | `gk`, `` | Move commit up | +| `quick_jump_down` | `j` | Jump down (2 lines) | +| `quick_jump_up` | `k` | Jump up (2 lines) | +| `quick_jump_down_visual` | `j` | Jump down (visual) | +| `quick_jump_up_visual` | `k` | Jump up (visual) | + +### graph_log / graph_branch + +| Group | Action | Keys | Description | +|-------|--------|------|-------------| +| `graph_log` | `exit` | `q`, `` | Close window | +| `graph_log` | `refresh` | `r` | Refresh | +| `graph_log` | `focus_branch` | `h` | Move to branch view | +| `graph_log` | `quick_jump_down` | `j` | Jump down (2 lines) | +| `graph_log` | `quick_jump_up` | `k` | Jump up (2 lines) | +| `graph_log` | `copy_oid` | `yy` | Copy commit id | +| `graph_log` | `copy_oid_clipboard` | `yc` | Copy commit id to clipboard | +| `graph_branch` | `exit` | `q`, `` | Close window | +| `graph_branch` | `refresh` | `r` | Refresh | +| `graph_branch` | `focus_log` | `l`, ``, `` | Move to commit log | +| `graph_select` | `select_commit` | ``, `` | Select commit | +| `graph_select` | `select_branch` | ``, `` | Select branch | + +### diff + +| Action | Keys | Description | +|--------|------|-------------| +| `exit` | `q`, `` | Close window | +| `focus_pane` | `l`, `` | Focus diff pane | +| `stage_file` | `s` | Stage file | +| `unstage_file` | `u` | Unstage file | +| `stage_toggle` | `-`, `` | Stage/unstage file | +| `refresh` | `r` | Refresh | + +### stash_list + +| Action | Keys | Description | +|--------|------|-------------| +| `exit` | `q`, `` | Close window | +| `apply` | `a` | Apply stash | +| `pop` | `p` | Pop stash | +| `drop` | `d` | Drop stash | + +### pick / input / confirm + +| Group | Action | Keys | Description | +|-------|--------|------|-------------| +| `pick` | `exit` | ``, `` | Close window (insert mode) | +| `pick` | `next_item` | `` | Move to next item | +| `pick` | `prev_item` | `` | Move to previous item | +| `input` | `exit` | `q`, `` | Cancel input | +| `input` | `exit_insert` | `` | Cancel input (insert mode) | +| `input` | `enter` | `` | Confirm input | +| `input` | `enter_insert` | `` | Confirm input (insert mode) | +| `confirm` | `exit` | `q`, `n`, `` | No / close | +| `confirm` | `move_no` | `l` | Move to No | +| `confirm` | `move_yes` | `h` | Move to Yes | +| `confirm` | `toggle` | `` | Toggle Yes/No | +| `confirm` | `yes` | `y` | Confirm Yes | +| `confirm` | `yes_enter` | `` | Confirm Yes | + +### blame / blame_file / blame_popup / patch + +| Group | Action | Keys | Description | +|-------|--------|------|-------------| +| `blame` | `exit` | `q`, `` | Close window | +| `blame` | `next_hunk` | `J`, `]c` | Next hunk | +| `blame` | `prev_hunk` | `K`, `[c` | Previous hunk | +| `blame_file` | `exit` | `q`, `` | Close window | +| `blame_file` | `show_detail` | `c` | Toggle blame hunk detail | +| `blame_popup` | `exit` | `q`, `` | Close blame detail | +| `patch` | `next_hunk` | `J` | Next hunk | +| `patch` | `prev_hunk` | `K` | Previous hunk | + +## Backward Compatibility + +The deprecated `file_tree_maps.menu` option still works. In `config.merge`, each legacy +action is translated into the matching `keymaps.file_tree.menu_` entry, so +existing configs keep working unchanged: + +```lua +-- Legacy +opts = { file_tree_maps = { menu = { commit = "c", stash = "z" } } } + +-- Equivalent new form +opts = { + keymaps = { + file_tree = { + menu_commit = "c", + menu_stash = "z", + }, + }, +} +``` + +New `keymaps` entries always take precedence over a translated legacy value. The legacy +`file_tree_maps.direct` handling is preserved for backward compatibility. + +--- + +## Technical Details + +### Files Changed + +| File | Role | +|------|------| +| `lua/fugit2/view/keymaps.lua` | New central keymap registry (defaults + `bind`/`bind_buf`/`resolve_keys`) | +| `lua/fugit2/config.lua` | `keymaps` config field, `get_keymaps()`, legacy `file_tree_maps` translation | +| `lua/fugit2/view/git_status.lua` | File tree, commit log, patch views, inputs bound via registry | +| `lua/fugit2/view/git_rebase.lua` | Rebase view keymaps via registry | +| `lua/fugit2/view/git_graph.lua` | Graph log/branch/select keymaps via registry | +| `lua/fugit2/view/git_diff.lua` | Diff view keymaps via registry | +| `lua/fugit2/view/git_pick.lua` | Picker input keymaps via registry | +| `lua/fugit2/view/git_blame.lua` | Blame split view via `bind_buf` | +| `lua/fugit2/view/git_blame_file.lua` | Inline blame + detail popup via registry | +| `lua/fugit2/view/components/stash_list_view.lua` | Stash list actions via registry | +| `lua/fugit2/view/components/patch_view.lua` | Hunk navigation via registry | +| `lua/fugit2/view/components/menus.lua` | Confirm popup keys via registry | + +### Registry API + +`keymaps.lua` exports: + +| Function | Purpose | +|----------|---------| +| `get(group, action)` | Returns the `Fugit2KeymapDef` for an action. | +| `resolve_keys(group, action, user)` | Effective keys after applying user overrides. | +| `bind(view, group, handlers, user, opts)` | Binds every action in a group with a handler to a NUI view. | +| `bind_buf(bufnr, group, handlers, user, opts)` | Same, but maps onto a raw buffer via `nui.utils.keymap`. | +| `defs(group)` | All defs for a group (used by the help menu). | + +`bind` iterates `M.defaults[group]`. For each action the effective keys are +`user[action]` when present, otherwise the default. `false` disables the mapping; +`""` binds it as a no-op. Actions without a handler are skipped, so views can bind only +the subset they implement. + +### Handler Resolution + +Handlers are stored as functions in each view's handler table keyed by action id, not +in the registry. This keeps the registry serializable (for the help menu) and avoids +capturing stale `self` references: + +```lua +local file_tree_handlers = { + stage_file = utils.wrap(GitStatus._index_add_reset_discard, self, TreeBase.IndexAction.ADD), + menu_commit = self:_menu_handlers(Menu.COMMIT), + -- ... +} +keymaps.bind(file_tree, "file_tree", file_tree_handlers, self.opts.keymaps.file_tree, map_options) +``` + +### Data Flow + +``` +opts.keymaps.. = key + -> config.merge() (legacy file_tree_maps translated) + -> view:setup_handlers() + -> keymaps.bind(view, group, handlers, config.get_keymaps(group)) + -> for action, def in defaults[group]: + keys = user[action] or def.keys + if keys ~= false and handlers[action] then + view:map(def.mode or "n", keys, handlers[action], opts) +``` + +### Testing + +Tests live in two spec files: + +**`spec/fugit2/view/keymaps_spec.lua`** — registry unit tests with a mock view: + +| Test | Coverage | +|------|----------| +| All view groups defined | Registry has every expected group | +| Every def has keys + desc | Completeness check | +| `get` known/unknown action/group | Lookup behavior | +| `resolve_keys` defaults/overrides/disable/no-op | Effective key resolution | +| `bind` defaults/overrides | Mapping uses effective keys | +| `bind` disabled/false | Action skipped when disabled | +| `bind` no-op handler | Empty-string handler maps as no-op | +| `bind` missing handler | Action skipped | +| `bind` unknown group | No-op | +| `bind` visual/insert modes | `mode = "v"` / `mode = "i"` honored | +| `bind_buf` delegation | Delegates to `nui.utils.keymap.set` | +| `defs` | Group defs returned | + +**`spec/fugit2/config_spec.lua`** — config merge + backward-compat translation: + +| Test | Coverage | +|------|----------| +| Partial args merged into defaults | `width` override keeps `height` default | +| Nested tables deep-merged | `file_tree_maps.menu.commit` override | +| Legacy `file_tree_maps.menu` translated | `menu_commit`, `menu_stash` populated | +| Defaults not translated | Defaults live in the registry, not config | +| New `keymaps` take precedence | Overrides legacy translation | +| User keymap groups intact | `file_tree`, `rebase` groups preserved | +| `keymaps.file_tree` always populated | Present after merge | + +Run tests with: + +```bash +luarocks test --local -- --config-file=nlua.busted spec/fugit2/view/keymaps_spec.lua +luarocks test --local -- --config-file=nlua.busted spec/fugit2/config_spec.lua +``` diff --git a/lua/fugit2/view/keymaps.lua b/lua/fugit2/view/keymaps.lua index c9150ee..2efdd81 100644 --- a/lua/fugit2/view/keymaps.lua +++ b/lua/fugit2/view/keymaps.lua @@ -2,8 +2,7 @@ --- --- This module is the single source of truth for default keybindings. Views bind --- their keymaps by calling `bind` with a handlers table; user overrides from ---- `opts.keymaps` are resolved here. The same registry feeds the help menu ---- (see docs/feature/help-menu.md). +--- `opts.keymaps` are resolved here. The same registry feeds the help menu. ---@class Fugit2KeymapDef ---@field keys string|string[] Default key binding(s). An empty string "" disables the