web: repair config source editor
This commit is contained in:
@@ -27,7 +27,7 @@
|
||||
let baseRevision = $state(0);
|
||||
let baseDigest = $state("");
|
||||
let renamePath = $state("");
|
||||
let baseSnapshot = $state<WorkspaceConfigTreeResponse["snapshot"] | null>(null);
|
||||
let baseSnapshot = $state.raw<WorkspaceConfigTreeResponse["snapshot"] | null>(null);
|
||||
let preflightDigest = $state("");
|
||||
let conflict = $state(false);
|
||||
let candidateContract = $state<WorkspaceConfigTreeResponse["contract"] | null>(null);
|
||||
@@ -56,10 +56,10 @@
|
||||
selectedPath = Object.keys(treeState.snapshot.entries).toSorted()[0] ?? "";
|
||||
}
|
||||
source = selectedPath ? treeState.snapshot.entries[selectedPath].content : "";
|
||||
baseSnapshot = structuredClone(treeState.snapshot);
|
||||
baseSnapshot = $state.snapshot(treeState.snapshot);
|
||||
baseRevision = treeState.snapshot.revision;
|
||||
baseDigest = treeState.snapshot.digest;
|
||||
await toolchain?.setSnapshot(treeState.snapshot);
|
||||
await toolchain?.setSnapshot(treeState.snapshot, treeState.contract.schema_bundle);
|
||||
draftChanges = [];
|
||||
renamePath = selectedPath;
|
||||
diagnostics = [];
|
||||
@@ -181,10 +181,10 @@
|
||||
preflightDigest = "";
|
||||
candidateContract = null;
|
||||
conflict = false;
|
||||
baseSnapshot = structuredClone(treeState.snapshot);
|
||||
baseSnapshot = $state.snapshot(treeState.snapshot);
|
||||
baseRevision = treeState.snapshot.revision;
|
||||
baseDigest = treeState.snapshot.digest;
|
||||
await toolchain?.setSnapshot(treeState.snapshot);
|
||||
await toolchain?.setSnapshot(treeState.snapshot, treeState.contract.schema_bundle);
|
||||
source = treeState.snapshot.entries[selectedPath]?.content ?? "";
|
||||
diagnostics = [];
|
||||
status = `Committed revision ${treeState.snapshot.revision}.`;
|
||||
@@ -210,7 +210,7 @@
|
||||
baseSnapshot = structuredClone(remote.snapshot);
|
||||
baseRevision = remote.snapshot.revision;
|
||||
baseDigest = remote.snapshot.digest;
|
||||
await toolchain.setSnapshot(remote.snapshot);
|
||||
await toolchain.setSnapshot(remote.snapshot, remote.contract.schema_bundle);
|
||||
try {
|
||||
const candidate = await toolchain.applyChanges(localChanges);
|
||||
draftChanges = localChanges;
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { CompletionResult } from "@codemirror/autocomplete";
|
||||
|
||||
export type ConfigSourceCompletionResult = {
|
||||
from: number;
|
||||
items: ConfigSourceCompletionItem[];
|
||||
};
|
||||
|
||||
type ConfigSourceCompletionItem = {
|
||||
label: string;
|
||||
kind: string;
|
||||
detail: string | null;
|
||||
priority: number;
|
||||
};
|
||||
|
||||
export function toCodeMirrorCompletion(
|
||||
source: string,
|
||||
result: ConfigSourceCompletionResult | null,
|
||||
): CompletionResult | null {
|
||||
if (!result) return null;
|
||||
return {
|
||||
from: utf8ByteOffsetToUtf16(source, result.from),
|
||||
options: result.items.map((item) => ({
|
||||
label: item.label,
|
||||
type: item.kind,
|
||||
detail: item.detail ?? undefined,
|
||||
boost: item.priority,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
+3
@@ -19,6 +19,8 @@ export function formatSource(source: string): string;
|
||||
|
||||
export function format_source(source: string): string;
|
||||
|
||||
export function set_schema_bundle(schema_bundle: any): void;
|
||||
|
||||
export function set_snapshot(snapshot: any): void;
|
||||
|
||||
export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module;
|
||||
@@ -33,6 +35,7 @@ export interface InitOutput {
|
||||
readonly evaluate_current: (a: any) => [number, number, number];
|
||||
readonly evaluate_snapshot: (a: any, b: any) => [number, number, number];
|
||||
readonly format_source: (a: number, b: number) => [number, number, number, number];
|
||||
readonly set_schema_bundle: (a: any) => [number, number];
|
||||
readonly set_snapshot: (a: any) => [number, number];
|
||||
readonly formatSource: (a: number, b: number) => [number, number];
|
||||
readonly __wbindgen_malloc: (a: number, b: number) => number;
|
||||
|
||||
@@ -143,6 +143,16 @@ export function format_source(source) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} schema_bundle
|
||||
*/
|
||||
export function set_schema_bundle(schema_bundle) {
|
||||
const ret = wasm.set_schema_bundle(schema_bundle);
|
||||
if (ret[1]) {
|
||||
throw takeFromExternrefTable0(ret[0]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} snapshot
|
||||
*/
|
||||
|
||||
Binary file not shown.
+1
@@ -9,6 +9,7 @@ export const compose_schema_bundle: (a: any) => [number, number, number];
|
||||
export const evaluate_current: (a: any) => [number, number, number];
|
||||
export const evaluate_snapshot: (a: any, b: any) => [number, number, number];
|
||||
export const format_source: (a: number, b: number) => [number, number, number, number];
|
||||
export const set_schema_bundle: (a: any) => [number, number];
|
||||
export const set_snapshot: (a: any) => [number, number];
|
||||
export const formatSource: (a: number, b: number) => [number, number];
|
||||
export const __wbindgen_malloc: (a: number, b: number) => number;
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export function jsonWorkerMessage<T>(request: T): T {
|
||||
return JSON.parse(JSON.stringify(request)) as T;
|
||||
}
|
||||
@@ -1,5 +1,19 @@
|
||||
import type { ConfigDiagnostic, ConfigTreeChange, ConfigTreeSnapshot, ToolchainContract } from "./types.ts";
|
||||
import type { ConfigSourceWorkerRequest, ConfigSourceWorkerResponse } from "./toolchain.worker.ts";
|
||||
import type {
|
||||
ConfigDiagnostic,
|
||||
ConfigTreeChange,
|
||||
ConfigTreeSnapshot,
|
||||
ToolchainContract,
|
||||
WorkspaceConfigSchemaBundle,
|
||||
} from "./types.ts";
|
||||
import { jsonWorkerMessage } from "./toolchain-message.ts";
|
||||
import {
|
||||
type ConfigSourceCompletionResult,
|
||||
toCodeMirrorCompletion,
|
||||
} from "./completion.ts";
|
||||
import type {
|
||||
ConfigSourceWorkerRequest,
|
||||
ConfigSourceWorkerResponse,
|
||||
} from "./toolchain.worker.ts";
|
||||
|
||||
type Command =
|
||||
| Omit<Extract<ConfigSourceWorkerRequest, { kind: "set_snapshot" }>, "id">
|
||||
@@ -13,26 +27,42 @@ type Command =
|
||||
export class ConfigSourceToolchain {
|
||||
#worker: Worker;
|
||||
#nextId = 1;
|
||||
#pending = new Map<number, { resolve: (value: unknown) => void; reject: (reason: unknown) => void }>();
|
||||
#pending = new Map<
|
||||
number,
|
||||
{ resolve: (value: unknown) => void; reject: (reason: unknown) => void }
|
||||
>();
|
||||
|
||||
constructor(worker = new Worker(new URL("./toolchain.worker.ts", import.meta.url), { type: "module" })) {
|
||||
constructor(
|
||||
worker = new Worker(new URL("./toolchain.worker.ts", import.meta.url), {
|
||||
type: "module",
|
||||
}),
|
||||
) {
|
||||
this.#worker = worker;
|
||||
worker.addEventListener("message", (event: MessageEvent<ConfigSourceWorkerResponse>) => {
|
||||
const pending = this.#pending.get(event.data.id);
|
||||
if (!pending) return;
|
||||
this.#pending.delete(event.data.id);
|
||||
if (event.data.ok) pending.resolve(event.data.result);
|
||||
else pending.reject(event.data.error);
|
||||
});
|
||||
worker.addEventListener(
|
||||
"message",
|
||||
(event: MessageEvent<ConfigSourceWorkerResponse>) => {
|
||||
const pending = this.#pending.get(event.data.id);
|
||||
if (!pending) return;
|
||||
this.#pending.delete(event.data.id);
|
||||
if (event.data.ok) pending.resolve(event.data.result);
|
||||
else pending.reject(event.data.error);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
setSnapshot(snapshot: ConfigTreeSnapshot): Promise<void> {
|
||||
return this.#request({ kind: "set_snapshot", snapshot });
|
||||
setSnapshot(
|
||||
snapshot: ConfigTreeSnapshot,
|
||||
schemaBundle: WorkspaceConfigSchemaBundle,
|
||||
): Promise<void> {
|
||||
return this.#request({ kind: "set_snapshot", snapshot, schemaBundle });
|
||||
}
|
||||
applyChanges(changes: ConfigTreeChange[]): Promise<ConfigTreeSnapshot> {
|
||||
return this.#request({ kind: "apply_changes", changes });
|
||||
}
|
||||
changesBetween(base: ConfigTreeSnapshot, candidate: ConfigTreeSnapshot): Promise<ConfigTreeChange[]> {
|
||||
changesBetween(
|
||||
base: ConfigTreeSnapshot,
|
||||
candidate: ConfigTreeSnapshot,
|
||||
): Promise<ConfigTreeChange[]> {
|
||||
return this.#request({ kind: "changes_between", base, candidate });
|
||||
}
|
||||
analyze(path: string, source?: string): Promise<ConfigDiagnostic[]> {
|
||||
@@ -41,22 +71,40 @@ export class ConfigSourceToolchain {
|
||||
evaluate(contract: ToolchainContract) {
|
||||
return this.#request({ kind: "evaluate", contract });
|
||||
}
|
||||
complete(path: string, source: string, utf16Offset: number, explicit = false): Promise<import("@codemirror/autocomplete").CompletionResult | null> {
|
||||
return this.#request({ kind: "complete", path, source, utf16Offset, explicit });
|
||||
async complete(
|
||||
path: string,
|
||||
source: string,
|
||||
utf16Offset: number,
|
||||
explicit = false,
|
||||
): Promise<import("@codemirror/autocomplete").CompletionResult | null> {
|
||||
const result = await this.#request<ConfigSourceCompletionResult | null>({
|
||||
kind: "complete",
|
||||
path,
|
||||
source,
|
||||
utf16Offset,
|
||||
explicit,
|
||||
});
|
||||
return toCodeMirrorCompletion(source, result);
|
||||
}
|
||||
format(source: string): Promise<string> {
|
||||
return this.#request({ kind: "format", source });
|
||||
}
|
||||
close(): void {
|
||||
this.#worker.terminate();
|
||||
for (const pending of this.#pending.values()) pending.reject(new Error("config source toolchain was closed"));
|
||||
for (const pending of this.#pending.values()) {
|
||||
pending.reject(new Error("config source toolchain was closed"));
|
||||
}
|
||||
this.#pending.clear();
|
||||
}
|
||||
#request<T>(request: Command): Promise<T> {
|
||||
const id = this.#nextId++;
|
||||
const message = jsonWorkerMessage({ ...request, id });
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
this.#pending.set(id, { resolve: (value) => resolve(value as T), reject });
|
||||
this.#worker.postMessage({ ...request, id });
|
||||
this.#pending.set(id, {
|
||||
resolve: (value) => resolve(value as T),
|
||||
reject,
|
||||
});
|
||||
this.#worker.postMessage(message);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,17 +5,30 @@ import init, {
|
||||
complete_current,
|
||||
evaluate_current,
|
||||
format_source,
|
||||
set_schema_bundle,
|
||||
set_snapshot,
|
||||
} from "./generated/config_source_wasm.js";
|
||||
import type { ConfigTreeChange } from "./types.ts";
|
||||
|
||||
export type ConfigSourceWorkerRequest =
|
||||
| { id: number; kind: "set_snapshot"; snapshot: unknown }
|
||||
| {
|
||||
id: number;
|
||||
kind: "set_snapshot";
|
||||
snapshot: unknown;
|
||||
schemaBundle: unknown;
|
||||
}
|
||||
| { id: number; kind: "apply_changes"; changes: ConfigTreeChange[] }
|
||||
| { id: number; kind: "changes_between"; base: unknown; candidate: unknown }
|
||||
| { id: number; kind: "analyze"; path: string; source?: string }
|
||||
| { id: number; kind: "evaluate"; contract: unknown }
|
||||
| { id: number; kind: "complete"; path: string; source: string; utf16Offset: number; explicit: boolean }
|
||||
| {
|
||||
id: number;
|
||||
kind: "complete";
|
||||
path: string;
|
||||
source: string;
|
||||
utf16Offset: number;
|
||||
explicit: boolean;
|
||||
}
|
||||
| { id: number; kind: "format"; source: string };
|
||||
|
||||
export type ConfigSourceWorkerResponse =
|
||||
@@ -25,7 +38,9 @@ export type ConfigSourceWorkerResponse =
|
||||
const ready = init();
|
||||
let snapshot: unknown = null;
|
||||
|
||||
self.onmessage = async (event: MessageEvent<ConfigSourceWorkerRequest>): Promise<void> => {
|
||||
self.onmessage = async (
|
||||
event: MessageEvent<ConfigSourceWorkerRequest>,
|
||||
): Promise<void> => {
|
||||
const request = event.data;
|
||||
try {
|
||||
await ready;
|
||||
@@ -34,6 +49,7 @@ self.onmessage = async (event: MessageEvent<ConfigSourceWorkerRequest>): Promise
|
||||
case "set_snapshot":
|
||||
snapshot = request.snapshot;
|
||||
set_snapshot(request.snapshot);
|
||||
set_schema_bundle(request.schemaBundle);
|
||||
result = null;
|
||||
break;
|
||||
case "apply_changes":
|
||||
@@ -44,14 +60,21 @@ self.onmessage = async (event: MessageEvent<ConfigSourceWorkerRequest>): Promise
|
||||
result = changes_between(request.base, request.candidate);
|
||||
break;
|
||||
case "analyze":
|
||||
if (!snapshot) throw new Error("config source snapshot is not initialized");
|
||||
if (!snapshot) {
|
||||
throw new Error("config source snapshot is not initialized");
|
||||
}
|
||||
result = analyze_snapshot(snapshot, request.path, request.source);
|
||||
break;
|
||||
case "evaluate":
|
||||
result = evaluate_current(request.contract);
|
||||
break;
|
||||
case "complete":
|
||||
result = complete_current(request.path, request.source, request.utf16Offset, request.explicit);
|
||||
result = complete_current(
|
||||
request.path,
|
||||
request.source,
|
||||
request.utf16Offset,
|
||||
request.explicit,
|
||||
);
|
||||
break;
|
||||
case "format":
|
||||
result = format_source(request.source);
|
||||
@@ -59,6 +82,10 @@ self.onmessage = async (event: MessageEvent<ConfigSourceWorkerRequest>): Promise
|
||||
}
|
||||
self.postMessage({ id: request.id, ok: true, result });
|
||||
} catch (error) {
|
||||
self.postMessage({ id: request.id, ok: false, error: error instanceof Error ? error.message : error });
|
||||
self.postMessage({
|
||||
id: request.id,
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : error,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -459,11 +459,11 @@ Deno.test("Decodal source editor keeps imperative EditorView out of reactive sta
|
||||
);
|
||||
|
||||
assert(
|
||||
editor.includes("let view: EditorView | null = null") &&
|
||||
!editor.includes("$state<EditorView") &&
|
||||
editor.includes("let view = $state.raw<EditorView | null>(null)") &&
|
||||
editor.includes("untrack(() => view)") &&
|
||||
editor.includes("untrack(() => value)") &&
|
||||
editor.includes("untrack(() => onChange)"),
|
||||
"CodeMirror EditorView must not be reactive state; otherwise mount cleanup can loop forever",
|
||||
"CodeMirror EditorView must not be deep reactive or tracked by the mount effect; otherwise cleanup can loop forever",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { autocompletion, type CompletionContext, type CompletionResult } from '@codemirror/autocomplete';
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
|
||||
import { Compartment, EditorState } from '@codemirror/state';
|
||||
import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection } from '@codemirror/view';
|
||||
import { tags } from '@lezer/highlight';
|
||||
import { decodal } from 'decodal-codemirror';
|
||||
|
||||
let {
|
||||
@@ -20,25 +22,43 @@
|
||||
} = $props();
|
||||
|
||||
let host = $state<HTMLDivElement | null>(null);
|
||||
let view: EditorView | null = null;
|
||||
let view = $state.raw<EditorView | null>(null);
|
||||
const readonlyCompartment = new Compartment();
|
||||
|
||||
const syntaxTheme = HighlightStyle.define([
|
||||
{ tag: tags.keyword, color: 'var(--accent)', fontWeight: '700' },
|
||||
{ tag: tags.variableName, color: 'var(--code)' },
|
||||
{ tag: [tags.bool, tags.number], color: 'var(--warning)' },
|
||||
{ tag: [tags.string, tags.regexp], color: 'var(--success)' },
|
||||
{ tag: tags.lineComment, color: 'var(--text-muted)', fontStyle: 'italic' },
|
||||
{ tag: tags.operator, color: 'var(--danger)' },
|
||||
{ tag: [tags.brace, tags.squareBracket, tags.paren, tags.punctuation], color: 'var(--accent-muted)' },
|
||||
]);
|
||||
|
||||
const theme = EditorView.theme({
|
||||
'&': {
|
||||
border: '1px solid var(--border-subtle)',
|
||||
border: '1px solid var(--line)',
|
||||
borderRadius: '0.75rem',
|
||||
minHeight: '24rem',
|
||||
background: 'var(--surface-2)',
|
||||
color: 'var(--text-primary)',
|
||||
background: 'var(--bg-raised)',
|
||||
color: 'var(--text)',
|
||||
fontSize: '0.9rem',
|
||||
},
|
||||
'&.cm-focused': { outline: '1px solid var(--accent-muted)', outlineOffset: '-1px' },
|
||||
'.cm-scroller': { fontFamily: 'var(--font-mono)', minHeight: '24rem' },
|
||||
'.cm-content': { padding: '0.75rem 0' },
|
||||
'.cm-gutters': { background: 'var(--surface-2)', color: 'var(--text-muted)' },
|
||||
'.cm-activeLine': { backgroundColor: 'rgba(125, 211, 252, 0.08)' },
|
||||
'.cm-content': { padding: '0.75rem 0', caretColor: 'var(--text-strong)' },
|
||||
'.cm-cursor, .cm-dropCursor': { borderLeftColor: 'var(--text-strong)', borderLeftWidth: '2px' },
|
||||
'&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': {
|
||||
backgroundColor: 'var(--interactive-selected)',
|
||||
},
|
||||
'.cm-gutters': { background: 'var(--bg-raised)', color: 'var(--text-muted)', borderColor: 'var(--line)' },
|
||||
'.cm-activeLine, .cm-activeLineGutter': { backgroundColor: 'var(--interactive-hover)' },
|
||||
'.cm-tooltip': { background: 'var(--bg-raised)', color: 'var(--text)', borderColor: 'var(--line-strong)' },
|
||||
'.cm-tooltip-autocomplete > ul > li[aria-selected]': { background: 'var(--interactive-selected)', color: 'var(--text-strong)' },
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!host || view) return;
|
||||
if (!host || untrack(() => view)) return;
|
||||
const initialValue = untrack(() => value);
|
||||
const initialReadonly = untrack(() => readonly);
|
||||
const handleChange = untrack(() => onChange);
|
||||
@@ -51,14 +71,17 @@
|
||||
lineNumbers(),
|
||||
drawSelection(),
|
||||
highlightActiveLine(),
|
||||
decodal(),
|
||||
decodal({ highlight: false }),
|
||||
syntaxHighlighting(syntaxTheme),
|
||||
...(handleComplete ? [autocompletion({ override: [async (context: CompletionContext) => {
|
||||
const doc = context.state.doc.toString();
|
||||
return await handleComplete(doc, context.pos, context.explicit);
|
||||
}] })] : []),
|
||||
keymap.of([]),
|
||||
EditorState.readOnly.of(initialReadonly),
|
||||
EditorView.editable.of(!initialReadonly),
|
||||
readonlyCompartment.of([
|
||||
EditorState.readOnly.of(initialReadonly),
|
||||
EditorView.editable.of(!initialReadonly),
|
||||
]),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) handleChange(update.state.doc.toString());
|
||||
}),
|
||||
@@ -73,6 +96,18 @@
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const editor = view;
|
||||
const nextReadonly = readonly;
|
||||
if (!editor) return;
|
||||
editor.dispatch({
|
||||
effects: readonlyCompartment.reconfigure([
|
||||
EditorState.readOnly.of(nextReadonly),
|
||||
EditorView.editable.of(!nextReadonly),
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!view) return;
|
||||
const current = view.state.doc.toString();
|
||||
|
||||
Reference in New Issue
Block a user