From de0f738bb203fba6bfdb6a80d4de159c415b5094 Mon Sep 17 00:00:00 2001
From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
Date: Thu, 12 Nov 2026 21:25:54 +0800
Subject: [PATCH] fix(editor): keep Enter for new lines in a completion's
snippet (#2967)
Completing an XML tag inserts the server's snippet $1$0. Enter moved to the next snippet field in every snippet, so it jumped to the end of the line instead of splitting the tag. Enter now moves between fields only in live templates and Emmet; in a completion's snippet Tab does and Enter inserts a line break.
Closes #2951
---
.../templates/__tests__/editor.test.ts | 19 ++++++++++++++++++-
src/features/templates/editor.ts | 18 ++++++++++++++++--
2 files changed, 34 insertions(+), 3 deletions(-)
diff --git a/src/features/templates/__tests__/editor.test.ts b/src/features/templates/__tests__/editor.test.ts
index a2fe4dc24..26d23099b 100644
--- a/src/features/templates/__tests__/editor.test.ts
+++ b/src/features/templates/__tests__/editor.test.ts
@@ -4,7 +4,13 @@ import { EditorSelection, EditorState, type Extension } from "@codemirror/state"
import { EditorView, runScopeHandlers } from "@codemirror/view";
import { completedSyntaxState } from "@/test/syntax";
import { languages } from "@codemirror/language-data";
-import { CompletionContext, autocompletion, completionStatus, hasNextSnippetField } from "@codemirror/autocomplete";
+import {
+ CompletionContext,
+ autocompletion,
+ completionStatus,
+ hasNextSnippetField,
+ snippet,
+} from "@codemirror/autocomplete";
import {
EXCLUSIVE_COMPLETION_KEY,
expandLiveTemplateAtCaret,
@@ -203,6 +209,17 @@ describe("template fields", () => {
expect(v.state.selection.main.head).toBe("for (let i = 0; i < ".length);
});
+ it("leaves Enter to the editor in a completion's snippet", async () => {
+ const v = await editor("typescript", "$1$0`.
+ snippet("${1}${0}")(v, { label: "dependency" }, 0, 4);
+ expect(v.state.selection.main.head).toBe("".length);
+ expect(hasNextSnippetField(v.state)).toBe(true);
+ expect(press(v, "Enter")).toBe(false);
+ expect(tab(v)).toBe(true);
+ expect(v.state.selection.main.head).toBe(v.state.doc.length);
+ });
+
it("Esc first drops the field's selection, then leaves the template", async () => {
const v = await editor("typescript", "fori");
tab(v);
diff --git a/src/features/templates/editor.ts b/src/features/templates/editor.ts
index acd3e712e..9cd6b8394 100644
--- a/src/features/templates/editor.ts
+++ b/src/features/templates/editor.ts
@@ -8,6 +8,7 @@ import {
closeCompletion,
completionStatus,
nextSnippetField,
+ pickedCompletion,
prevSnippetField,
snippet,
snippetKeymap,
@@ -190,10 +191,22 @@ const unlessLookup =
(view) =>
completionStatus(view.state) === "active" ? false : cmd(view);
-/** Tab and Enter both move to the next field (Enter is the lookup's while it is open). */
+/**
+ * Whether the active snippet came from a live template or Emmet, where Enter moves to the
+ * next field. A completion's snippet (an LSP `$1$0`) keeps Enter for new lines.
+ */
+const templateSnippet = StateField.define({
+ create: () => false,
+ update: (value, tr) => (movedToField(tr) === 0 && tr.docChanged ? !tr.annotation(pickedCompletion) : value),
+});
+
+/** Tab moves to the next field; Enter too in templates (the lookup's while it is open). */
const templateFieldKeys = snippetKeymap.of([
{ key: "Tab", run: unlessLookup(nextSnippetField), shift: prevSnippetField },
- { key: "Enter", run: unlessLookup(nextSnippetField) },
+ {
+ key: "Enter",
+ run: unlessLookup((view) => view.state.field(templateSnippet, false) === true && nextSnippetField(view)),
+ },
{ key: "Escape", run: escapeTemplate },
]);
@@ -590,6 +603,7 @@ export function templateEditorExtension(ctx: EditorDocContext): Extension {
return [
templateDoc.of({ path: ctx.path, language: ctx.language }),
fieldCompletion,
+ templateSnippet,
completeOnFieldMove,
templateFieldKeys,
templateLookup,