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,