From 714e69f43a0f9ee188c1bd874bfba79fbd981928 Mon Sep 17 00:00:00 2001 From: Corey Kaylor Date: Wed, 4 Nov 2026 19:03:36 -0500 Subject: [PATCH] Add g? key help and describe every mapping Every DiffBandit mapping now carries a "DiffBandit: ..." description and a help section (util/keyhelp), so :map, which-key, and keymap pickers explain them. g? (navigation.help_key) opens a floating list of the current window's keys, read from its live mappings so remaps and disabled keys show as they are. It is grouped by purpose and explains the compact diff header and the panel's staged symbols. Headers show a "g? help" hint (diff, merge, folder diff, commit panel, git log), the commit message label shows ":w commit" and the amend key, and the read-only review hint line is built from the configured keys instead of a hardcoded string. Fixes #21 --- README.md | 9 + doc/diffbandit.txt | 17 +- doc/tags | 1 + lua/diffbandit/config.lua | 1 + lua/diffbandit/folder/init.lua | 49 +++-- lua/diffbandit/init.lua | 3 - lua/diffbandit/merge/init.lua | 71 +++++-- lua/diffbandit/panel/init.lua | 139 +++++++++++--- lua/diffbandit/session/init.lua | 107 +++++++---- lua/diffbandit/util/keyhelp.lua | 316 +++++++++++++++++++++++++++++++ lua/diffbandit/util/log_view.lua | 21 +- tests/run.lua | 1 + tests/specs/27_keyhelp.lua | 195 +++++++++++++++++++ 13 files changed, 822 insertions(+), 108 deletions(-) create mode 100644 lua/diffbandit/util/keyhelp.lua create mode 100644 tests/specs/27_keyhelp.lua diff --git a/README.md b/README.md index 88c4228..34057ce 100644 --- a/README.md +++ b/README.md @@ -271,6 +271,12 @@ executable. ## Default Keys +Press `g?` in any DiffBandit window to see its keys. The list comes from +the window's live mappings, so it reflects your remaps, and it explains the +compact diff header and the panel's staged symbols. Headers show a `g? help` +hint; set `navigation.help_key = false` to turn both off. Every mapping also +has a `DiffBandit: ...` description, so which-key and `:map` list them. + Inside a diff view: | Key | Action | @@ -289,6 +295,7 @@ Inside a diff view: | `<<` | Apply the right side to the left target | | `u` | Undo the last DiffBandit hunk action | | `q` | Close the diff session (`navigation.close_key`) | +| `g?` | Show the keys of the current window (`navigation.help_key`) | At a file boundary, `]c` and `[c` first notify that the next press will move to the next or previous file. This keeps hunk navigation deliberate while still @@ -451,6 +458,8 @@ require("diffbandit").setup({ bottom = "]d", }, snap_key = "]s", + close_key = "q", + help_key = "g?", }, git = { default_mode = "all", diff --git a/doc/diffbandit.txt b/doc/diffbandit.txt index 00b8c29..25a2403 100644 --- a/doc/diffbandit.txt +++ b/doc/diffbandit.txt @@ -168,7 +168,19 @@ COMMANDS *diffbandit-commands* ============================================================================== DEFAULT MAPPINGS *diffbandit-mappings* -Mappings are buffer-local inside DiffBandit buffers. +Mappings are buffer-local inside DiffBandit buffers. Every DiffBandit view +answers |diffbandit-g?| with the keys of the window you are in, and each +mapping carries a "DiffBandit: ..." description, so `:map`, which-key, and +keymap pickers explain them too. + + *diffbandit-g?* + g? Show the keys of the current window in a floating window, + grouped by purpose, with a legend for the diff header and the + panel's staged symbols. The list is read from the live + mappings, so remapped and disabled keys are shown as they are. + q, , or g? closes it. Headers show a "g? help" hint. + Set by `navigation.help_key`; false disables the key and the + hints. Diff view mappings: @@ -209,10 +221,10 @@ Diff view mappings: *diffbandit-u* u Undo the last DiffBandit hunk action for the current file. - *diffbandit-q* *diffbandit-gzp* gzp Show or hide the commit panel for a Git diff. + *diffbandit-q* q Close the diff session. Set by `navigation.close_key`; false disables it. @@ -432,6 +444,7 @@ Common configuration: }, snap_key = "]s", close_key = "q", + help_key = "g?", }, git = { default_mode = "all", diff --git a/doc/tags b/doc/tags index a164349..ffa708f 100644 --- a/doc/tags +++ b/doc/tags @@ -35,6 +35,7 @@ diffbandit-commands diffbandit.txt /*diffbandit-commands* diffbandit-commit-panel diffbandit.txt /*diffbandit-commit-panel* diffbandit-configuration diffbandit.txt /*diffbandit-configuration* diffbandit-folder-diff diffbandit.txt /*diffbandit-folder-diff* +diffbandit-g? diffbandit.txt /*diffbandit-g?* diffbandit-git diffbandit.txt /*diffbandit-git* diffbandit-gzp diffbandit.txt /*diffbandit-gzp* diffbandit-hunk-actions diffbandit.txt /*diffbandit-hunk-actions* diff --git a/lua/diffbandit/config.lua b/lua/diffbandit/config.lua index 2b977bf..146e198 100644 --- a/lua/diffbandit/config.lua +++ b/lua/diffbandit/config.lua @@ -121,6 +121,7 @@ local defaults = { }, snap_key = "]s", close_key = "q", + help_key = "g?", prompt_message = "Reached final change in this diff. Open next file with changes?", on_request_next_file = nil, }, diff --git a/lua/diffbandit/folder/init.lua b/lua/diffbandit/folder/init.lua index 7f39de3..336675a 100644 --- a/lua/diffbandit/folder/init.lua +++ b/lua/diffbandit/folder/init.lua @@ -6,6 +6,7 @@ local ui = require("diffbandit.util.ui") local layout = require("diffbandit.util.layout") local winbar = require("diffbandit.util.winbar") local config_mod = require("diffbandit.config") +local keyhelp = require("diffbandit.util.keyhelp") local Folder = {} Folder.__index = Folder @@ -540,7 +541,9 @@ function Folder:render() local columns = folder_config(self).columns or {} local left_lines = { header_text("left", self.left_root, self.left_entries, left_width) } local gutter_lines = { center_text(compact_filter_label(self.filter), self.gutter_width) } - local right_lines = { header_text("right", self.right_root, self.right_entries, right_width) } + local help_hint = keyhelp.hint(config_mod.section(self.config, "navigation").help_key) + local hint_width = help_hint and (vim.fn.strdisplaywidth(help_hint) + 2) or 0 + local right_lines = { header_text("right", self.right_root, self.right_entries, math.max(10, right_width - hint_width)) } local left_meta = {} local right_meta = {} @@ -581,6 +584,7 @@ function Folder:render() vim.api.nvim_buf_clear_namespace(buf, self.ns, 0, -1) vim.api.nvim_buf_add_highlight(buf, self.ns, "DiffBanditStatus", 0, 0, -1) end + keyhelp.place_hint(self.right_buf, self.ns, 0, help_hint, "DiffBanditStatusMuted") for index, row in ipairs(self.visible_rows or {}) do local line = index @@ -1035,55 +1039,60 @@ end function Folder:setup_keymaps() local opts = { nowait = true, noremap = true, silent = true } local keys = folder_config(self).keys or {} - local function map(buf, lhs, rhs) + local help_key = config_mod.section(self.config, "navigation").help_key + -- section/desc label the mapping for the help window (util/keyhelp). + local function map(buf, lhs, rhs, section, desc) if lhs and lhs ~= "" then - vim.keymap.set("n", lhs, rhs, vim.tbl_extend("force", opts, { buffer = buf })) + vim.keymap.set("n", lhs, rhs, keyhelp.opts(buf, section, desc, vim.tbl_extend("force", opts, { buffer = buf }))) end end local function maps(buf) + map(buf, help_key, function() + keyhelp.open(buf, { title = "Folder diff", order = { "General", "Folder" }, help_key = help_key }) + end, "General", "Show this key help") + map(buf, keys.close or "q", function() + self:close() + end, "General", "Close this view") map(buf, keys.open or "", function() self:open_selected() - end) + end, "Folder", "Open the file pair, or expand the directory") map(buf, keys.alternate_open or "o", function() self:open_selected() - end) + end, "Folder", "Open the file pair, or expand the directory") map(buf, keys.toggle_expand or "", function() self:toggle_expand() - end) + end, "Folder", "Expand or collapse the directory") map(buf, keys.alternate_toggle_expand or "za", function() self:toggle_expand() - end) + end, "Folder", "Expand or collapse the directory") map(buf, keys.expand_all or "zR", function() self:expand_all() - end) + end, "Folder", "Expand all directories") map(buf, keys.collapse_all or "zM", function() self:collapse_all() - end) + end, "Folder", "Collapse all directories") map(buf, keys.next_diff or "]c", function() self:goto_diff(1) - end) + end, "Navigation", "Next difference") map(buf, keys.prev_diff or "[c", function() self:goto_diff(-1) - end) - map(buf, keys.refresh or "R", function() - self:refresh() - end) + end, "Navigation", "Previous difference") map(buf, keys.filter or "s", function() self:select_filter() - end) - map(buf, keys.close or "q", function() - self:close() - end) + end, "Folder", "Choose which entries to show") + map(buf, keys.refresh or "R", function() + self:refresh() + end, "Folder", "Compare again") map(buf, "l", function() if self.right_win and vim.api.nvim_win_is_valid(self.right_win) then vim.api.nvim_set_current_win(self.right_win) end - end) + end, "Navigation", "Go to the right side") map(buf, "h", function() if self.left_win and vim.api.nvim_win_is_valid(self.left_win) then vim.api.nvim_set_current_win(self.left_win) end - end) + end, "Navigation", "Go to the left side") end maps(self.left_buf) maps(self.right_buf) diff --git a/lua/diffbandit/init.lua b/lua/diffbandit/init.lua index 43f7bad..ca49a2e 100644 --- a/lua/diffbandit/init.lua +++ b/lua/diffbandit/init.lua @@ -268,7 +268,6 @@ local function review_panel_details(queue) target = review.target, author = commit.author, date = commit.date, - help = " focus diff ]f/[f files ]c/[c hunks q close", } elseif review.kind == "compare" then return { @@ -276,12 +275,10 @@ local function review_panel_details(queue) subtitle = review.direct and "Direct branch comparison" or "Merge-base branch comparison", base = review.base, target = review.target, - help = " focus diff ]f/[f files ]c/[c hunks q close", } end return { title = "Git review", - help = " focus diff ]f/[f files ]c/[c hunks q close", } end diff --git a/lua/diffbandit/merge/init.lua b/lua/diffbandit/merge/init.lua index 9fc8e34..e181271 100644 --- a/lua/diffbandit/merge/init.lua +++ b/lua/diffbandit/merge/init.lua @@ -3,6 +3,7 @@ local git = require("diffbandit.git") local merge_model = require("diffbandit.merge.model") local nvim = require("diffbandit.util.nvim") local keymaps = require("diffbandit.util.keymaps") +local keyhelp = require("diffbandit.util.keyhelp") local layout = require("diffbandit.util.layout") local winbar = require("diffbandit.util.winbar") local document = require("diffbandit.util.document") @@ -844,21 +845,30 @@ end function Merge:setup_keymaps() local keys = config_mod.section(self.config, "merge", "keys") local document_keys = config_mod.section(self.config, "navigation", "document_keys") - local function map(buf, lhs, rhs) - self:set_buffer_keymap("n", buf, lhs, rhs, { nowait = true, noremap = true, silent = true }) + local help_key = config_mod.section(self.config, "navigation").help_key + -- section/desc label the mapping for the help window (util/keyhelp). + local function map(buf, lhs, rhs, section, desc) + self:set_buffer_keymap("n", buf, lhs, rhs, + keyhelp.opts(buf, section, desc, { nowait = true, noremap = true, silent = true })) end for _, buf in ipairs({ self.local_buf, self.result_buf, self.remote_buf }) do - map(buf, keys.next_conflict, function() self:goto_next_hunk() end) - map(buf, keys.prev_conflict, function() self:goto_prev_hunk() end) - map(buf, keys.accept_local, function() self:accept("local") end) - map(buf, keys.accept_remote, function() self:accept("remote") end) - map(buf, keys.accept_both, function() self:accept("both") end) - map(buf, keys.apply_non_conflicting, function() self:apply_non_conflicting() end) - map(buf, keys.focus_panel, function() self:focus_commit_panel_for_current_file() end) - map(buf, document_keys.top, function() self:goto_document_edge("top") end) - map(buf, document_keys.bottom, function() self:goto_document_edge("bottom") end) - map(buf, keys.snap, function() self:snap_to_cursor() end) - map(buf, keys.close, function() self:close() end) + map(buf, help_key, function() self:show_key_help() end, "General", "Show this key help") + map(buf, keys.close, function() self:close() end, "General", "Close this view") + map(buf, keys.next_conflict, function() self:goto_next_hunk() end, "Merge", "Next conflict") + map(buf, keys.prev_conflict, function() self:goto_prev_hunk() end, "Merge", "Previous conflict") + map(buf, keys.accept_local, function() self:accept("local") end, "Merge", "Accept local (ours) into the result") + map(buf, keys.accept_remote, function() self:accept("remote") end, "Merge", "Accept remote (theirs) into the result") + map(buf, keys.accept_both, function() self:accept("both") end, "Merge", "Accept both, local then remote") + map(buf, keys.apply_non_conflicting, function() self:apply_non_conflicting() end, + "Merge", "Apply every non-conflicting change") + map(buf, keys.focus_panel, function() self:focus_commit_panel_for_current_file() end, + "Panel", "Open or focus the commit panel") + map(buf, document_keys.top, function() self:goto_document_edge("top") end, + "Navigation", "Align the panes to the top") + map(buf, document_keys.bottom, function() self:goto_document_edge("bottom") end, + "Navigation", "Align the panes to the bottom") + map(buf, keys.snap, function() self:snap_to_cursor() end, + "Navigation", "Line up the other panes with the cursor line") end -- Visibility toggles also work from the panel buffers, so the panel can be -- hidden while focused in it and panes re-shown from anywhere. @@ -869,15 +879,34 @@ function Merge:setup_keymaps() end for _, buf in ipairs(toggle_bufs) do if buf and vim.api.nvim_buf_is_valid(buf) then - map(buf, keys.toggle_panel, function() self:toggle_panel_visibility() end) - map(buf, keys.toggle_local, function() self:toggle_pane("local") end) - map(buf, keys.toggle_remote, function() self:toggle_pane("remote") end) - map(buf, keys.show_all, function() self:show_all() end) + map(buf, keys.toggle_panel, function() self:toggle_panel_visibility() end, + "Layout", "Show or hide the commit panel") + map(buf, keys.toggle_local, function() self:toggle_pane("local") end, + "Layout", "Show or hide the local (left) pane") + map(buf, keys.toggle_remote, function() self:toggle_pane("remote") end, + "Layout", "Show or hide the remote (right) pane") + map(buf, keys.show_all, function() self:show_all() end, + "Layout", "Show every hidden pane and the panel") end end keymaps.reassert_later(self, { self.local_buf, self.result_buf, self.remote_buf }) end +function Merge:show_key_help() + keyhelp.open(vim.api.nvim_get_current_buf(), { + title = "Merge", + notes = { + { + title = "Result", + rows = { + { keys = ":w", desc = "Write the result and mark the file resolved" }, + }, + }, + }, + help_key = config_mod.section(self.config, "navigation").help_key, + }) +end + function Merge:status_text() local conflict_text if #self.conflicts == 0 then @@ -952,7 +981,13 @@ function Merge:render_headers() ) end if remote_width then - ui.set_header_line(self.remote_header_buf, self.header_ns, lines.remote, remote_width) + ui.set_header_line_with_right( + self.remote_header_buf, + self.header_ns, + lines.remote, + keyhelp.hint(config_mod.section(self.config, "navigation").help_key), + remote_width + ) end end diff --git a/lua/diffbandit/panel/init.lua b/lua/diffbandit/panel/init.lua index 7f1e1f9..ecacc00 100644 --- a/lua/diffbandit/panel/init.lua +++ b/lua/diffbandit/panel/init.lua @@ -5,6 +5,7 @@ local config_mod = require("diffbandit.config") local layout = require("diffbandit.util.layout") local panel_api = require("diffbandit.host.panel_api") local state = require("diffbandit.state") +local keyhelp = require("diffbandit.util.keyhelp") local M = {} @@ -640,6 +641,8 @@ function M.render_nav(session, preferred_entry_index, opts) vim.api.nvim_buf_set_lines(panel.nav_buf, 0, -1, false, lines) nvim.set_buffer_options(panel.nav_buf, { modifiable = false }) apply_nav_highlights(session) + keyhelp.place_hint(panel.nav_buf, session.ns, 0, + keyhelp.hint(config_mod.section(session.config, "navigation").help_key)) local target_row if opts.no_initial_selection then @@ -677,9 +680,10 @@ function M.render_commit(session) if details.date and details.date ~= "" then lines[#lines + 1] = "Date: " .. tostring(details.date) end - if details.help and details.help ~= "" then + local help = M.review_help(session) + if help ~= "" then lines[#lines + 1] = "" - lines[#lines + 1] = details.help + lines[#lines + 1] = help end nvim.set_buffer_options(panel.commit_buf, { modifiable = true }) vim.api.nvim_buf_set_lines(panel.commit_buf, 0, -1, false, lines) @@ -703,6 +707,13 @@ function M.render_commit(session) " " .. panel.validation_message, "DiffBanditDelete", } + else + local keys = panel_config(session).keys or {} + local hint = ":w commit" + if keys.toggle_amend and keys.toggle_amend ~= "" then + hint = hint .. " " .. keys.toggle_amend .. " amend" + end + message_label_chunks[#message_label_chunks + 1] = { " " .. hint, "Comment" } end if not panel.message_initialized then panel.message_initialized = true @@ -1018,6 +1029,67 @@ function M.close(session) panel.visible = false end +-- Staged-state symbols, explained in the help window. +local function staged_indicator_notes(session) + local indicator = panel_config(session).staged_indicator or {} + local rows = {} + for _, item in ipairs({ + { indicator.unstaged, "Not staged" }, + { indicator.partial, "Partly staged" }, + { indicator.staged, "Staged" }, + }) do + if item[1] and item[1] ~= "" then + rows[#rows + 1] = { keys = item[1], desc = item[2] } + end + end + return rows +end + +function M.show_key_help(session) + local buf = vim.api.nvim_get_current_buf() + local panel = session.panel or {} + local notes = {} + if buf == panel.commit_buf and not is_review_panel(session) then + notes[#notes + 1] = { + title = "Commit", + rows = { { keys = ":w", desc = "Commit staged changes with this message" } }, + } + elseif not is_review_panel(session) then + local rows = staged_indicator_notes(session) + if #rows > 0 then + notes[#notes + 1] = { title = "File list", rows = rows } + end + end + keyhelp.open(buf, { + title = buf == panel.commit_buf and not is_review_panel(session) and "Commit message" or "Commit panel", + notes = notes, + order = { "General", "Files", "Staging and actions", "Commit" }, + help_key = config_mod.section(session.config, "navigation").help_key, + }) +end + +-- One-line key summary shown in read-only reviews, built from the +-- configured keys so remaps and disabled keys stay accurate. +function M.review_help(session) + local keys = panel_config(session).keys or {} + local file_keys = config_mod.section((session or {}).config, "git", "file_keys") + local help_key = config_mod.section((session or {}).config, "navigation").help_key + local parts = {} + local function add(lhs, label) + if lhs and lhs ~= "" then + parts[#parts + 1] = lhs .. " " .. label + end + end + add(keys.focus_diff, "focus diff") + if session.file_queue and file_keys.next and file_keys.prev then + add(file_keys.next .. "/" .. file_keys.prev, "files") + end + add("]c/[c", "hunks") + add(keys.close, "close") + add(help_key, "help") + return table.concat(parts, " ") +end + local function setup_commit_write_command(session) local panel = session.panel if not panel or not panel.commit_buf or not vim.api.nvim_buf_is_valid(panel.commit_buf) then @@ -1081,31 +1153,43 @@ function M.setup_keymaps(session) -- lhs = nil/false skips the map (unset config key or failed condition); -- when = false skips it explicitly. + local help_key = config_mod.section((session or {}).config, "navigation").help_key + local show_help = function() + M.show_key_help(session) + end + + -- section/desc label each mapping for the help window (util/keyhelp). local specs = { - { buf = "nav", lhs = "j", fn = function() M.move(session, 1) end }, - { buf = "nav", lhs = "k", fn = function() M.move(session, -1) end }, - { buf = "nav", lhs = keys.toggle_stage, fn = function() M.toggle_stage(session) end }, - { buf = "nav", lhs = keys.focus_diff, fn = focus_diff }, - { buf = "nav", lhs = keys.focus_commit, fn = function() M.focus_commit(session) end }, - { buf = "nav", lhs = keys.file_actions, fn = function() M.open_file_actions(session) end, when = not review }, - { buf = "nav", lhs = "]c", fn = navigate_change("next") }, - { buf = "nav", lhs = "[c", fn = navigate_change("prev") }, - { buf = "nav", lhs = file_keys.next, fn = navigate_file("next"), when = has_queue }, - { buf = "nav", lhs = file_keys.prev, fn = navigate_file("prev"), when = has_queue }, - { buf = "nav", lhs = keys.commit, fn = commit, when = not review }, - { buf = "nav", lhs = keys.toggle_amend, fn = toggle_amend, when = keys.toggle_amend ~= keys.toggle_stage and not review }, - { buf = "nav", lhs = keys.refresh, fn = function() session:refresh_git_queue() end }, - { buf = "nav", lhs = keys.close, fn = close }, - - { buf = "commit", lhs = keys.focus_diff, fn = focus_diff, when = review }, - { buf = "commit", lhs = "]c", fn = navigate_change("next"), when = review }, - { buf = "commit", lhs = "[c", fn = navigate_change("prev"), when = review }, - { buf = "commit", lhs = file_keys.next, fn = navigate_file("next"), when = review and has_queue }, - { buf = "commit", lhs = file_keys.prev, fn = navigate_file("prev"), when = review and has_queue }, - { buf = "commit", lhs = keys.close, fn = close, when = review }, - - { buf = "commit", lhs = keys.commit, fn = commit, when = not review }, - { buf = "commit", lhs = keys.toggle_amend, fn = toggle_amend, when = not review }, + { buf = "nav", lhs = help_key, fn = show_help, section = "General", desc = "Show this key help" }, + { buf = "nav", lhs = keys.close, fn = close, section = "General", desc = "Close this view" }, + { buf = "nav", lhs = "j", fn = function() M.move(session, 1) end, section = "Files", desc = "Next file (previews it)" }, + { buf = "nav", lhs = "k", fn = function() M.move(session, -1) end, section = "Files", desc = "Previous file (previews it)" }, + { buf = "nav", lhs = keys.focus_diff, fn = focus_diff, section = "Files", desc = "Focus the diff of the selected file" }, + { buf = "nav", lhs = file_keys.next, fn = navigate_file("next"), when = has_queue, section = "Files", desc = "Open the next file" }, + { buf = "nav", lhs = file_keys.prev, fn = navigate_file("prev"), when = has_queue, section = "Files", desc = "Open the previous file" }, + { buf = "nav", lhs = "]c", fn = navigate_change("next"), section = "Hunks", desc = "Next hunk in the selected file" }, + { buf = "nav", lhs = "[c", fn = navigate_change("prev"), section = "Hunks", desc = "Previous hunk in the selected file" }, + { buf = "nav", lhs = keys.toggle_stage, fn = function() M.toggle_stage(session) end, + section = "Staging and actions", desc = "Stage or unstage the selected file" }, + { buf = "nav", lhs = keys.file_actions, fn = function() M.open_file_actions(session) end, when = not review, + section = "Staging and actions", desc = "File actions (discard, delete, ignore, ...)" }, + { buf = "nav", lhs = keys.focus_commit, fn = function() M.focus_commit(session) end, + section = "Commit", desc = review and "Focus the review details" or "Write the commit message" }, + { buf = "nav", lhs = keys.commit, fn = commit, when = not review, section = "Commit", desc = "Commit staged changes" }, + { buf = "nav", lhs = keys.toggle_amend, fn = toggle_amend, when = keys.toggle_amend ~= keys.toggle_stage and not review, + section = "Commit", desc = "Toggle amend mode" }, + { buf = "nav", lhs = keys.refresh, fn = function() session:refresh_git_queue() end, section = "Panel", desc = "Refresh the file list" }, + + { buf = "commit", lhs = help_key, fn = show_help, section = "General", desc = "Show this key help" }, + { buf = "commit", lhs = keys.close, fn = close, when = review, section = "General", desc = "Close this view" }, + { buf = "commit", lhs = keys.focus_diff, fn = focus_diff, when = review, section = "Files", desc = "Focus the diff" }, + { buf = "commit", lhs = file_keys.next, fn = navigate_file("next"), when = review and has_queue, section = "Files", desc = "Open the next file" }, + { buf = "commit", lhs = file_keys.prev, fn = navigate_file("prev"), when = review and has_queue, section = "Files", desc = "Open the previous file" }, + { buf = "commit", lhs = "]c", fn = navigate_change("next"), when = review, section = "Hunks", desc = "Next hunk" }, + { buf = "commit", lhs = "[c", fn = navigate_change("prev"), when = review, section = "Hunks", desc = "Previous hunk" }, + + { buf = "commit", lhs = keys.commit, fn = commit, when = not review, section = "Commit", desc = "Commit staged changes" }, + { buf = "commit", lhs = keys.toggle_amend, fn = toggle_amend, when = not review, section = "Commit", desc = "Toggle amend mode" }, } local buffers = { nav = panel.nav_buf } @@ -1119,7 +1203,8 @@ function M.setup_keymaps(session) for _, spec in ipairs(specs) do local buf = buffers[spec.buf] if buf and spec.lhs and spec.when ~= false then - vim.keymap.set("n", spec.lhs, spec.fn, { buffer = buf, nowait = true, noremap = true, silent = true }) + vim.keymap.set("n", spec.lhs, spec.fn, + keyhelp.opts(buf, spec.section, spec.desc, { buffer = buf, nowait = true, noremap = true, silent = true })) end end end diff --git a/lua/diffbandit/session/init.lua b/lua/diffbandit/session/init.lua index d905dbc..297de68 100644 --- a/lua/diffbandit/session/init.lua +++ b/lua/diffbandit/session/init.lua @@ -13,6 +13,7 @@ local keymaps = require("diffbandit.util.keymaps") local session_layout = require("diffbandit.session.layout") local render_host = require("diffbandit.session.render_host") local config_mod = require("diffbandit.config") +local keyhelp = require("diffbandit.util.keyhelp") local view_builder = require("diffbandit.diff.view") local diff_document = require("diffbandit.diff.document") local kitty = require("diffbandit.kitty") @@ -535,14 +536,15 @@ end function Session:setup_keymaps() local opts = { nowait = true, noremap = true, silent = true } - local function map(buf, lhs, rhs) - self:set_buffer_keymap("n", buf, lhs, rhs, opts) + -- section/desc label the mapping for the help window (util/keyhelp). + local function map(buf, lhs, rhs, section, desc) + self:set_buffer_keymap("n", buf, lhs, rhs, keyhelp.opts(buf, section, desc, opts)) end - local function action_map(buf, lhs, command, fallback) + local function action_map(buf, lhs, command, fallback, desc) if vim.fn.exists(":" .. command) == 2 then - map(buf, lhs, "" .. command .. "") + map(buf, lhs, "" .. command .. "", "Staging and actions", desc) else - map(buf, lhs, fallback) + map(buf, lhs, fallback, "Staging and actions", desc) end end local navigation = self.config.navigation or {} @@ -550,68 +552,74 @@ function Session:setup_keymaps() local git_keys = config_mod.section(self.config, "git", "file_keys") local panel_keys = config_mod.section(self.config, "git", "panel", "keys") local action_keys = ((self.config.actions or {}).keys or {}) + local is_git = self.file_queue and self.file_queue.kind == "git" local function buffer_maps(buf) - map(buf, "]c", function() - self:goto_next_chunk() - end) - map(buf, "[c", function() - self:goto_prev_chunk() - end) + if navigation.help_key then + map(buf, navigation.help_key, function() + self:show_key_help() + end, "General", "Show this key help") + end if navigation.close_key then map(buf, navigation.close_key, function() self:close() - end) + end, "General", "Close this view") end + map(buf, "]c", function() + self:goto_next_chunk() + end, "Hunks", "Next hunk") + map(buf, "[c", function() + self:goto_prev_chunk() + end, "Hunks", "Previous hunk") if document_keys.top then map(buf, document_keys.top, function() self:goto_document_edge("top") - end) + end, "Navigation", "Align both panes to the top") end if document_keys.bottom then map(buf, document_keys.bottom, function() self:goto_document_edge("bottom") - end) + end, "Navigation", "Align both panes to the bottom") end if navigation.snap_key then map(buf, navigation.snap_key, function() self:snap_to_cursor() - end) + end, "Navigation", "Line up the other pane with the cursor line") end if self.file_queue and git_keys.next then map(buf, git_keys.next, function() self:goto_next_file() - end) + end, "Files", "Next file") end if self.file_queue and git_keys.prev then map(buf, git_keys.prev, function() self:goto_prev_file() - end) + end, "Files", "Previous file") end if action_keys.toggle_stage then action_map(buf, action_keys.toggle_stage, "DiffBanditToggleStageHunk", function() self:toggle_stage_hunk() - end) + end, "Stage or unstage the active hunk") end if action_keys.apply_left then action_map(buf, action_keys.apply_left, "DiffBanditApplyLeftHunk", function() self:apply_left_hunk() - end) + end, "Copy the left side of the hunk to the right") end if action_keys.apply_right then action_map(buf, action_keys.apply_right, "DiffBanditApplyRightHunk", function() self:apply_right_hunk() - end) + end, "Copy the right side of the hunk to the left") end if action_keys.undo then if buf == self.right_buf and self.right and self.right.editable then map(buf, action_keys.undo, function() self:undo_edit_or_action() - end) + end, "Staging and actions", "Undo the last edit or hunk action") else action_map(buf, action_keys.undo, "DiffBanditUndo", function() self:undo_action() - end) + end, "Undo the last hunk action") end end end @@ -622,18 +630,17 @@ function Session:setup_keymaps() buffer_maps(self.right_num_buf) buffer_maps(self.right_buf) if self.file_queue and panel_keys.focus_panel then - map(self.left_buf, panel_keys.focus_panel, function() - self:focus_commit_panel_for_current_file() - end) - map(self.right_buf, panel_keys.focus_panel, function() - self:focus_commit_panel_for_current_file() - end) + for _, buf in ipairs({ self.left_buf, self.right_buf }) do + map(buf, panel_keys.focus_panel, function() + self:focus_commit_panel_for_current_file() + end, "Panel", "Open or focus the commit panel") + end end - if self.file_queue and self.file_queue.kind == "git" and panel_keys.toggle_panel then + if is_git and panel_keys.toggle_panel then for _, buf in ipairs({ self.left_buf, self.right_buf }) do map(buf, panel_keys.toggle_panel, function() self:toggle_commit_panel() - end) + end, "Panel", "Show or hide the commit panel") end self:map_panel_toggle() end @@ -641,15 +648,41 @@ function Session:setup_keymaps() if self.right_win and vim.api.nvim_win_is_valid(self.right_win) then vim.api.nvim_set_current_win(self.right_win) end - end) + end, "Navigation", "Go to the right pane") map(self.right_buf, "h", function() if self.left_win and vim.api.nvim_win_is_valid(self.left_win) then vim.api.nvim_set_current_win(self.left_win) end - end) + end, "Navigation", "Go to the left pane") keymaps.reassert_later(self, { self.left_buf, self.right_buf }) end +-- Header legend for the help window: what the compact status fields mean. +function Session:key_help_notes() + if not self.status_enabled then + return nil + end + local rows = {} + if self.file_queue and self.file_queue.kind == "git" then + rows[#rows + 1] = { keys = "all/unstg/stg", desc = "Which changes this review shows" } + rows[#rows + 1] = { keys = "2/6", desc = "File 2 of 6 in the review" } + rows[#rows + 1] = { keys = "h3/5", desc = "Hunk 3 of 5 (h-/5: cursor is between hunks)" } + rows[#rows + 1] = { keys = "M A D R ??", desc = "Git status of this file (?? untracked)" } + rows[#rows + 1] = { keys = "1/5", desc = "Hunks of this file that are staged" } + else + rows[#rows + 1] = { keys = "h3/5", desc = "Hunk 3 of 5 (h-/5: cursor is between hunks)" } + end + return { { title = "Header", rows = rows } } +end + +function Session:show_key_help() + keyhelp.open(vim.api.nvim_get_current_buf(), { + title = "Diff", + notes = self:key_help_notes(), + help_key = (self.config.navigation or {}).help_key, + }) +end + function Session:set_buffer_keymap(mode, buf, lhs, rhs, opts) keymaps.set(self, mode, buf, lhs, rhs, opts) end @@ -1116,7 +1149,13 @@ function Session:render_status_headers() self.status_lines = lines ui.set_header_line(self.left_header_buf, self.ns, self.status_lines.left, vim.api.nvim_win_get_width(self.left_header_win)) ui.set_header_line(self.center_header_buf, self.ns, self.status_lines.center, center_width) - ui.set_header_line(self.right_header_buf, self.ns, self.status_lines.right, vim.api.nvim_win_get_width(self.right_header_win)) + ui.set_header_line_with_right( + self.right_header_buf, + self.ns, + self.status_lines.right, + keyhelp.hint((self.config.navigation or {}).help_key), + vim.api.nvim_win_get_width(self.right_header_win) + ) end function Session:replace_sources(sources, opts) @@ -1687,7 +1726,7 @@ function Session:map_panel_toggle() if buf and vim.api.nvim_buf_is_valid(buf) then self:set_buffer_keymap("n", buf, key, function() self:toggle_commit_panel() - end, { nowait = true, noremap = true, silent = true }) + end, keyhelp.opts(buf, "Panel", "Hide the commit panel", { nowait = true, noremap = true, silent = true })) end end end diff --git a/lua/diffbandit/util/keyhelp.lua b/lua/diffbandit/util/keyhelp.lua new file mode 100644 index 0000000..dc0b4ba --- /dev/null +++ b/lua/diffbandit/util/keyhelp.lua @@ -0,0 +1,316 @@ +-- In-view key help (the help key, g? by default). +-- +-- Every DiffBandit mapping carries a `desc` ("DiffBandit: ") so the +-- mappings explain themselves in :map, which-key, and keymap pickers. The help +-- window lists the *live* mappings of the buffer it opens from, so remapped, +-- disabled, or context-dependent keys always match what pressing them does. +-- A per-buffer registry only adds the section each description belongs to. +local M = {} + +M.PREFIX = "DiffBandit: " + +-- Section order in the help window; unknown sections sort after these. +M.SECTIONS = { + "General", + "Navigation", + "Hunks", + "Files", + "Staging and actions", + "Merge", + "Layout", + "Panel", + "Commit", + "Folder", + "Log", +} + +local section_rank = {} +for index, name in ipairs(M.SECTIONS) do + section_rank[name] = index +end + +-- tags[buf][desc] = { section = string, order = integer } +local tags = {} +local order_seq = 0 +local cleanup_group + +local function ensure_cleanup() + if cleanup_group then + return + end + cleanup_group = vim.api.nvim_create_augroup("DiffBanditKeyHelp", { clear = true }) + vim.api.nvim_create_autocmd("BufWipeout", { + group = cleanup_group, + callback = function(args) + tags[args.buf] = nil + end, + }) +end + +-- Returns `opts` extended with a DiffBandit description, and records which +-- help section the mapping belongs to. Use for every DiffBandit mapping: +-- vim.keymap.set("n", lhs, rhs, keyhelp.opts(buf, "Hunks", "Next hunk", base)) +function M.opts(buf, section, desc, base) + local opts = vim.tbl_extend("force", {}, base or {}) + if desc and desc ~= "" then + opts.desc = M.PREFIX .. desc + if buf and buf ~= 0 then + ensure_cleanup() + tags[buf] = tags[buf] or {} + if not tags[buf][desc] then + order_seq = order_seq + 1 + tags[buf][desc] = { section = section or "General", order = order_seq } + end + end + end + return opts +end + +-- nvim_buf_get_keymap reports a space as " " and "<" as "". +local function display_lhs(lhs) + return (lhs:gsub(" ", ""):gsub("", "<")) +end + +-- The DiffBandit mappings currently active in `buf`, grouped into sections: +-- { { name = "Hunks", rows = { { keys = "]c", desc = "Next hunk" }, ... } }, ... } +-- Keys sharing a description collapse into one row (", o"). `order` +-- optionally lists section names to put first (e.g. "Files" in the panel). +function M.sections(buf, order) + if not (buf and vim.api.nvim_buf_is_valid(buf)) then + return {} + end + local buf_tags = tags[buf] or {} + local rows_by_desc = {} + local rows = {} + for _, map in ipairs(vim.api.nvim_buf_get_keymap(buf, "n")) do + local desc = map.desc + if desc and desc:sub(1, #M.PREFIX) == M.PREFIX then + desc = desc:sub(#M.PREFIX + 1) + local row = rows_by_desc[desc] + if row then + row.keys[#row.keys + 1] = display_lhs(map.lhs) + else + local tag = buf_tags[desc] or {} + row = { + desc = desc, + keys = { display_lhs(map.lhs) }, + section = tag.section or "General", + order = tag.order or math.huge, + } + rows_by_desc[desc] = row + rows[#rows + 1] = row + end + end + end + local rank = vim.tbl_extend("force", {}, section_rank) + for index, name in ipairs(order or {}) do + rank[name] = index - #(order or {}) - 1 + end + table.sort(rows, function(a, b) + local ra = rank[a.section] or math.huge + local rb = rank[b.section] or math.huge + if ra ~= rb then + return ra < rb + end + if a.section ~= b.section then + return a.section < b.section + end + if a.order ~= b.order then + return a.order < b.order + end + return a.desc < b.desc + end) + local sections = {} + local current + for _, row in ipairs(rows) do + if not current or current.name ~= row.section then + current = { name = row.section, rows = {} } + sections[#sections + 1] = current + end + table.sort(row.keys) + current.rows[#current.rows + 1] = { keys = table.concat(row.keys, ", "), desc = row.desc } + end + return sections +end + +-- "g? help": the short, muted hint DiffBandit headers show. +function M.hint(help_key) + if not help_key or help_key == "" then + return nil + end + return help_key .. " help" +end + +-- Right-aligned hint text on `row` of `buf`, for header rows that are real +-- buffer lines (panel, folder diff, log) rather than header windows. `hl` +-- defaults to Comment; pass the header group for rows drawn as a bar. +function M.place_hint(buf, ns, row, text, hl) + if not (text and text ~= "" and buf and vim.api.nvim_buf_is_valid(buf)) then + return + end + pcall(vim.api.nvim_buf_set_extmark, buf, ns, row or 0, 0, { + virt_text = { { text, hl or "Comment" } }, + virt_text_pos = "right_align", + hl_mode = "combine", + }) +end + +local help_ns = vim.api.nvim_create_namespace("DiffBanditKeyHelpWindow") +local open_help + +local function close_help() + local help = open_help + open_help = nil + if help and help.win and vim.api.nvim_win_is_valid(help.win) then + pcall(vim.api.nvim_win_close, help.win, true) + end +end + +local function build_lines(sections, notes, footer) + local key_width = 0 + for _, section in ipairs(sections) do + for _, row in ipairs(section.rows) do + key_width = math.max(key_width, vim.fn.strdisplaywidth(row.keys)) + end + end + for _, note in ipairs(notes or {}) do + for _, row in ipairs(note.rows or {}) do + key_width = math.max(key_width, vim.fn.strdisplaywidth(row.keys)) + end + end + key_width = math.min(key_width, 24) + + local lines, marks = {}, {} + local function add_heading(text) + if #lines > 0 then + lines[#lines + 1] = "" + end + lines[#lines + 1] = " " .. text + marks[#marks + 1] = { row = #lines - 1, col = 1, end_col = #lines[#lines], hl = "Title" } + end + local function add_row(keys, desc) + local pad = string.rep(" ", math.max(1, key_width - vim.fn.strdisplaywidth(keys) + 2)) + local line = " " .. keys .. pad .. desc .. " " + lines[#lines + 1] = line + marks[#marks + 1] = { row = #lines - 1, col = 3, end_col = 3 + #keys, hl = "Special" } + end + + for _, section in ipairs(sections) do + add_heading(section.name) + for _, row in ipairs(section.rows) do + add_row(row.keys, row.desc) + end + end + for _, note in ipairs(notes or {}) do + add_heading(note.title) + for _, row in ipairs(note.rows or {}) do + add_row(row.keys, row.desc) + end + end + if #sections == 0 and #(notes or {}) == 0 then + lines[#lines + 1] = " No DiffBandit keys in this window." + end + for _, text in ipairs(footer or {}) do + if text == footer[1] then + lines[#lines + 1] = "" + end + lines[#lines + 1] = " " .. text + marks[#marks + 1] = { row = #lines - 1, col = 0, end_col = #lines[#lines], hl = "Comment" } + end + return lines, marks +end + +-- Open the help window for `buf` (default: the current buffer). +-- opts.title context shown in the border ("Diff", "Commit panel", ...) +-- opts.notes extra sections: { { title = "Header", rows = { { keys, desc } } } } +-- opts.help_key the key that opened the window (also closes it) +-- opts.order section names to list first (see M.sections) +-- Pressing the help key while the window is open closes it. +function M.open(buf, opts) + opts = opts or {} + buf = buf or vim.api.nvim_get_current_buf() + if open_help and open_help.win and vim.api.nvim_win_is_valid(open_help.win) then + close_help() + return nil + end + + local footer = { + "Commands: :DiffBanditClose closes this view, :help diffbandit for everything.", + "q or closes this window.", + } + -- A note titled like a key section joins it (":w" under "Commit"). + local sections = M.sections(buf, opts.order) + local by_name = {} + for _, section in ipairs(sections) do + by_name[section.name] = section + end + local notes = {} + for _, note in ipairs(opts.notes or {}) do + local section = by_name[note.title] + if section then + vim.list_extend(section.rows, note.rows or {}) + else + notes[#notes + 1] = note + end + end + local lines, marks = build_lines(sections, notes, footer) + + local width = 0 + for _, line in ipairs(lines) do + width = math.max(width, vim.fn.strdisplaywidth(line)) + end + width = math.max(30, math.min(width + 1, vim.o.columns - 4)) + local height = math.max(1, math.min(#lines, vim.o.lines - 4)) + + local help_buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(help_buf, 0, -1, false, lines) + for _, mark in ipairs(marks) do + pcall(vim.api.nvim_buf_set_extmark, help_buf, help_ns, mark.row, mark.col, { + end_col = mark.end_col, + hl_group = mark.hl, + }) + end + for name, value in pairs({ modifiable = false, bufhidden = "wipe", filetype = "diffbandit-help" }) do + pcall(vim.api.nvim_set_option_value, name, value, { buf = help_buf }) + end + + local title = " DiffBandit keys" .. (opts.title and (": " .. opts.title) or "") .. " " + local win = vim.api.nvim_open_win(help_buf, true, { + relative = "editor", + row = math.max(0, math.floor((vim.o.lines - height) / 2) - 1), + col = math.max(0, math.floor((vim.o.columns - width) / 2)), + width = width, + height = height, + style = "minimal", + border = "rounded", + title = title, + title_pos = "center", + zindex = 250, + }) + pcall(vim.api.nvim_set_option_value, "cursorline", true, { win = win }) + pcall(vim.api.nvim_set_option_value, "wrap", false, { win = win }) + open_help = { win = win, buf = help_buf } + + local map_opts = { buffer = help_buf, nowait = true, noremap = true, silent = true } + for _, lhs in ipairs({ "q", "", opts.help_key }) do + if lhs and lhs ~= "" then + vim.keymap.set("n", lhs, close_help, map_opts) + end + end + vim.api.nvim_create_autocmd({ "WinLeave", "BufLeave" }, { + buffer = help_buf, + once = true, + callback = function() + vim.schedule(close_help) + end, + }) + return win +end + +function M.is_open() + return open_help ~= nil and open_help.win ~= nil and vim.api.nvim_win_is_valid(open_help.win) +end + +M.close = close_help + +return M diff --git a/lua/diffbandit/util/log_view.lua b/lua/diffbandit/util/log_view.lua index b39db47..87aacea 100644 --- a/lua/diffbandit/util/log_view.lua +++ b/lua/diffbandit/util/log_view.lua @@ -4,6 +4,8 @@ local git = require("diffbandit.git") local nvim = require("diffbandit.util.nvim") local ui = require("diffbandit.util.ui") +local keyhelp = require("diffbandit.util.keyhelp") +local config_mod = require("diffbandit.config") local M = {} @@ -35,6 +37,8 @@ local function render_log(browser) vim.api.nvim_buf_set_lines(browser.buf, 0, -1, false, lines) vim.api.nvim_buf_clear_namespace(browser.buf, browser.ns, 0, -1) vim.api.nvim_buf_add_highlight(browser.buf, browser.ns, "DiffBanditStatusAccent", 0, 0, -1) + keyhelp.place_hint(browser.buf, browser.ns, 0, + keyhelp.hint(config_mod.section(browser.config, "navigation").help_key)) nvim.set_buffer_options(browser.buf, { modifiable = false }) end @@ -78,19 +82,28 @@ function M.open(config, opts, callbacks) pcall(vim.api.nvim_win_set_cursor, browser.win, { 2, 0 }) end - local map_opts = { buffer = browser.buf, nowait = true, noremap = true, silent = true } + local base_opts = { buffer = browser.buf, nowait = true, noremap = true, silent = true } + local function map_opts(section, desc) + return keyhelp.opts(browser.buf, section, desc, base_opts) + end + local help_key = config_mod.section(config, "navigation").help_key + if help_key then + vim.keymap.set("n", help_key, function() + keyhelp.open(browser.buf, { title = "Git log", help_key = help_key }) + end, map_opts("General", "Show this key help")) + end vim.keymap.set("n", "q", function() if browser.win and vim.api.nvim_win_is_valid(browser.win) then pcall(vim.api.nvim_win_close, browser.win, true) end - end, map_opts) + end, map_opts("General", "Close the log")) vim.keymap.set("n", "", function() local line = vim.api.nvim_win_get_cursor(browser.win)[1] local commit = browser.commits[line - 1] if commit and callbacks.open_commit then callbacks.open_commit(commit.hash, { root = root }) end - end, map_opts) + end, map_opts("Log", "Review the commit under the cursor")) vim.keymap.set("n", "R", function() local next_commits, refresh_err = git.log(root, opts) if not next_commits then @@ -99,7 +112,7 @@ function M.open(config, opts, callbacks) end browser.commits = next_commits render_log(browser) - end, map_opts) + end, map_opts("Log", "Refresh the log")) return browser, nil end diff --git a/tests/run.lua b/tests/run.lua index dac1c62..55a08cb 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -39,6 +39,7 @@ local specs = { "15_running.lua", "20_git_merge.lua", "25_launcher.lua", + "27_keyhelp.lua", "30_session_snap.lua", "32_document_cache.lua", "35_render_recovery.lua", diff --git a/tests/specs/27_keyhelp.lua b/tests/specs/27_keyhelp.lua new file mode 100644 index 0000000..a958aac --- /dev/null +++ b/tests/specs/27_keyhelp.lua @@ -0,0 +1,195 @@ +-- Suite: in-view key help (issue #21): mapping descriptions, the g? window, +-- and the header hints. Uses the git fixtures from 20_git_merge. + +local keyhelp = require("diffbandit.util.keyhelp") + +-- Every DiffBandit mapping in `buf` that `host` installed carries a +-- "DiffBandit: " description, so :map / which-key / g? can explain it. +local function assert_described(buf, label) + for _, map in ipairs(vim.api.nvim_buf_get_keymap(buf, "n")) do + if map.desc == nil or map.desc:sub(1, #keyhelp.PREFIX) ~= keyhelp.PREFIX then + error(string.format("%s: mapping %q has no DiffBandit description", label, map.lhs)) + end + end +end + +local function section_names(sections) + local names = {} + for _, section in ipairs(sections) do + names[#names + 1] = section.name + end + return table.concat(names, ",") +end + +local function find_row(sections, desc) + for _, section in ipairs(sections) do + for _, row in ipairs(section.rows) do + if row.desc == desc then + return row, section.name + end + end + end + return nil +end + +local function help_lines() + local win = vim.api.nvim_get_current_win() + return table.concat(vim.api.nvim_buf_get_lines(vim.api.nvim_win_get_buf(win), 0, -1, false), "\n") +end + +-- keyhelp: grouping, collapsing, display, and live state. +do + local buf = vim.api.nvim_create_buf(false, true) + local base = { buffer = buf } + vim.keymap.set("n", "]c", function() end, keyhelp.opts(buf, "Hunks", "Next hunk", base)) + vim.keymap.set("n", "", function() end, keyhelp.opts(buf, "Folder", "Open entry", base)) + vim.keymap.set("n", "o", function() end, keyhelp.opts(buf, "Folder", "Open entry", base)) + vim.keymap.set("n", "", function() end, keyhelp.opts(buf, "Staging and actions", "Stage", base)) + vim.keymap.set("n", "<<", function() end, keyhelp.opts(buf, "Staging and actions", "Apply right", base)) + vim.keymap.set("n", "g?", function() end, keyhelp.opts(buf, "General", "Show this key help", base)) + vim.keymap.set("n", "x", function() end, { buffer = buf, desc = "someone else's map" }) + + local sections = keyhelp.sections(buf) + assert_eq(section_names(sections), "General,Hunks,Staging and actions,Folder", + "Help sections follow the canonical order and skip empty ones") + local open_row = find_row(sections, "Open entry") + assert_eq(open_row and open_row.keys, ", o", "Keys sharing a description collapse into one row") + assert_eq((find_row(sections, "Stage") or {}).keys, "", "A space lhs displays as ") + assert_eq((find_row(sections, "Apply right") or {}).keys, "<<", "A < lhs displays as <, not ") + assert_eq(find_row(sections, "someone else's map"), nil, "Mappings from other plugins are not listed") + assert_eq(section_names(keyhelp.sections(buf, { "Folder" })), "Folder,General,Hunks,Staging and actions", + "A view can list its own sections first") + + vim.keymap.del("n", "]c", { buffer = buf }) + assert_eq(find_row(keyhelp.sections(buf), "Next hunk"), nil, "Help lists live mappings, so a removed key disappears") + + assert_eq(keyhelp.hint("g?"), "g? help", "Header hint names the help key") + assert_eq(keyhelp.hint(false), nil, "A disabled help key shows no hint") + vim.api.nvim_buf_delete(buf, { force = true }) +end + +-- 2-way git diff: descriptions, g? window, header hint, and the panel. +do + -- Wide enough for the header to fit the hint next to the path; narrower + -- headers drop the hint and keep the path. + local saved_columns = vim.o.columns + vim.o.columns = 220 + local repo = make_git_repo() + write_repo_file(repo, "a.txt", { "one", "two" }) + commit_baseline(repo) + write_repo_file(repo, "a.txt", { "one", "TWO" }) + local queue = assert((git_mod.queue({ root = repo, mode = "all" }, config.git))) + local loaded = assert((queue.load(1))) + queue.index = 1 + local session = assert((Session.start({ left = loaded.left, right = loaded.right }, config, { + queue = queue, + panel = true, + panel_initial_selection = 1, + }))) + state_mod.register(session) + + for _, field in ipairs({ "left_buf", "left_num_buf", "connector_buf", "right_num_buf" }) do + assert_described(session[field], "Diff " .. field) + end + assert_described(session.panel.nav_buf, "Panel file list") + + vim.api.nvim_set_current_win(session.right_win) + buffer_keymap_callback(session.right_buf, "n", "g?")() + assert_eq(keyhelp.is_open(), true, "g? in a diff pane opens the key help") + local text = help_lines() + assert_eq(text:find("Next hunk", 1, true) ~= nil, true, "Diff help lists hunk navigation") + assert_eq(text:find("Close this view", 1, true) ~= nil, true, "Diff help lists the close key") + assert_eq(text:find("Header", 1, true) ~= nil, true, "Diff help explains the compact header fields") + assert_eq(text:find(":DiffBanditClose", 1, true) ~= nil, true, "Help points at the close command") + keyhelp.close() + assert_eq(keyhelp.is_open(), false, "The help window closes") + + local header = vim.api.nvim_buf_get_lines(session.right_header_buf, 0, 1, false)[1] or "" + assert_eq(vim.trim(header):sub(-#"g? help"), "g? help", "The diff header shows the help hint on the right") + + local nav_hint = vim.api.nvim_buf_get_extmarks(session.panel.nav_buf, session.ns, { 0, 0 }, { 0, -1 }, { details = true }) + local found_hint = false + for _, mark in ipairs(nav_hint) do + for _, chunk in ipairs((mark[4] or {}).virt_text or {}) do + found_hint = found_hint or chunk[1] == "g? help" + end + end + assert_eq(found_hint, true, "The panel's first row shows the help hint") + + local label_marks = vim.api.nvim_buf_get_extmarks(session.panel.commit_buf, session.ns, { 1, 0 }, { 1, -1 }, { details = true }) + local label = "" + for _, mark in ipairs(label_marks) do + for _, chunk in ipairs((mark[4] or {}).virt_text or {}) do + label = label .. chunk[1] + end + end + assert_eq(label:find(":w commit", 1, true) ~= nil, true, "The commit message label says how to commit") + + vim.api.nvim_set_current_win(session.panel.nav_win) + buffer_keymap_callback(session.panel.nav_buf, "n", "g?")() + text = help_lines() + assert_eq(text:find("Stage or unstage the selected file", 1, true) ~= nil, true, "Panel help lists file staging") + assert_eq(text:find("Partly staged", 1, true) ~= nil, true, "Panel help explains the staged symbols") + keyhelp.close() + + vim.api.nvim_buf_set_lines(session.panel.commit_buf, 2, -1, false, { "" }) + session:close() + vim.o.columns = saved_columns +end + +-- Review hint line follows remapped keys; help_key = false removes help. +do + local review_config = config_mod.apply({ + navigation = { help_key = false }, + git = { panel = { keys = { focus_diff = "o", close = "Q" } } }, + }) + local fake = { + config = review_config, + file_queue = { kind = "git" }, + panel = { mode = "review" }, + } + assert_eq(panel_mod.review_help(fake), "o focus diff ]f/[f files ]c/[c hunks Q close", + "The review hint line uses the configured keys and omits a disabled help key") + fake.config = config + assert_eq(panel_mod.review_help(fake), " focus diff ]f/[f files ]c/[c hunks q close g? help", + "The default review hint line includes the help key") + + local left = vim.fn.tempname() + local right = vim.fn.tempname() + vim.fn.writefile({ "a" }, left) + vim.fn.writefile({ "b" }, right) + local session = assert((Session.start({ + left = source_mod.from_file(left, nil, review_config), + right = source_mod.from_file(right, nil, review_config), + }, review_config, {}))) + assert_eq(buffer_keymap_callback(session.left_buf, "n", "g?"), nil, "help_key = false leaves g? unmapped") + session:close() +end + +-- Merge resolver mappings are described too. +do + local repo = make_git_repo() + local session = assert((merge_mod.start({ + root = repo, + path = "help_conflict.txt", + base_lines = { "base" }, + local_lines = { "local" }, + remote_lines = { "remote" }, + result_lines = { "base" }, + conflicts = {}, + non_conflicting = {}, + local_hunks = {}, + remote_hunks = {}, + }, config, {}))) + for _, field in ipairs({ "local_buf", "remote_buf" }) do + assert_described(session[field], "Merge " .. field) + end + vim.api.nvim_set_current_win(session.result_win) + buffer_keymap_callback(session.result_buf, "n", "g?")() + local text = help_lines() + assert_eq(text:find("Accept local (ours) into the result", 1, true) ~= nil, true, "Merge help lists accept actions") + assert_eq(text:find(":w", 1, true) ~= nil, true, "Merge help explains writing the result") + keyhelp.close() + pcall(vim.api.nvim_set_option_value, "modified", false, { buf = session.result_buf }) + session:close() +end