diff --git a/web/workspace/deno.json b/web/workspace/deno.json index c416e66d..e462ec24 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -20,6 +20,7 @@ "@codemirror/language": "npm:@codemirror/language@6.12.4", "@codemirror/state": "npm:@codemirror/state@6.7.1", "@codemirror/view": "npm:@codemirror/view@6.43.8", + "@lezer/common": "npm:@lezer/common@1.5.2", "@lezer/highlight": "npm:@lezer/highlight@1.2.3", "decodal-codemirror": "npm:decodal-codemirror@0.3.0", "clsx": "npm:clsx@2.1.1", diff --git a/web/workspace/deno.lock b/web/workspace/deno.lock index 71b8b841..dc902d3a 100644 --- a/web/workspace/deno.lock +++ b/web/workspace/deno.lock @@ -9,6 +9,7 @@ "npm:@codemirror/state@6.7.1": "6.7.1", "npm:@codemirror/view@6.43.8": "6.43.8", "npm:@fontsource/ibm-plex-mono@5.3.0": "5.3.0", + "npm:@lezer/common@1.5.2": "1.5.2", "npm:@lezer/highlight@1.2.3": "1.2.3", "npm:@sveltejs/adapter-static@3.0.9": "3.0.9_@sveltejs+kit@2.49.4__@sveltejs+vite-plugin-svelte@6.2.1___svelte@5.45.6___vite@7.2.7____yaml@2.9.0___yaml@2.9.0__svelte@5.45.6__typescript@5.9.3__vite@7.2.7___yaml@2.9.0__yaml@2.9.0_yaml@2.9.0", "npm:@sveltejs/kit@2.49.4": "2.49.4_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7___yaml@2.9.0__yaml@2.9.0_svelte@5.45.6_typescript@5.9.3_vite@7.2.7__yaml@2.9.0_yaml@2.9.0", @@ -1026,6 +1027,7 @@ "npm:@codemirror/language@6.12.4", "npm:@codemirror/state@6.7.1", "npm:@codemirror/view@6.43.8", + "npm:@lezer/common@1.5.2", "npm:@lezer/highlight@1.2.3", "npm:@sveltejs/adapter-static@3.0.9", "npm:@sveltejs/kit@2.49.4", diff --git a/web/workspace/src/lib/workspace/config-source/completion.ts b/web/workspace/src/lib/workspace/config-source/completion.ts index 7a4d755c..81ad4888 100644 --- a/web/workspace/src/lib/workspace/config-source/completion.ts +++ b/web/workspace/src/lib/workspace/config-source/completion.ts @@ -12,6 +12,12 @@ type ConfigSourceCompletionItem = { priority: number; }; +export function shouldStartCompletionAfterTyping( + insertedText: string, +): boolean { + return /\S/u.test(insertedText); +} + export function toCodeMirrorCompletion( result: ConfigSourceCompletionResult | null, ): CompletionResult | null { diff --git a/web/workspace/src/lib/workspace/settings/DecodalSourceEditor.svelte b/web/workspace/src/lib/workspace/settings/DecodalSourceEditor.svelte index 821c8a9e..6835d02a 100644 --- a/web/workspace/src/lib/workspace/settings/DecodalSourceEditor.svelte +++ b/web/workspace/src/lib/workspace/settings/DecodalSourceEditor.svelte @@ -2,6 +2,7 @@ import { untrack } from 'svelte'; import { autocompletion, + closeCompletion, completionKeymap, completionStatus, startCompletion, @@ -10,9 +11,10 @@ } from '@codemirror/autocomplete'; import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'; import { Compartment, EditorState } from '@codemirror/state'; - import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection } from '@codemirror/view'; + import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection, type ViewUpdate } from '@codemirror/view'; import { tags } from '@lezer/highlight'; import { decodal } from 'decodal-codemirror'; + import { shouldStartCompletionAfterTyping } from '$lib/workspace/config-source/completion.ts'; import { fixedSchemaWrapperExtension, moveSelectionIntoFixedWrapper, @@ -88,6 +90,27 @@ }); } + function typedText(update: ViewUpdate): string | null { + let foundTyping = false; + let insertedText = ''; + for (const transaction of update.transactions) { + if (!transaction.isUserEvent('input.type')) continue; + foundTyping = true; + transaction.changes.iterChanges((_fromA, _toA, _fromB, _toB, inserted) => { + insertedText += inserted.toString(); + }); + } + return foundTyping ? insertedText : null; + } + + function dismissCompletion(editor: EditorView) { + queueMicrotask(() => { + if (completionStatus(editor.state) !== null) closeCompletion(editor); + }); + } + + const completionKeymapWithoutEnter = completionKeymap.filter((binding) => binding.key !== 'Enter'); + $effect(() => { if (!host || untrack(() => view)) return; const initialValue = untrack(() => value); @@ -106,6 +129,7 @@ decodal({ highlight: false }), syntaxHighlighting(syntaxTheme), autocompletion({ + activateOnTyping: false, override: [ async (context: CompletionContext) => { if (!handleComplete) return null; @@ -114,7 +138,7 @@ }, ], }), - keymap.of(completionKeymap), + keymap.of(completionKeymapWithoutEnter), fixedSchemaWrapperCompartment.of( initialFixedSchemaWrapper ? fixedSchemaWrapperExtension() : [], ), @@ -122,17 +146,18 @@ EditorState.readOnly.of(initialReadonly), EditorView.editable.of(!initialReadonly), ]), - EditorView.domEventHandlers({ - focus: (_event, editor) => { - scheduleCompletion(editor); - return false; - }, - }), EditorView.updateListener.of((update) => { - if (update.selectionSet && !update.docChanged) { - scheduleCompletion(update.view); + if (update.docChanged) { + handleChange(update.state.doc.toString()); + const insertedText = typedText(update); + if (insertedText !== null) { + if (shouldStartCompletionAfterTyping(insertedText)) { + scheduleCompletion(update.view); + } else { + dismissCompletion(update.view); + } + } } - if (update.docChanged) handleChange(update.state.doc.toString()); }), theme, ], @@ -156,7 +181,6 @@ EditorView.editable.of(!nextReadonly), ]), }); - if (!nextReadonly) scheduleCompletion(editor); }); $effect(() => { diff --git a/web/workspace/test/config-source/editor-state.test.ts b/web/workspace/test/config-source/editor-state.test.ts index 3f49e021..150d3667 100644 --- a/web/workspace/test/config-source/editor-state.test.ts +++ b/web/workspace/test/config-source/editor-state.test.ts @@ -1,3 +1,4 @@ +import denoConfig from "../../deno.json" with { type: "json" }; import { CODEMIRROR_VITE_DEDUPE } from "../../src/lib/workspace/config-source/vite-dedupe.ts"; declare const Deno: { @@ -24,6 +25,13 @@ Deno.test("Vite deduplicates CodeMirror stateful packages", () => { `Vite must deduplicate ${packageName}`, ); } + + for (const packageName of CODEMIRROR_VITE_DEDUPE) { + assert( + packageName in (denoConfig.imports ?? {}), + `${packageName} must be a direct dependency so Vite can deduplicate it`, + ); + } }); Deno.test("config editor snapshots Svelte proxies before cloning baselines", async () => { @@ -92,11 +100,15 @@ Deno.test("Decodal editor follows readonly prop changes after mount", async () = "CodeMirror theme must use workspace tokens that actually exist", ); assert( - source.includes("keymap.of(completionKeymap)") && + source.includes("keymap.of(completionKeymapWithoutEnter)") && + source.includes("binding.key !== 'Enter'") && + source.includes("activateOnTyping: false") && + source.includes("shouldStartCompletionAfterTyping(insertedText)") && source.includes("startCompletion(editor)") && - source.includes("update.selectionSet && !update.docChanged") && + !source.includes("EditorView.domEventHandlers") && + !source.includes("update.selectionSet") && source.includes("completionStatus(editor.state) === null"), - "completion should be explicitly available and start when an editable cursor moves into an empty schema position", + "completion should start only after non-whitespace typing, without using focus, cursor movement, Space, or Enter", ); assert( source.includes("fixedSchemaWrapperCompartment.reconfigure") && diff --git a/web/workspace/test/config-source/toolchain.test.ts b/web/workspace/test/config-source/toolchain.test.ts index 28dfbc0c..4e28fe2c 100644 --- a/web/workspace/test/config-source/toolchain.test.ts +++ b/web/workspace/test/config-source/toolchain.test.ts @@ -3,7 +3,10 @@ declare const Deno: { readTextFile(path: URL): Promise; }; -import { toCodeMirrorCompletion } from "../../src/lib/workspace/config-source/completion.ts"; +import { + shouldStartCompletionAfterTyping, + toCodeMirrorCompletion, +} from "../../src/lib/workspace/config-source/completion.ts"; import { jsonWorkerMessage } from "../../src/lib/workspace/config-source/toolchain-message.ts"; function assert(condition: unknown, message: string): asserts condition { @@ -67,6 +70,25 @@ Deno.test("toolchain converts reactive-like proxies to plain Worker messages", a ); }); +Deno.test("completion starts only after non-whitespace typing", () => { + assert( + !shouldStartCompletionAfterTyping(" "), + "Space should not start completion", + ); + assert( + !shouldStartCompletionAfterTyping("\n"), + "Enter should not start completion", + ); + assert( + !shouldStartCompletionAfterTyping("\t"), + "other whitespace should not start completion", + ); + assert( + shouldStartCompletionAfterTyping("p"), + "non-whitespace typing should start completion", + ); +}); + Deno.test("toolchain preserves WASM UTF-16 completion ranges for CodeMirror", () => { const result = toCodeMirrorCompletion({ from: "let 名 = ".length,