From d7683704d02ab586ae86678ee0e27069a8df897a Mon Sep 17 00:00:00 2001 From: Richard Gill Date: Wed, 29 Jul 2026 06:43:18 +0100 Subject: [PATCH 1/2] feat(explorer): custom keymaps --- README.md | 35 +++ doc/codediff.txt | 40 ++++ doc/tags | 1 + lua/codediff/config.lua | 1 + lua/codediff/ui/explorer/keymaps.lua | 42 ++++ lua/codediff/ui/explorer/nodes.lua | 45 +++- tests/ui/explorer/custom_keymaps_spec.lua | 249 ++++++++++++++++++++++ 7 files changed, 406 insertions(+), 7 deletions(-) create mode 100644 tests/ui/explorer/custom_keymaps_spec.lua diff --git a/README.md b/README.md index ca133825..fe5110cf 100644 --- a/README.md +++ b/README.md @@ -193,6 +193,7 @@ https://github.com/user-attachments/assets/64c41f01-dffe-4318-bce4-16eec8de356e restore = "X", -- Discard changes (restore file) toggle_changes = "gu", -- Toggle Changes (unstaged) group visibility toggle_staged = "gs", -- Toggle Staged Changes group visibility + custom = {}, -- Additional { key, desc, callback } explorer keymaps -- Fold keymaps (Vim-style) fold_open = "zo", -- Open fold (expand current node) fold_open_recursive = "zO", -- Open fold recursively (expand all descendants) @@ -315,6 +316,40 @@ require("codediff").setup({ }) ``` +#### Custom explorer keymaps + +`keymaps.explorer.custom` adds buffer-local mappings to the Explorer. Each mapping has `key`, `desc`, and `callback`: + +```lua +require("codediff").setup({ + keymaps = { + explorer = { + custom = { + { + key = "gy", + desc = "Copy explorer path", + callback = function(ctx) + if ctx.entry.path then + vim.fn.setreg("+", ctx.entry.path) + end + end, + }, + }, + }, + }, +}) +``` + +The callback receives the selected Explorer row as `ctx.entry`: + +```lua +{ kind = "file", path, old_path, group, status, stats } +{ kind = "directory", name, path, group, stats, files } +{ kind = "group", name, group, stats, files } +``` + +Directory and group `files` use the normalized `{ path, old_path, group, status, stats }` shape. `stats` is `nil` when line statistics are disabled. `ctx.redraw()` renders the existing tree again without rebuilding nodes, changing selection or fold state, or running Git. `ctx.refresh()` starts the normal asynchronous Explorer refresh. Both functions become no-ops after the Explorer closes. Custom mappings are installed after built-in Explorer mappings, so matching keys intentionally override them. + The C library will be downloaded automatically on first use. No `build` step needed! ### Managing Library Installation diff --git a/doc/codediff.txt b/doc/codediff.txt index 63a3216b..363cffca 100644 --- a/doc/codediff.txt +++ b/doc/codediff.txt @@ -319,6 +319,7 @@ Setup entry point: restore = "X", toggle_changes = "gu", toggle_staged = "gs", + custom = {}, fold_open = "zo", fold_open_recursive = "zO", fold_close = "zc", @@ -443,6 +444,45 @@ Example: replace only group rows and right-align the file count: }) < +EXPLORER CUSTOM KEYMAPS *codediff-explorer-custom-keymaps* + +`keymaps.explorer.custom` adds buffer-local mappings to the Explorer. Each +mapping has `key`, `desc`, and `callback`: +>lua + require("codediff").setup({ + keymaps = { + explorer = { + custom = { + { + key = "gy", + desc = "Copy explorer path", + callback = function(ctx) + if ctx.entry.path then + vim.fn.setreg("+", ctx.entry.path) + end + end, + }, + }, + }, + }, + }) +< + +The callback receives the selected row as `ctx.entry`, in one of these shapes: +>lua + { kind = "file", path, old_path, group, status, stats } + { kind = "directory", name, path, group, stats, files } + { kind = "group", name, group, stats, files } +< + +Directory and group `files` use the normalized +`{ path, old_path, group, status, stats }` shape. `stats` is nil when line +statistics are disabled. `ctx.redraw()` renders the existing tree again without +rebuilding nodes, changing selection or fold state, or running Git. +`ctx.refresh()` starts the normal asynchronous Explorer refresh. Both functions +become no-ops after the Explorer closes. Custom mappings are installed after +built-in Explorer mappings, so matching keys intentionally override them. + ============================================================================== HIGHLIGHT GROUPS *codediff-highlight-groups* diff --git a/doc/tags b/doc/tags index f1c5ef03..87ca85b4 100644 --- a/doc/tags +++ b/doc/tags @@ -5,6 +5,7 @@ codediff codediff.txt /*codediff* codediff-commands codediff.txt /*codediff-commands* codediff-configuration codediff.txt /*codediff-configuration* codediff-events codediff.txt /*codediff-events* +codediff-explorer-custom-keymaps codediff.txt /*codediff-explorer-custom-keymaps* codediff-explorer-line-formatters codediff.txt /*codediff-explorer-line-formatters* codediff-explorer-line-statistics codediff.txt /*codediff-explorer-line-statistics* codediff-filler-text codediff.txt /*codediff-filler-text* diff --git a/lua/codediff/config.lua b/lua/codediff/config.lua index 51d54336..99ebdb39 100644 --- a/lua/codediff/config.lua +++ b/lua/codediff/config.lua @@ -137,6 +137,7 @@ M.defaults = { restore = "X", -- Discard changes to file (restore to index/HEAD) toggle_changes = "gu", -- Toggle Changes (unstaged) group visibility toggle_staged = "gs", -- Toggle Staged Changes group visibility + custom = {}, -- Additional { key, desc, callback } explorer keymaps -- Fold keymaps (Vim-style) fold_open = "zo", -- Open fold (expand current node) fold_open_recursive = "zO", -- Open fold recursively (expand current node and all descendants) diff --git a/lua/codediff/ui/explorer/keymaps.lua b/lua/codediff/ui/explorer/keymaps.lua index 66cb09d4..67a2e7bf 100644 --- a/lua/codediff/ui/explorer/keymaps.lua +++ b/lua/codediff/ui/explorer/keymaps.lua @@ -3,10 +3,50 @@ local config = require("codediff.config") local resolve = require("codediff.keymap.resolve") local actions_module = require("codediff.ui.explorer.actions") local refresh_module = require("codediff.ui.explorer.refresh") +local nodes_module = require("codediff.ui.explorer.nodes") local tree_utils = require("codediff.ui.lib.tree_utils") local M = {} +local function is_explorer_alive(explorer) + return vim.api.nvim_buf_is_valid(explorer.bufnr) and vim.api.nvim_tabpage_is_valid(explorer.tabpage) +end + +local function custom_keymap_context(explorer, node) + return { + entry = nodes_module.get_entry(node), + redraw = function() + if is_explorer_alive(explorer) then + explorer.tree:render() + end + end, + refresh = function() + if is_explorer_alive(explorer) then + refresh_module.refresh(explorer) + end + end, + } +end + +local function setup_custom_keymaps(explorer, custom_keymaps, map_options) + for _, keymap in ipairs(custom_keymaps or {}) do + vim.keymap.set( + "n", + keymap.key, + function() + local node = explorer.tree:get_node() + if node then + keymap.callback(custom_keymap_context(explorer, node)) + end + end, + vim.tbl_extend("force", map_options, { + buffer = explorer.bufnr, + desc = keymap.desc, + }) + ) + end +end + -- Setup keymaps for explorer panel -- @param explorer: explorer object with tree, split, git_root, on_file_select, etc. function M.setup(explorer) @@ -189,6 +229,8 @@ function M.setup(explorer) tabpage = explorer.tabpage, }) + setup_custom_keymaps(explorer, explorer_keymaps.custom, map_options) + -- Note: next_file/prev_file keymaps are set via view/keymaps.lua:setup_all_keymaps() -- which uses set_tab_keymap to set them on all buffers including explorer end diff --git a/lua/codediff/ui/explorer/nodes.lua b/lua/codediff/ui/explorer/nodes.lua index dae0e576..2e4ae070 100644 --- a/lua/codediff/ui/explorer/nodes.lua +++ b/lua/codediff/ui/explorer/nodes.lua @@ -96,16 +96,20 @@ local function context_stats(stats) return vim.deepcopy(stats) end +local function normalize_file(file, group) + return { + path = file.path, + old_path = file.old_path, + group = group or file.group, + status = file.status, + stats = context_stats(file.line_stats or file.stats), + } +end + local function normalize_files(files, group) local normalized = {} for _, file in ipairs(files or {}) do - normalized[#normalized + 1] = { - path = file.path, - old_path = file.old_path, - group = group or file.group, - status = file.status, - stats = context_stats(file.line_stats), - } + normalized[#normalized + 1] = normalize_file(file, group) end return normalized end @@ -352,6 +356,33 @@ local function selected_background(is_selected) return vim.api.nvim_get_hl(0, { name = "CodeDiffExplorerSelected", link = false }).bg end +function M.get_entry(node) + local data = node.data or {} + if data.type == "group" then + return { + kind = "group", + name = data.name, + group = data.name, + stats = context_stats(data.stats), + files = normalize_files(data.files, data.name), + } + end + if data.type == "directory" then + return { + kind = "directory", + name = data.name, + path = data.dir_path, + group = data.group, + stats = context_stats(data.stats), + files = normalize_files(data.files, data.group), + } + end + + local entry = normalize_file(data, data.group) + entry.kind = "file" + return entry +end + -- Prepare node for rendering (format display) function M.prepare_node(node, max_width, selected_path, selected_group) local data = node.data or {} diff --git a/tests/ui/explorer/custom_keymaps_spec.lua b/tests/ui/explorer/custom_keymaps_spec.lua new file mode 100644 index 00000000..13e3cacd --- /dev/null +++ b/tests/ui/explorer/custom_keymaps_spec.lua @@ -0,0 +1,249 @@ +local Tree = require("codediff.ui.lib.tree") +local Line = require("codediff.ui.lib.line") +local config = require("codediff.config") +local keymaps = require("codediff.ui.explorer.keymaps") +local nodes = require("codediff.ui.explorer.nodes") +local refresh = require("codediff.ui.explorer.refresh") +local explorer_tree = require("codediff.ui.explorer.tree") + +local original_refresh = refresh.refresh + +local reset_config = function() + config.options = vim.deepcopy(config.defaults) + config.options.explorer.view_mode = "tree" + config.options.explorer.flatten_dirs = false +end + +local create_explorer = function(prepare_node) + local previous_bufnr = vim.api.nvim_get_current_buf() + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_win_set_buf(0, bufnr) + local files = { + { + path = "src/renamed.lua", + old_path = "src/old.lua", + status = "M", + line_stats = { insertions = 5, deletions = 2, binary = false }, + }, + { path = "src/new.lua", status = "A", line_stats = { insertions = 3, deletions = 1, binary = false } }, + } + local roots = explorer_tree.create_tree_data({ unstaged = files, staged = {}, conflicts = {} }, "/repo", nil, false, { + unstaged = true, + staged = false, + }) + local tree = Tree({ + bufnr = bufnr, + nodes = roots, + prepare_node = prepare_node and function(node) + local line = Line() + line:append(prepare_node(node), "Normal") + return line + end, + }) + roots[1]:expand() + roots[1]._children[1]:expand() + tree:render() + return { + previous_bufnr = previous_bufnr, + root = roots[1], + directory = roots[1]._children[1], + file = roots[1]._children[1]._children[2], + explorer = { + bufnr = bufnr, + winid = vim.api.nvim_get_current_win(), + tabpage = vim.api.nvim_get_current_tabpage(), + split = { bufnr = bufnr }, + tree = tree, + }, + } +end + +local cleanup_explorer = function(fixture) + if vim.api.nvim_buf_is_valid(fixture.previous_bufnr) then + vim.api.nvim_win_set_buf(0, fixture.previous_bufnr) + end + if vim.api.nvim_buf_is_valid(fixture.explorer.bufnr) then + vim.api.nvim_buf_delete(fixture.explorer.bufnr, { force = true }) + end +end + +describe("Explorer custom keymaps", function() + before_each(function() + reset_config() + refresh.refresh = original_refresh + end) + + after_each(function() + refresh.refresh = original_refresh + config.options = vim.deepcopy(config.defaults) + end) + + it("exposes stable file, directory, and group entries", function() + local fixture = create_explorer() + + assert.same({ + kind = "file", + path = "src/renamed.lua", + old_path = "src/old.lua", + group = "unstaged", + status = "M", + }, nodes.get_entry(fixture.file)) + assert.same({ + kind = "directory", + name = "src", + path = "src", + group = "unstaged", + files = { + { path = "src/renamed.lua", old_path = "src/old.lua", group = "unstaged", status = "M" }, + { path = "src/new.lua", group = "unstaged", status = "A" }, + }, + }, nodes.get_entry(fixture.directory)) + assert.same({ + kind = "group", + name = "unstaged", + group = "unstaged", + files = { + { path = "src/renamed.lua", old_path = "src/old.lua", group = "unstaged", status = "M" }, + { path = "src/new.lua", group = "unstaged", status = "A" }, + }, + }, nodes.get_entry(fixture.root)) + + cleanup_explorer(fixture) + end) + + it("includes line stats in callback entries when enabled", function() + config.options.explorer.line_stats.enabled = true + local fixture = create_explorer() + local totals = { + files_changed = 2, + insertions = 8, + deletions = 3, + binary_files = 0, + unavailable_files = 0, + } + + assert.same({ insertions = 5, deletions = 2, binary = false }, nodes.get_entry(fixture.file).stats) + assert.same(totals, nodes.get_entry(fixture.directory).stats) + assert.same(totals, nodes.get_entry(fixture.root).stats) + assert.same({ + { + path = "src/renamed.lua", + old_path = "src/old.lua", + group = "unstaged", + status = "M", + stats = { insertions = 5, deletions = 2, binary = false }, + }, + { + path = "src/new.lua", + group = "unstaged", + status = "A", + stats = { insertions = 3, deletions = 1, binary = false }, + }, + }, nodes.get_entry(fixture.directory).files) + + cleanup_explorer(fixture) + end) + + it("uses custom descriptions and lets custom mappings override built-ins", function() + local fixture = create_explorer() + local calls = 0 + local refresh_calls = 0 + refresh.refresh = function() + refresh_calls = refresh_calls + 1 + end + config.options.keymaps.explorer.custom = { + { + key = "R", + desc = "Custom refresh action", + callback = function() + calls = calls + 1 + end, + }, + } + + keymaps.setup(fixture.explorer) + local mapping = vim.fn.maparg("R", "n", false, true) + mapping.callback() + + assert.equals("Custom refresh action", mapping.desc) + assert.equals(1, mapping.buffer) + assert.equals(1, calls) + assert.equals(0, refresh_calls) + cleanup_explorer(fixture) + end) + + it("redraws the existing tree without changing identity, folds, or selection", function() + local reviewed = false + local fixture = create_explorer(function(node) + return node.text .. (reviewed and " reviewed" or "") + end) + fixture.directory:collapse() + fixture.explorer.tree:render() + vim.api.nvim_win_set_cursor(0, { 2, 0 }) + local root_id = fixture.root:get_id() + local directory_id = fixture.directory:get_id() + config.options.keymaps.explorer.custom = { + { + key = "m", + desc = "Mark reviewed", + callback = function(ctx) + reviewed = true + ctx.redraw() + end, + }, + } + + keymaps.setup(fixture.explorer) + vim.fn.maparg("m", "n", false, true).callback() + + assert.equals(fixture.root, fixture.explorer.tree:get_nodes()[1]) + assert.equals(fixture.root, fixture.explorer.tree:get_node(root_id)) + assert.equals(fixture.directory, fixture.explorer.tree:get_node(directory_id)) + assert.is_true(fixture.root:is_expanded()) + assert.is_false(fixture.directory:is_expanded()) + assert.same({ 2, 0 }, vim.api.nvim_win_get_cursor(0)) + assert.equals("src reviewed", vim.api.nvim_buf_get_lines(fixture.explorer.bufnr, 1, 2, false)[1]) + cleanup_explorer(fixture) + end) + + it("forwards refresh and becomes safe after the explorer closes", function() + local fixture = create_explorer() + local context + local refresh_calls = 0 + local render_calls = 0 + config.options.keymaps.explorer.custom = { + { + key = "x", + desc = "Capture context", + callback = function(ctx) + context = ctx + end, + }, + } + refresh.refresh = function(explorer) + assert.equals(fixture.explorer, explorer) + refresh_calls = refresh_calls + 1 + end + + keymaps.setup(fixture.explorer) + vim.fn.maparg("x", "n", false, true).callback() + context.refresh() + assert.equals(1, refresh_calls) + + fixture.explorer.tree.render = function() + render_calls = render_calls + 1 + end + fixture.explorer.tabpage = 999999 + assert.has_no.errors(context.redraw) + assert.has_no.errors(context.refresh) + assert.equals(0, render_calls) + assert.equals(1, refresh_calls) + + fixture.explorer.tabpage = vim.api.nvim_get_current_tabpage() + cleanup_explorer(fixture) + assert.has_no.errors(context.redraw) + assert.has_no.errors(context.refresh) + assert.equals(0, render_calls) + assert.equals(1, refresh_calls) + end) +end) From 35f3f81d58873503214f2de1a1335686f056d724 Mon Sep 17 00:00:00 2001 From: Richard Gill Date: Sun, 2 Aug 2026 09:57:18 +0100 Subject: [PATCH 2/2] refactor(explorer): own custom keymaps through lifecycle --- README.md | 2 +- doc/codediff.txt | 5 +- lua/codediff/ui/explorer/keymaps.lua | 31 ++++----- lua/codediff/ui/keymap_help.lua | 81 +++++++++++++++-------- tests/keymap/keymap_help_spec.lua | 22 ++++++ tests/ui/explorer/custom_keymaps_spec.lua | 54 +++++++++++++-- 6 files changed, 140 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index fe5110cf..f04cae09 100644 --- a/README.md +++ b/README.md @@ -348,7 +348,7 @@ The callback receives the selected Explorer row as `ctx.entry`: { kind = "group", name, group, stats, files } ``` -Directory and group `files` use the normalized `{ path, old_path, group, status, stats }` shape. `stats` is `nil` when line statistics are disabled. `ctx.redraw()` renders the existing tree again without rebuilding nodes, changing selection or fold state, or running Git. `ctx.refresh()` starts the normal asynchronous Explorer refresh. Both functions become no-ops after the Explorer closes. Custom mappings are installed after built-in Explorer mappings, so matching keys intentionally override them. +Directory and group `files` use the normalized `{ path, old_path, group, status, stats }` shape. `stats` is `nil` when line statistics are disabled. `ctx.redraw()` renders the existing tree again without rebuilding nodes, changing selection or fold state, or running Git. `ctx.refresh()` starts the normal asynchronous Explorer refresh. Both functions become no-ops after the Explorer closes. Custom mappings override matching built-ins, appear in the `g?` keymap help, and are restored or removed with the Explorer lifecycle. The C library will be downloaded automatically on first use. No `build` step needed! diff --git a/doc/codediff.txt b/doc/codediff.txt index 363cffca..4f1d17d0 100644 --- a/doc/codediff.txt +++ b/doc/codediff.txt @@ -480,8 +480,9 @@ Directory and group `files` use the normalized statistics are disabled. `ctx.redraw()` renders the existing tree again without rebuilding nodes, changing selection or fold state, or running Git. `ctx.refresh()` starts the normal asynchronous Explorer refresh. Both functions -become no-ops after the Explorer closes. Custom mappings are installed after -built-in Explorer mappings, so matching keys intentionally override them. +become no-ops after the Explorer closes. Custom mappings override matching +built-ins, appear in the `g?` keymap help, and are restored or removed with the +Explorer lifecycle. ============================================================================== HIGHLIGHT GROUPS *codediff-highlight-groups* diff --git a/lua/codediff/ui/explorer/keymaps.lua b/lua/codediff/ui/explorer/keymaps.lua index 67a2e7bf..4c2c4c38 100644 --- a/lua/codediff/ui/explorer/keymaps.lua +++ b/lua/codediff/ui/explorer/keymaps.lua @@ -28,22 +28,14 @@ local function custom_keymap_context(explorer, node) } end -local function setup_custom_keymaps(explorer, custom_keymaps, map_options) +local function setup_custom_keymaps(explorer, custom_keymaps, panel_map) for _, keymap in ipairs(custom_keymaps or {}) do - vim.keymap.set( - "n", - keymap.key, - function() - local node = explorer.tree:get_node() - if node then - keymap.callback(custom_keymap_context(explorer, node)) - end - end, - vim.tbl_extend("force", map_options, { - buffer = explorer.bufnr, - desc = keymap.desc, - }) - ) + panel_map(keymap.key, function() + local node = explorer.tree:get_node() + if node then + keymap.callback(custom_keymap_context(explorer, node)) + end + end, keymap.desc, { priority = 1 }) end end @@ -61,8 +53,9 @@ function M.setup(explorer) -- leak into user buffers and must survive tab switches (suspendable=false). -- Required lazily to avoid a module cycle through the lifecycle package. local lifecycle = require("codediff.ui.lifecycle") - local function panel_map(lhs, rhs, desc) - lifecycle.set_buf_keymap(explorer.tabpage, split.bufnr, "n", lhs, rhs, vim.tbl_extend("force", map_options, { desc = desc }), { suspendable = false }) + local function panel_map(lhs, rhs, desc, meta) + local claim = vim.tbl_extend("force", { suspendable = false }, meta or {}) + lifecycle.set_buf_keymap(explorer.tabpage, split.bufnr, "n", lhs, rhs, vim.tbl_extend("force", map_options, { desc = desc }), claim) end -- Toggle expand/collapse or select file @@ -229,7 +222,9 @@ function M.setup(explorer) tabpage = explorer.tabpage, }) - setup_custom_keymaps(explorer, explorer_keymaps.custom, map_options) + lifecycle.begin_keymap_scope(explorer.tabpage, "explorer-custom") + setup_custom_keymaps(explorer, explorer_keymaps.custom, panel_map) + lifecycle.end_keymap_scope(explorer.tabpage, "explorer-custom") -- Note: next_file/prev_file keymaps are set via view/keymaps.lua:setup_all_keymaps() -- which uses set_tab_keymap to set them on all buffers including explorer diff --git a/lua/codediff/ui/keymap_help.lua b/lua/codediff/ui/keymap_help.lua index 37758b03..aa647545 100644 --- a/lua/codediff/ui/keymap_help.lua +++ b/lua/codediff/ui/keymap_help.lua @@ -47,6 +47,59 @@ local function section(title, entries, is_bound) return { title = title, items = items } end +local function explorer_entries(keymaps) + local custom = type(keymaps.custom) == "table" and keymaps.custom or {} + local overridden = {} + for index, mapping in ipairs(custom) do + local key = normalize.canonical(mapping.key) + if key then + overridden[key] = index + end + end + + local builtins = { + { keymaps.select, "Select / toggle expand" }, + { MOUSE_SELECT, "Select file (double click)" }, + { "j", "Move down / auto-open file" }, + { "k", "Move up / auto-open file" }, + { "", "Move down / auto-open file" }, + { "", "Move up / auto-open file" }, + { keymaps.hover, "Show full path" }, + { keymaps.refresh, "Refresh explorer" }, + { keymaps.toggle_view_mode, "Toggle list/tree view" }, + { keymaps.stage_all, "Stage all files" }, + { keymaps.unstage_all, "Unstage all files" }, + { keymaps.restore, "Discard changes to file" }, + { keymaps.toggle_changes, "Toggle Changes visibility" }, + { keymaps.toggle_staged, "Toggle Staged visibility" }, + { keymaps.fold_open, "Open fold" }, + { keymaps.fold_open_recursive, "Open fold recursively" }, + { keymaps.fold_close, "Close fold" }, + { keymaps.fold_close_recursive, "Close fold recursively" }, + { keymaps.fold_toggle, "Toggle fold" }, + { keymaps.fold_toggle_recursive, "Toggle fold recursively" }, + { keymaps.fold_open_all, "Open all folds" }, + { keymaps.fold_close_all, "Close all folds" }, + } + + local entries = {} + for _, entry in ipairs(builtins) do + local keys = vim.tbl_filter(function(key) + return overridden[normalize.canonical(key)] == nil + end, normalize.key_list(entry[1])) + if #keys > 0 then + table.insert(entries, { keys, entry[2] }) + end + end + for index, mapping in ipairs(custom) do + local key = normalize.canonical(mapping.key) + if key and overridden[key] == index then + table.insert(entries, { mapping.key, mapping.desc }) + end + end + return entries +end + -- Build sections for the current session. -- -- Section inclusion follows the session shape (a standalone diff has no @@ -85,33 +138,7 @@ local function build_sections(keymaps, is_bound, shape) if shape.explorer then local ekm = keymaps.explorer - table.insert( - sections, - section("EXPLORER", { - { ekm.select, "Select / toggle expand" }, - { MOUSE_SELECT, "Select file (double click)" }, - { "j", "Move down / auto-open file" }, - { "k", "Move up / auto-open file" }, - { "", "Move down / auto-open file" }, - { "", "Move up / auto-open file" }, - { ekm.hover, "Show full path" }, - { ekm.refresh, "Refresh explorer" }, - { ekm.toggle_view_mode, "Toggle list/tree view" }, - { ekm.stage_all, "Stage all files" }, - { ekm.unstage_all, "Unstage all files" }, - { ekm.restore, "Discard changes to file" }, - { ekm.toggle_changes, "Toggle Changes visibility" }, - { ekm.toggle_staged, "Toggle Staged visibility" }, - { ekm.fold_open, "Open fold" }, - { ekm.fold_open_recursive, "Open fold recursively" }, - { ekm.fold_close, "Close fold" }, - { ekm.fold_close_recursive, "Close fold recursively" }, - { ekm.fold_toggle, "Toggle fold" }, - { ekm.fold_toggle_recursive, "Toggle fold recursively" }, - { ekm.fold_open_all, "Open all folds" }, - { ekm.fold_close_all, "Close all folds" }, - }, is_bound) - ) + table.insert(sections, section("EXPLORER", explorer_entries(ekm), is_bound)) end if shape.history then diff --git a/tests/keymap/keymap_help_spec.lua b/tests/keymap/keymap_help_spec.lua index 653e8dda..895d0561 100644 --- a/tests/keymap/keymap_help_spec.lua +++ b/tests/keymap/keymap_help_spec.lua @@ -270,6 +270,28 @@ describe("keymap help popup", function() cleanup() end) + it("shows custom Explorer mappings in place of overridden built-ins", function() + local tabpage, cleanup = open_explorer({ + keymaps = { + explorer = { + custom = { + { + key = "R", + desc = "Inspect explorer entry", + callback = function() end, + }, + }, + }, + }, + }) + + local rendered = table.concat(help_lines(tabpage), "\n") + assert.is_truthy(rendered:find("Inspect explorer entry", 1, true)) + assert.is_nil(rendered:find("Refresh explorer", 1, true)) + + cleanup() + end) + it("does not advertise gm when move detection is off", function() -- compute_moves defaults to false, so align_move is never bound. local tabpage, cleanup = open_standalone() diff --git a/tests/ui/explorer/custom_keymaps_spec.lua b/tests/ui/explorer/custom_keymaps_spec.lua index 13e3cacd..84e371d9 100644 --- a/tests/ui/explorer/custom_keymaps_spec.lua +++ b/tests/ui/explorer/custom_keymaps_spec.lua @@ -2,6 +2,7 @@ local Tree = require("codediff.ui.lib.tree") local Line = require("codediff.ui.lib.line") local config = require("codediff.config") local keymaps = require("codediff.ui.explorer.keymaps") +local lifecycle = require("codediff.ui.lifecycle") local nodes = require("codediff.ui.explorer.nodes") local refresh = require("codediff.ui.explorer.refresh") local explorer_tree = require("codediff.ui.explorer.tree") @@ -43,22 +44,30 @@ local create_explorer = function(prepare_node) roots[1]:expand() roots[1]._children[1]:expand() tree:render() + local tabpage = vim.api.nvim_get_current_tabpage() + local winid = vim.api.nvim_get_current_win() + local explorer = { + bufnr = bufnr, + winid = winid, + tabpage = tabpage, + split = { bufnr = bufnr }, + tree = tree, + } + local empty_ref = { relative = "" } + lifecycle.create_session(tabpage, "explorer", nil, empty_ref, empty_ref, nil, nil, previous_bufnr, previous_bufnr, winid, winid, {}) + lifecycle.set_explorer(tabpage, explorer) + return { previous_bufnr = previous_bufnr, root = roots[1], directory = roots[1]._children[1], file = roots[1]._children[1]._children[2], - explorer = { - bufnr = bufnr, - winid = vim.api.nvim_get_current_win(), - tabpage = vim.api.nvim_get_current_tabpage(), - split = { bufnr = bufnr }, - tree = tree, - }, + explorer = explorer, } end local cleanup_explorer = function(fixture) + lifecycle.cleanup(fixture.explorer.tabpage) if vim.api.nvim_buf_is_valid(fixture.previous_bufnr) then vim.api.nvim_win_set_buf(0, fixture.previous_bufnr) end @@ -162,6 +171,14 @@ describe("Explorer custom keymaps", function() } keymaps.setup(fixture.explorer) + lifecycle.begin_keymap_scope(fixture.explorer.tabpage, "view") + lifecycle.set_tab_keymap(fixture.explorer.tabpage, "n", "R", function() + refresh_calls = refresh_calls + 1 + end, { desc = "Later built-in action" }) + lifecycle.end_keymap_scope(fixture.explorer.tabpage, "view") + lifecycle.set_buf_keymap(fixture.explorer.tabpage, fixture.explorer.bufnr, "n", "R", function() + refresh_calls = refresh_calls + 1 + end, { desc = "Later panel action" }, { suspendable = false }) local mapping = vim.fn.maparg("R", "n", false, true) mapping.callback() @@ -172,6 +189,29 @@ describe("Explorer custom keymaps", function() cleanup_explorer(fixture) end) + it("keeps panel ownership through suspension and restores the previous mapping on teardown", function() + local fixture = create_explorer() + vim.keymap.set("n", "x", function() end, { buffer = fixture.explorer.bufnr, desc = "Previous mapping" }) + config.options.keymaps.explorer.custom = { + { + key = "x", + desc = "Custom panel action", + callback = function() end, + }, + } + + keymaps.setup(fixture.explorer) + assert.is_true(lifecycle.owns_keymap(fixture.explorer.tabpage, "x", "n", fixture.explorer.bufnr)) + assert.equals("Custom panel action", vim.fn.maparg("x", "n", false, true).desc) + + lifecycle.clear_tab_keymaps(fixture.explorer.tabpage) + assert.equals("Custom panel action", vim.fn.maparg("x", "n", false, true).desc) + + lifecycle.dispose_keymaps(fixture.explorer.tabpage) + assert.equals("Previous mapping", vim.fn.maparg("x", "n", false, true).desc) + cleanup_explorer(fixture) + end) + it("redraws the existing tree without changing identity, folds, or selection", function() local reviewed = false local fixture = create_explorer(function(node)