Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 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!

### Managing Library Installation
Expand Down
41 changes: 41 additions & 0 deletions doc/codediff.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -443,6 +444,46 @@ 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 override matching
built-ins, appear in the `g?` keymap help, and are restored or removed with the
Explorer lifecycle.

==============================================================================
HIGHLIGHT GROUPS *codediff-highlight-groups*

Expand Down
1 change: 1 addition & 0 deletions doc/tags
Original file line number Diff line number Diff line change
Expand Up @@ -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*
Expand Down
1 change: 1 addition & 0 deletions lua/codediff/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
41 changes: 39 additions & 2 deletions lua/codediff/ui/explorer/keymaps.lua
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,42 @@ 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, panel_map)
for _, keymap in ipairs(custom_keymaps or {}) do
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

-- Setup keymaps for explorer panel
-- @param explorer: explorer object with tree, split, git_root, on_file_select, etc.
function M.setup(explorer)
Expand All @@ -21,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
Expand Down Expand Up @@ -189,6 +222,10 @@ function M.setup(explorer)
tabpage = explorer.tabpage,
})

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
end
Expand Down
45 changes: 38 additions & 7 deletions lua/codediff/ui/explorer/nodes.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {}
Expand Down
81 changes: 54 additions & 27 deletions lua/codediff/ui/keymap_help.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
{ "<Down>", "Move down / auto-open file" },
{ "<Up>", "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
Expand Down Expand Up @@ -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" },
{ "<Down>", "Move down / auto-open file" },
{ "<Up>", "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
Expand Down
22 changes: 22 additions & 0 deletions tests/keymap/keymap_help_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading
Loading