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