From 5325d98c3fdcea7ea6654734ed2a5a555dcc4249 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Fri, 6 Nov 2026 08:04:25 +0800 Subject: [PATCH] fix(vim): save Normal-mode Vim edits (#1642) codemirror-vim labels its own edits input.type.compose to group them for undo; the Normal-mode IME extender matched that label alone and marked them transient, so the document model and saves missed them. Only transactions of a view with an IME composition in progress are transient now. --- src/features/vim/__tests__/vim.test.ts | 22 ++++++++++++++++++++++ src/features/vim/engine.ts | 26 ++++++++++++++++++++++---- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/src/features/vim/__tests__/vim.test.ts b/src/features/vim/__tests__/vim.test.ts index 8b6faebd..097a3138 100644 --- a/src/features/vim/__tests__/vim.test.ts +++ b/src/features/vim/__tests__/vim.test.ts @@ -144,6 +144,28 @@ describe("Vim engine", () => { view.destroy(); }); + it("keeps Normal-mode Vim edits in the document", async () => { + const transient: boolean[] = []; + const view = makeView([ + vimExtension(), + EditorView.updateListener.of((u) => { + for (const tr of u.transactions) if (tr.docChanged) transient.push(!!tr.annotation(transientChange)); + }), + ]); + const cm = getCM(view)!; + // codemirror-vim labels these `input.type.compose` to group them for undo. + Vim.handleKey(cm, "x", "user"); + for (const key of ["w", "c", "i", "w"]) Vim.handleKey(cm, key, "user"); + await tick(); + view.dispatch(view.state.replaceSelection("2")); + Vim.handleKey(cm, "", "user"); + await tick(); + + expect(view.state.doc.toString()).toBe("ne 2\nthree"); + expect(transient).not.toContain(true); + view.destroy(); + }); + it("removes a whole composition whose commit shares text with the preedit", async () => { const view = makeView([vimExtension()]); const composition = (type: string, data: string) => diff --git a/src/features/vim/engine.ts b/src/features/vim/engine.ts index e05020f3..c3612a20 100644 --- a/src/features/vim/engine.ts +++ b/src/features/vim/engine.ts @@ -39,6 +39,13 @@ interface ImeComposition { } const imeCompositions = new WeakMap(); +/** Views with a Normal-mode IME composition in progress (`imeCompositions` can't be iterated). */ +const composingViews = new Set(); + +function endComposition(view: EditorView) { + imeCompositions.delete(view); + composingViews.delete(view); +} /** * codemirror-vim only discards single-character IME commits in Normal mode, so @@ -51,7 +58,7 @@ const imeCompositions = new WeakMap(); function finishImeComposition(view: EditorView) { const composition = imeCompositions.get(view); if (!composition) return; - imeCompositions.delete(view); + endComposition(view); clearTimeout(composition.timer); const cm = getCM(view); if (!cm) return; @@ -84,6 +91,7 @@ const imeHandlers = EditorView.domEventHandlers({ const previous = imeCompositions.get(view); if (previous) clearTimeout(previous.timer); const range = view.state.selection.main; + composingViews.add(view); imeCompositions.set(view, { from: range.from, to: range.to, @@ -142,19 +150,29 @@ const imeTracker = ViewPlugin.define((view) => ({ }, })); -/** Composition changes are temporary: no undo entry, no document/backend sync. */ +/** + * Composition changes are temporary: no undo entry, no document/backend sync. Only those of a + * composition the IME started: codemirror-vim labels its own edits (`x`, `dd`, `ciw`…) + * `input.type.compose` too, to group them for undo, and those are real edits. + */ const imeTransactions = EditorState.transactionExtender.of((tr) => - !tr.startState.field(vimInsert) && tr.isUserEvent("input.type.compose") + !tr.startState.field(vimInsert) && tr.isUserEvent("input.type.compose") && composingState(tr.startState) ? { annotations: [Transaction.addToHistory.of(false), transientChange.of(true)] } : null, ); +/** Whether `state` is the current state of a view with an IME composition in progress. */ +function composingState(state: EditorState): boolean { + for (const view of composingViews) if (view.state === state) return true; + return false; +} + const imeCleanup = ViewPlugin.define((view) => ({ destroy() { const composition = imeCompositions.get(view); if (composition) { clearTimeout(composition.timer); - imeCompositions.delete(view); + endComposition(view); } }, }));