fix: deduplicate CodeMirror completion state
This commit is contained in:
@@ -13,12 +13,11 @@ type ConfigSourceCompletionItem = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function toCodeMirrorCompletion(
|
export function toCodeMirrorCompletion(
|
||||||
source: string,
|
|
||||||
result: ConfigSourceCompletionResult | null,
|
result: ConfigSourceCompletionResult | null,
|
||||||
): CompletionResult | null {
|
): CompletionResult | null {
|
||||||
if (!result) return null;
|
if (!result) return null;
|
||||||
return {
|
return {
|
||||||
from: utf8ByteOffsetToUtf16(source, result.from),
|
from: result.from,
|
||||||
options: result.items.map((item) => ({
|
options: result.items.map((item) => ({
|
||||||
label: item.label,
|
label: item.label,
|
||||||
type: item.kind,
|
type: item.kind,
|
||||||
@@ -27,31 +26,3 @@ export function toCodeMirrorCompletion(
|
|||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function utf8ByteOffsetToUtf16(source: string, byteOffset: number): number {
|
|
||||||
if (!Number.isSafeInteger(byteOffset) || byteOffset < 0) {
|
|
||||||
throw new RangeError(
|
|
||||||
"completion byte offset must be a non-negative integer",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let bytes = 0;
|
|
||||||
let utf16 = 0;
|
|
||||||
for (const character of source) {
|
|
||||||
if (bytes === byteOffset) return utf16;
|
|
||||||
const codePoint = character.codePointAt(0)!;
|
|
||||||
bytes += codePoint <= 0x7f
|
|
||||||
? 1
|
|
||||||
: codePoint <= 0x7ff
|
|
||||||
? 2
|
|
||||||
: codePoint <= 0xffff
|
|
||||||
? 3
|
|
||||||
: 4;
|
|
||||||
utf16 += character.length;
|
|
||||||
if (bytes > byteOffset) {
|
|
||||||
throw new RangeError("completion byte offset splits a UTF-8 code point");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (bytes === byteOffset) return utf16;
|
|
||||||
throw new RangeError("completion byte offset is outside the source");
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export class ConfigSourceToolchain {
|
|||||||
utf16Offset,
|
utf16Offset,
|
||||||
explicit,
|
explicit,
|
||||||
});
|
});
|
||||||
return toCodeMirrorCompletion(source, result);
|
return toCodeMirrorCompletion(result);
|
||||||
}
|
}
|
||||||
format(source: string): Promise<string> {
|
format(source: string): Promise<string> {
|
||||||
return this.#request({ kind: "format", source });
|
return this.#request({ kind: "format", source });
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export const CODEMIRROR_VITE_DEDUPE = [
|
||||||
|
"@codemirror/autocomplete",
|
||||||
|
"@codemirror/language",
|
||||||
|
"@codemirror/state",
|
||||||
|
"@codemirror/view",
|
||||||
|
"@lezer/common",
|
||||||
|
];
|
||||||
@@ -1,6 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import { autocompletion, type CompletionContext, type CompletionResult } from '@codemirror/autocomplete';
|
import {
|
||||||
|
autocompletion,
|
||||||
|
completionKeymap,
|
||||||
|
completionStatus,
|
||||||
|
startCompletion,
|
||||||
|
type CompletionContext,
|
||||||
|
type CompletionResult,
|
||||||
|
} from '@codemirror/autocomplete';
|
||||||
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
|
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
|
||||||
import { Compartment, EditorState } from '@codemirror/state';
|
import { Compartment, EditorState } from '@codemirror/state';
|
||||||
import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection } from '@codemirror/view';
|
import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection } from '@codemirror/view';
|
||||||
@@ -69,6 +76,18 @@
|
|||||||
'.cm-tooltip-autocomplete > ul > li[aria-selected]': { background: 'var(--interactive-selected)', color: 'var(--text-strong)' },
|
'.cm-tooltip-autocomplete > ul > li[aria-selected]': { background: 'var(--interactive-selected)', color: 'var(--text-strong)' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function scheduleCompletion(editor: EditorView) {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (
|
||||||
|
editor.hasFocus &&
|
||||||
|
!editor.state.facet(EditorState.readOnly) &&
|
||||||
|
completionStatus(editor.state) === null
|
||||||
|
) {
|
||||||
|
startCompletion(editor);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!host || untrack(() => view)) return;
|
if (!host || untrack(() => view)) return;
|
||||||
const initialValue = untrack(() => value);
|
const initialValue = untrack(() => value);
|
||||||
@@ -86,11 +105,16 @@
|
|||||||
highlightActiveLine(),
|
highlightActiveLine(),
|
||||||
decodal({ highlight: false }),
|
decodal({ highlight: false }),
|
||||||
syntaxHighlighting(syntaxTheme),
|
syntaxHighlighting(syntaxTheme),
|
||||||
...(handleComplete ? [autocompletion({ override: [async (context: CompletionContext) => {
|
autocompletion({
|
||||||
const doc = context.state.doc.toString();
|
override: [
|
||||||
return await handleComplete(doc, context.pos, context.explicit);
|
async (context: CompletionContext) => {
|
||||||
}] })] : []),
|
if (!handleComplete) return null;
|
||||||
keymap.of([]),
|
const doc = context.state.doc.toString();
|
||||||
|
return await handleComplete(doc, context.pos, context.explicit);
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
keymap.of(completionKeymap),
|
||||||
fixedSchemaWrapperCompartment.of(
|
fixedSchemaWrapperCompartment.of(
|
||||||
initialFixedSchemaWrapper ? fixedSchemaWrapperExtension() : [],
|
initialFixedSchemaWrapper ? fixedSchemaWrapperExtension() : [],
|
||||||
),
|
),
|
||||||
@@ -98,7 +122,16 @@
|
|||||||
EditorState.readOnly.of(initialReadonly),
|
EditorState.readOnly.of(initialReadonly),
|
||||||
EditorView.editable.of(!initialReadonly),
|
EditorView.editable.of(!initialReadonly),
|
||||||
]),
|
]),
|
||||||
|
EditorView.domEventHandlers({
|
||||||
|
focus: (_event, editor) => {
|
||||||
|
scheduleCompletion(editor);
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
}),
|
||||||
EditorView.updateListener.of((update) => {
|
EditorView.updateListener.of((update) => {
|
||||||
|
if (update.selectionSet && !update.docChanged) {
|
||||||
|
scheduleCompletion(update.view);
|
||||||
|
}
|
||||||
if (update.docChanged) handleChange(update.state.doc.toString());
|
if (update.docChanged) handleChange(update.state.doc.toString());
|
||||||
}),
|
}),
|
||||||
theme,
|
theme,
|
||||||
@@ -123,6 +156,7 @@
|
|||||||
EditorView.editable.of(!nextReadonly),
|
EditorView.editable.of(!nextReadonly),
|
||||||
]),
|
]),
|
||||||
});
|
});
|
||||||
|
if (!nextReadonly) scheduleCompletion(editor);
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { CODEMIRROR_VITE_DEDUPE } from "../../src/lib/workspace/config-source/vite-dedupe.ts";
|
||||||
|
|
||||||
declare const Deno: {
|
declare const Deno: {
|
||||||
test(name: string, fn: () => Promise<void> | void): void;
|
test(name: string, fn: () => Promise<void> | void): void;
|
||||||
readTextFile(path: URL): Promise<string>;
|
readTextFile(path: URL): Promise<string>;
|
||||||
@@ -7,6 +9,23 @@ function assert(condition: unknown, message: string): asserts condition {
|
|||||||
if (!condition) throw new Error(message);
|
if (!condition) throw new Error(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Deno.test("Vite deduplicates CodeMirror stateful packages", () => {
|
||||||
|
for (
|
||||||
|
const packageName of [
|
||||||
|
"@codemirror/autocomplete",
|
||||||
|
"@codemirror/language",
|
||||||
|
"@codemirror/state",
|
||||||
|
"@codemirror/view",
|
||||||
|
"@lezer/common",
|
||||||
|
]
|
||||||
|
) {
|
||||||
|
assert(
|
||||||
|
CODEMIRROR_VITE_DEDUPE.includes(packageName),
|
||||||
|
`Vite must deduplicate ${packageName}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
Deno.test("config editor snapshots Svelte proxies before cloning baselines", async () => {
|
Deno.test("config editor snapshots Svelte proxies before cloning baselines", async () => {
|
||||||
const source = await Deno.readTextFile(
|
const source = await Deno.readTextFile(
|
||||||
new URL(
|
new URL(
|
||||||
@@ -72,6 +91,13 @@ Deno.test("Decodal editor follows readonly prop changes after mount", async () =
|
|||||||
!source.includes("--border-subtle"),
|
!source.includes("--border-subtle"),
|
||||||
"CodeMirror theme must use workspace tokens that actually exist",
|
"CodeMirror theme must use workspace tokens that actually exist",
|
||||||
);
|
);
|
||||||
|
assert(
|
||||||
|
source.includes("keymap.of(completionKeymap)") &&
|
||||||
|
source.includes("startCompletion(editor)") &&
|
||||||
|
source.includes("update.selectionSet && !update.docChanged") &&
|
||||||
|
source.includes("completionStatus(editor.state) === null"),
|
||||||
|
"completion should be explicitly available and start when an editable cursor moves into an empty schema position",
|
||||||
|
);
|
||||||
assert(
|
assert(
|
||||||
source.includes("fixedSchemaWrapperCompartment.reconfigure") &&
|
source.includes("fixedSchemaWrapperCompartment.reconfigure") &&
|
||||||
source.includes("fixedSchemaWrapperExtension()") &&
|
source.includes("fixedSchemaWrapperExtension()") &&
|
||||||
|
|||||||
@@ -67,10 +67,9 @@ Deno.test("toolchain converts reactive-like proxies to plain Worker messages", a
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("toolchain adapts WASM completion items and byte offsets for CodeMirror", () => {
|
Deno.test("toolchain preserves WASM UTF-16 completion ranges for CodeMirror", () => {
|
||||||
const source = "let 名 = tru";
|
const result = toCodeMirrorCompletion({
|
||||||
const result = toCodeMirrorCompletion(source, {
|
from: "let 名 = ".length,
|
||||||
from: new TextEncoder().encode("let 名 = ").length,
|
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
label: "true",
|
label: "true",
|
||||||
@@ -84,7 +83,7 @@ Deno.test("toolchain adapts WASM completion items and byte offsets for CodeMirro
|
|||||||
assert(result !== null, "WASM completion should produce a CodeMirror result");
|
assert(result !== null, "WASM completion should produce a CodeMirror result");
|
||||||
assert(
|
assert(
|
||||||
result.from === "let 名 = ".length,
|
result.from === "let 名 = ".length,
|
||||||
"byte offsets should become UTF-16 offsets",
|
"WASM UTF-16 offsets should be preserved for CodeMirror",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
result.options.length === 1,
|
result.options.length === 1,
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import { sveltekit } from "@sveltejs/kit/vite";
|
import { sveltekit } from "@sveltejs/kit/vite";
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
|
import { CODEMIRROR_VITE_DEDUPE } from "./src/lib/workspace/config-source/vite-dedupe";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [sveltekit()],
|
plugins: [sveltekit()],
|
||||||
|
|
||||||
|
resolve: {
|
||||||
|
dedupe: CODEMIRROR_VITE_DEDUPE,
|
||||||
|
},
|
||||||
|
|
||||||
server: {
|
server: {
|
||||||
host: "localhost",
|
host: "localhost",
|
||||||
port: 5173,
|
port: 5173,
|
||||||
|
|||||||
Reference in New Issue
Block a user