From 7c80b68787a16dea15f358057ff084b38e4fd7be Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 9 Nov 2026 20:50:59 +0800 Subject: [PATCH] fix(vim): only shadow leader keys, not operators a mapping starts with (#2360) --- src/features/vim/__tests__/vim.test.ts | 15 +++++++++++++++ src/features/vim/engine.ts | 14 ++++++++------ 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/src/features/vim/__tests__/vim.test.ts b/src/features/vim/__tests__/vim.test.ts index 20757a8c2..0aa9f38f5 100644 --- a/src/features/vim/__tests__/vim.test.ts +++ b/src/features/vim/__tests__/vim.test.ts @@ -173,6 +173,21 @@ describe("Vim engine", () => { d(); }); + it.each([ + ["nmap dx (GotoFile)", ["d", "w"], "two\nthree"], + ["nmap cs (GotoFile)", ["c", "w"], " two\nthree"], + ["nnoremap yz yy", ["y", "y", "p"], "one two\none two\nthree"], + ])("keeps built-in operators a mapping starts with (%s)", async (rc, keys, doc) => { + const view = makeView(); + const cm = getCM(view)!; + applyRcCommands(parseVimrc(rc).commands); + for (const k of keys) Vim.handleKey(cm as never, k, "user"); + await tick(); + expect(view.state.doc.toString()).toBe(doc); + applyRcCommands([]); + view.destroy(); + }); + it("restores the built-in key the leader shadowed once the rc stops using it (#2337)", async () => { const view = makeView(); const cm = getCM(view)!; diff --git a/src/features/vim/engine.ts b/src/features/vim/engine.ts index 904de5ea5..85d9a4567 100644 --- a/src/features/vim/engine.ts +++ b/src/features/vim/engine.ts @@ -327,10 +327,10 @@ const LEADER_BUILTIN_DEFAULTS: Record> = { }; /** Built-ins unmapped to make room for a user mapping's first key, by key. */ -const shadowedBuiltins = new Map | null>(); +const shadowedBuiltins = new Map>(); function firstKey(lhs: string): string { - return /^<[CSMA]-*\w+>|^./i.exec(lhs)?.[0] ?? lhs; + return /^<[^<>]+>|^./.exec(lhs)?.[0] ?? lhs; } /** Applies rc commands (replacing earlier rc mappings). Returns problems to report. */ @@ -342,17 +342,19 @@ export function applyRcCommands(commands: readonly RcCommand[]): string[] { // one's first keys collide with. Restoring first keeps mapclear's // user/default boundary honest — it assumes the default region never shrank. for (const [key, entry] of shadowedBuiltins) { - if (entry) Vim._mapCommand(entry as never); + Vim._mapCommand(entry as never); shadowedBuiltins.delete(key); } for (const c of commands) { if (c.kind !== "map" && c.kind !== "mapEx") continue; const key = firstKey(c.lhs); + // Only the single-key motions a leader reuses: unmapping an operator or prefix + // (`d`, `c`, `y`, ``…) would break every command built on it. + const builtin = LEADER_BUILTIN_DEFAULTS[key]; + if (!builtin || shadowedBuiltins.has(key)) continue; // The builtins this shadows are context-less; `undefined` matches them. - if (!shadowedBuiltins.has(key) && Vim.unmap(key, undefined as unknown as string)) { - shadowedBuiltins.set(key, LEADER_BUILTIN_DEFAULTS[key] ?? null); - } + if (Vim.unmap(key, undefined as unknown as string)) shadowedBuiltins.set(key, builtin); } for (const c of commands) { try {