Skip to content

refactor(keymap): own mappings through a slot registry - #505

Merged
esmuellert merged 5 commits into
mainfrom
refactor/keymap-registry
Aug 1, 2026
Merged

refactor(keymap): own mappings through a slot registry#505
esmuellert merged 5 commits into
mainfrom
refactor/keymap-registry

Conversation

@esmuellert

Copy link
Copy Markdown
Owner

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.

esmuellert and others added 5 commits August 1, 2026 03:06
Every codediff mapping was installed with a bare vim.keymap.set from one of
38 call sites, and cleanup deleted whatever key names happened to be in
config at the time. That model could not restore what it overwrote, and it
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.

Fixes:
- a pre-existing buffer-local mapping (q, and plugin maps such as gitsigns
  ]c / [c) is restored on close instead of being destroyed (#289, #334, and
  the keymap half of #394)
- ih is released from operator-pending and visual mode, not just normal
- the previous file's buffer is detached when the diff switches files
- conflict mode no longer deletes the user's do/dp; the view layer withdraws
  its claim so the original mapping is restored for the merge
- two sessions sharing one buffer no longer corrupt each other's snapshot
- a mapping replaced by another plugin while codediff is active is left
  alone: the foreign mapping wins, and is never restored over

g? is now derived from the registry rather than a hand-maintained list,
which fixes three drifts: gm shown when diff.compute_moves is off, do/dp
shown in conflict mode, and the hunk staging keys hidden outside explorer
mode. Double click, the explorer auto-open keys and the compact fold wraps
are accounted for; the fold wraps opt out of the popup because they are
transparent wrappers over native Vim keys.

Public config, defaults and behavior are unchanged. A golden matrix fixture
pins role x mode x lhs x desc for every session shape and verifies each
mapping is reachable through maparg, which catches mappings that are listed
but encoded twice (the trap that briefly broke <2-LeftMouse>, <Down> and
<Up>). A coverage spec asserts all 115 configured mappings resolve in every
shape that provides them.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
An adversarial review of the registry found seven defects. Four of them
already existed on main and were simply invisible before mappings became
tracked claims; three were gaps in the new machinery itself.

Pre-existing, now fixed:
- compact's synced-fold wrappers sit on real diff buffers, so they must be
  released when the user leaves the tab. They were marked non-suspendable,
  which is only correct for codediff-owned panel buffers.
- keymap setup could only add. Mappings from a previous session shape stayed
  installed: gm after switching to inline, conflict mappings after leaving a
  merge, and the old key after the quit binding was reconfigured. Setup
  passes now run inside a named scope and retire whatever the pass did not
  re-claim; leaving conflict mode retires the conflict scope outright.
- a pane wiped while the session was suspended made resume_diff drop the
  session without disposing its registry, stranding codediff's mappings on
  whichever pane survived. TabEnter now validates the panes first, and
  resume_diff disposes before it orphans anything.

Gaps in the new machinery:
- the slot stored the configured spelling, so changing mapleader mid-session
  made the installed key unaddressable and the user's mapping was never
  restored. The API spelling is now frozen with keytrans at claim time, while
  the canonical bytes remain the slot identity.
- "the foreign mapping wins" did not hold in two cases: deleting the restored
  mapping while codediff was suspended resurrected it on dispose, and
  re-mapping the same RHS with different options was overwritten on resume.
  Absence now counts as displacement when a snapshot exists, and mapping
  identity compares expr, noremap, silent, nowait, script, replace_keycodes
  and desc rather than the RHS alone.
- owns() reported cached state, so g? could advertise a key another plugin
  had taken over. Ownership queries now verify against the installed mapping.

BufWipeout is wired to drop slot bookkeeping for buffers that no longer
exist; forget_buffer previously had no caller outside tests.

Adds ten regression tests covering the transitions these defects hid in:
mapleader change, foreign delete and options change while suspended,
displacement, scope retirement, compact tab switch, layout toggle and
reconfiguration. Verified against unmodified main, where all four
pre-existing defects reproduce.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
ui/view/keymaps.lua carried both jobs: it decided which key binds where, and
it implemented what every key does. At 866 lines, 77% of it was action
bodies, which made the binding rules hard to read and hid a real hazard —
each action closed over tabpage, both buffer numbers and the layout flags,
so a mapping installed for one diff could still be holding a buffer the
session had already replaced.

The action bodies move to ui/view/actions/ unchanged, and take an explicit
context instead of capturing it:

  keymaps.lua   866 -> 214   declarations only
  actions/hunk.lua     252   hunk lookup, patch building, stage/unstage/
                             discard, and the ih textobject
  actions/panes.lua    156   explorer toggle and focus, open-in-previous-tab
  actions/move.lua     147   gm alignment
  actions/diffget.lua  118   do / dp
  actions/stage.lua     70   toggle_stage, toggle_staged_view

Making the capture explicit is the point: the values an action depends on are
now visible in its signature rather than inherited from whichever setup pass
created it.

The other keymap modules are left alone. conflict/keymaps.lua already
delegates to its own action modules, and the explorer and history panels bind
panel-scoped objects that live and die with the panel, so they do not carry
the same hazard.

Behavior is unchanged: every extracted body is identical to its original
modulo the context parameter, and the golden keymap matrix still matches.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
A second adversarial review found two more ways the registry could take a key
that was no longer its own, plus a case where releasing one scope destroyed a
mapping belonging to another.

- installed_is_ours() compared only the callback or right-hand side, so a
  plugin that re-mapped the same RHS with different options — or reused
  codediff's own dispatcher — was still treated as ours and deleted on
  teardown. The slot now records the mapping exactly as Neovim stored it and
  compares the whole thing, matching what same_map already did elsewhere.

- Claims were keyed by owner alone. Two setup passes in one session claiming
  the same key (a configured `view.toggle_compact = "zo"` alongside compact's
  own zo wrapper) meant the second replaced the first, and releasing it left
  the key unmapped instead of revealing the earlier claim. Claim identity is
  now (owner, scope), so overlapping scopes layer and unwind in order.

- Scopes are tracked on a stack rather than a single mutable field, and
  end_scope takes the scope name so a pass that aborted before closing cannot
  leave the stack dirty. Nested passes now restore their parent. A claim made
  after an aborted pass is still attributed to it; that is inherent to an
  ambient scope and is noted in the code.

Adds five regression tests: active remap with changed options, active remap
reusing our callback, overlapping scope release, nested scopes, and an
aborted pass not growing the stack.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds a spec that follows the reproduction steps from each issue as written,
so a future change that reopens one fails by issue number rather than as an
unexplained assertion somewhere else:

  #289 / #334  gitsigns [c and ]c stop working after closing CodeDiff
  #211 / #224  keymaps not restored after navigating files then quitting
  #394         ih left mapped in operator-pending and visual mode, conflict
               keys never removed, and the gf escape

Both halves of #289/#334 are covered. 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 refactor fixes.

The remaining checks are guard rails for issues fixed earlier — #428 close
never calls qall, #412 cross-file hunk navigation stays off by default, #276
close_on_open_in_prev_tab stays false, #202 hunk navigation from the explorer,
#322 gf from the explorer, #207 mappings keep nowait — so this work cannot
silently reopen them.

Verified against unmodified origin/main, where 5 of the 16 checks fail.

Bumps the minor version: no public API, config key or default changes, but
buffer-local mappings codediff overrides are now handed back on teardown,
which is a visible behavior change.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@esmuellert
esmuellert requested a review from yanuoma as a code owner August 1, 2026 18:33
@esmuellert
esmuellert enabled auto-merge August 1, 2026 18:34
@esmuellert
esmuellert disabled auto-merge August 1, 2026 18:40
@esmuellert
esmuellert merged commit a8663e5 into main Aug 1, 2026
19 of 20 checks passed
@esmuellert
esmuellert deleted the refactor/keymap-registry branch August 1, 2026 18:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant