web: repair config source editor

This commit is contained in:
2026-08-14 23:28:14 +09:00
parent 1ed835fb79
commit 83a99541b2
18 changed files with 876 additions and 54 deletions
@@ -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");
}
@@ -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
*/
@@ -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();