Skip to content

feat(explorer): custom keymaps - #492

Open
richardgill wants to merge 2 commits into
esmuellert:mainfrom
richardgill:explorer-custom-keymaps
Open

feat(explorer): custom keymaps#492
richardgill wants to merge 2 commits into
esmuellert:mainfrom
richardgill:explorer-custom-keymaps

Conversation

@richardgill

@richardgill richardgill commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Adds keymaps.explorer.custom for lifecycle-owned, buffer-local Explorer mappings.

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,
        },
      },
    },
  },
})

Callbacks receive this context:

{
  entry = selected_entry,
  redraw = function() end,
  refresh = function() end,
}

ctx.entry has exactly one of these shapes, based on the selected row:

{ 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.

  • redraw() renders the existing tree without rebuilding nodes, changing selection or fold state, or running Git.
  • refresh() uses the normal asynchronous Explorer refresh.
  • Custom mappings are lifecycle registry claims on the Explorer panel and intentionally override matching built-ins.
  • Registry teardown restores any previous buffer-local mapping and safely removes CodeDiff-owned mappings.
  • Custom mappings appear in the g? keymap help with their configured descriptions.
  • Both callback functions safely become no-ops after the Explorer closes.

Testing

  • Added focused coverage for registry ownership, suspension, restoration, built-in collision precedence, and g? visibility.
  • Retained callback coverage for file, directory, and group entries, line statistics, redraw, refresh, and post-teardown safety.
  • Ran the complete C and Lua suites with an isolated NVIM_APPNAME.

Manual testing

Used this configuration with one modified file at src/example.lua:

require("codediff").setup({
  explorer = {
    view_mode = "tree",
    flatten_dirs = false,
    auto_refresh = false,
  },
  keymaps = {
    explorer = {
      custom = {
        {
          key = "gp",
          desc = "Print explorer callback context",
          callback = function(ctx)
            vim.notify(vim.inspect(ctx))
          end,
        },
      },
    },
  },
})

Invoked gp on the file, its parent directory, and the Changes group. Each message shows the corresponding entry metadata and the shared redraw and refresh functions.

Without line statistics

custom-explorer-keymaps-without-line-stats.mp4

With line statistics

custom-explorer-keymaps-with-line-stats.mp4

Supersedes part of #448.

Fixes #455.
Fixes #388.

@richardgill richardgill added the Size/M Medium - Write logic, add tests (one evening) label Jul 29, 2026
@richardgill
richardgill force-pushed the explorer-custom-keymaps branch from 4395bbf to 190653b Compare August 1, 2026 06:59
@richardgill
richardgill marked this pull request as ready for review August 1, 2026 06:59
esmuellert added a commit that referenced this pull request Aug 1, 2026
## Summary

codediff installed every mapping with a bare `vim.keymap.set` from 38
call sites across 9 files, and cleaned up by deleting whatever key names
happened to be in config at the time. That model could not restore what
it overwrote, and could not remove what it had actually installed.

Mappings now go through a per-session registry backed by a global slot
arbiter keyed by `(bufnr, mode, canonical lhs)`. Each slot snapshots the
pre-existing buffer-local mapping on first claim and hands it back after
the last claim is released, so ownership is reference counted and
teardown is symmetric with setup.

Closes #289
Closes #334
Closes #394

## Reported issues fixed

Each is verified by following the reproduction steps from the issue as
written, in `tests/ui/keymap/issue_regressions_spec.lua`. The same
checks were run against unmodified `main` as a control:

| Issue | Check | main | this PR |
|---|---|---|---|
| #289 / #334 | buffer-local gitsigns `]c` restored after close | ✗ | ✓
|
| #289 / #334 | buffer-local gitsigns `[c` restored after close | ✗ | ✓
|
| #394 | `ih` released from operator-pending and visual mode | ✗ | ✓ |
| #394 | conflict mode no longer destroys the user's `do` | ✗ | ✓ |
| #394 | conflict mappings removed on close | ✗ | ✓ |

A note on #289/#334: a **global** gitsigns mapping already recovered on
`main`, because deleting codediff's buffer-local shadow reveals it. The
**buffer-local** form gitsigns documents for `on_attach` did not, and
that is the half this fixes. Both are now covered by tests.

#394 also lists window options (`scrollbind`/`cursorbind`). Those are
already moot — `scrollbind` is now only ever set to `false`, having been
replaced by structural scroll sync.

## Other defects fixed

Found by two adversarial audits and verified to reproduce on `main`:

- compact's synced-fold wrappers leaked into every other tab
- keymap setup was append-only, so `gm` survived a switch to inline,
conflict mappings survived leaving a merge, and reconfiguring `quit`
left both the old and new key bound
- a pane wiped while the session was suspended stranded mappings on
whichever pane survived
- changing `mapleader` mid-session made the installed key unaddressable,
so the user's mapping was never restored
- "the foreign mapping wins" did not hold when the restored mapping was
deleted while suspended, when the same RHS was re-mapped with different
options, or when another plugin reused codediff's own callback
- two sessions sharing one buffer corrupted each other's snapshot
- overlapping scopes could not layer: with `view.toggle_compact = "zo"`,
leaving compact removed the configured mapping instead of revealing it

## `g?` is generated from the registry

The help popup was a hand-maintained list and had drifted. It now
filters candidates through actual ownership, which fixed three
inaccuracies and cannot drift again:

- `gm` was advertised when `diff.compute_moves` is off (the default)
- `do`/`dp` were advertised in conflict mode, where they are not bound
- the hunk staging keys were hidden outside explorer mode despite being
bound
- explorer/history double click and the `auto_open_on_cursor` keys were
never listed

## Structure

`ui/view/keymaps.lua` went from 866 lines to 214. It now only declares
which key binds where; behavior moved unchanged to `ui/view/actions/`,
taking an explicit context instead of capturing buffers and layout flags
implicitly.

```
lua/codediff/keymap/{init,normalize,slots,registry}.lua   new, ~560 lines
lua/codediff/ui/view/actions/{hunk,diffget,panes,stage,move}.lua
```

## Compatibility

No public API change. The config shape, every default key,
`string`/`false` semantics, the deprecated
`keymaps.explorer.toggle_stage` fallback, `nowait`/`noremap`/`silent`
defaults, the `CodeDiff*` autocmd events and their payloads, and the
exported `next_hunk`/`prev_hunk`/`next_file`/`prev_file` functions are
all unchanged.

Minor version bump: no contract break, but buffer-local mappings
codediff overrides are now handed back on teardown, which users will
notice.

## Testing

- **80 spec files pass** (~35s)
- **Golden keymap matrix** (`tests/fixtures/keymap_matrix.txt`) pins
role × mode × lhs × desc for every session shape and verifies each
mapping is reachable through `maparg`, not merely present — that
distinction caught a double-encoding bug that broke `<2-LeftMouse>`,
`<Down>` and `<Up>`
- **Coverage spec** asserts all 115 configured mappings resolve in every
shape that provides them
- **Registry unit tests** (41) cover nested claims, out-of-order
disposal, foreign takeover, special keys, scopes and buffer wipeout
- **Issue regressions** follow each issue's own reproduction steps

## Known limitations

- Buffer-local mappings are not window-local. If a diff buffer is split
into a non-codediff tab while the session is live, codediff's mappings
are visible there. Neovim offers no mechanism to scope a mapping to a
window.
- If a keymap setup pass throws between `begin_scope` and `end_scope`,
claims made before the next pass are attributed to the aborted scope and
retired with it. Inherent to an ambient scope; an aborted setup is
already a failed state.

## Deliberately out of scope

#407 (multiple keys per action), #455/#492 (custom explorer keymaps),
#357 (same-LHS collision semantics, now diagnosable through the
registry). The registry leaves extension points for each.
@richardgill
richardgill force-pushed the explorer-custom-keymaps branch from 190653b to 35f3f81 Compare August 2, 2026 08:57
@richardgill

Copy link
Copy Markdown
Collaborator Author

@esmuellert I’ve rebased this onto the latest main and adapted it to the lifecycle keymap registry introduced in #505. Custom Explorer mappings now use lifecycle-owned panel claims, restore correctly on teardown, preserve override behavior, and appear in g? help. All tests and checks pass. Ready for another look when convenient!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Size/M Medium - Write logic, add tests (one evening)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature: support custom explorer keymaps Track viewed-file state during revision review

1 participant