diff --git a/crates/config-source-wasm/src/lib.rs b/crates/config-source-wasm/src/lib.rs index 042312bb..21d64ac7 100644 --- a/crates/config-source-wasm/src/lib.rs +++ b/crates/config-source-wasm/src/lib.rs @@ -1,9 +1,102 @@ use config_source::{ - ConfigTreeSnapshot, EvaluationResult, SnapshotEnvironment, ToolchainContract, VirtualPath, + ConfigTreeChange, ConfigTreeSnapshot, EvaluationResult, SnapshotEnvironment, ToolchainContract, + VirtualPath, }; use serde_wasm_bindgen::{Serializer, from_value}; +use std::cell::RefCell; use wasm_bindgen::prelude::*; +thread_local! { + static SESSION: RefCell> = const { RefCell::new(None) }; +} + +#[wasm_bindgen] +pub fn set_snapshot(snapshot: JsValue) -> Result<(), JsValue> { + let snapshot: ConfigTreeSnapshot = decode(snapshot)?; + SESSION.with(|session| session.replace(Some(snapshot))); + Ok(()) +} + +#[wasm_bindgen] +pub fn apply_changes(changes: JsValue) -> Result { + let changes: Vec = decode(changes)?; + SESSION.with(|session| { + let mut session = session.borrow_mut(); + let snapshot = session + .as_ref() + .ok_or_else(|| JsValue::from_str("config source snapshot is not initialized"))? + .apply(&changes) + .map_err(js_error)?; + *session = Some(snapshot.clone()); + encode(snapshot) + }) +} + +#[wasm_bindgen] +pub fn evaluate_current(contract: JsValue) -> Result { + let contract: ToolchainContract = decode(contract)?; + SESSION.with(|session| { + let session = session.borrow(); + let snapshot = session + .as_ref() + .ok_or_else(|| JsValue::from_str("config source snapshot is not initialized"))?; + encode( + SnapshotEnvironment::new(snapshot.clone()) + .evaluate_contract(&contract) + .map_err(|diagnostics| { + encode(&diagnostics).unwrap_or_else(|_| JsValue::from_str("evaluation failed")) + })?, + ) + }) +} + +#[derive(serde::Serialize)] +struct WasmCompletionResult { + from: usize, + items: Vec, +} + +#[derive(serde::Serialize)] +struct WasmCompletionItem { + label: String, + kind: String, + detail: Option, + priority: i32, +} + +#[wasm_bindgen] +pub fn complete_current( + entrypoint: String, + source: String, + utf8_byte_offset: usize, + explicit: bool, +) -> Result { + let entrypoint = VirtualPath::parse(entrypoint).map_err(js_error)?; + SESSION.with(|session| { + let session = session.borrow(); + let snapshot = session + .as_ref() + .ok_or_else(|| JsValue::from_str("config source snapshot is not initialized"))?; + let result = SnapshotEnvironment::new(snapshot.clone()) + .complete(&entrypoint, &source, utf8_byte_offset, explicit) + .map_err(|error| JsValue::from_str(&format!("{error:?}")))? + .map(|result| WasmCompletionResult { + from: result.from, + items: result + .items + .into_iter() + .map(|item| WasmCompletionItem { + label: item.label, + kind: format!("{:?}", item.kind).to_lowercase(), + detail: item.detail, + priority: item.priority, + }) + .collect(), + }); + encode(result) + }) +} + #[wasm_bindgen] pub fn evaluate_snapshot(snapshot: JsValue, contract: JsValue) -> Result { let snapshot: ConfigTreeSnapshot = decode(snapshot)?; diff --git a/crates/workspace-server/src/config_source.rs b/crates/workspace-server/src/config_source.rs index 232c86d2..e9f72a25 100644 --- a/crates/workspace-server/src/config_source.rs +++ b/crates/workspace-server/src/config_source.rs @@ -33,6 +33,7 @@ pub struct ConfigCommitRequest { pub base_digest: String, pub changes: Vec, pub entrypoints: Vec, + pub toolchain_fingerprint: String, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -65,6 +66,17 @@ impl SqliteWorkspaceStore { current.snapshot.revision ))); } + let expected_contract = ToolchainContract::new( + DEFAULT_SCHEMA_VERSION, + request.entrypoints.clone(), + DEFAULT_IMPORT_POLICY_VERSION, + ); + if expected_contract.fingerprint != request.toolchain_fingerprint { + return Err(config_conflict(format!( + "toolchain fingerprint mismatch; current fingerprint is {}", + expected_contract.fingerprint + ))); + } evaluate_candidate(current, &request.changes, request.entrypoints.clone()) } @@ -378,6 +390,12 @@ mod tests { content: "{ broken = ; }".into(), }], entrypoints: vec![path(DEFAULT_CONFIG_ENTRYPOINT)], + toolchain_fingerprint: ToolchainContract::new( + DEFAULT_SCHEMA_VERSION, + vec![path(DEFAULT_CONFIG_ENTRYPOINT)], + DEFAULT_IMPORT_POLICY_VERSION, + ) + .fingerprint, }, ) .unwrap_err(); @@ -402,6 +420,12 @@ mod tests { content: "{ answer = 42; }".into(), }], entrypoints: vec![path(DEFAULT_CONFIG_ENTRYPOINT)], + toolchain_fingerprint: ToolchainContract::new( + DEFAULT_SCHEMA_VERSION, + vec![path(DEFAULT_CONFIG_ENTRYPOINT)], + DEFAULT_IMPORT_POLICY_VERSION, + ) + .fingerprint, }, ) .unwrap(); @@ -426,6 +450,12 @@ mod tests { content: "{ answer = 42; }".into(), }], entrypoints: vec![path(DEFAULT_CONFIG_ENTRYPOINT)], + toolchain_fingerprint: ToolchainContract::new( + DEFAULT_SCHEMA_VERSION, + vec![path(DEFAULT_CONFIG_ENTRYPOINT)], + DEFAULT_IMPORT_POLICY_VERSION, + ) + .fingerprint, }; let candidate = store .evaluate_workspace_config_candidate("w-config", &request) @@ -439,6 +469,31 @@ mod tests { assert!(matches!(error, Error::WorkspaceConfigConflict(_))); } + #[tokio::test] + async fn commit_rejects_mismatched_toolchain_fingerprint() { + let store = SqliteWorkspaceStore::in_memory().unwrap(); + store.upsert_workspace(&workspace()).await.unwrap(); + let empty = ConfigTreeSnapshot::empty(); + let error = store + .evaluate_and_commit_workspace_config( + "w-config", + &ConfigCommitRequest { + base_revision: 0, + base_digest: empty.digest, + changes: vec![ConfigTreeChange::Create { + path: path(DEFAULT_CONFIG_ENTRYPOINT), + content_type: ConfigContentType::Decodal, + content: "{ answer = 42; }".into(), + }], + entrypoints: vec![path(DEFAULT_CONFIG_ENTRYPOINT)], + toolchain_fingerprint: "sha256:stale-toolchain".into(), + }, + ) + .unwrap_err(); + assert!(matches!(error, Error::WorkspaceConfigConflict(_))); + assert!(store.load_workspace_config("w-config").unwrap().is_none()); + } + #[test] fn migration_creates_config_authority_without_changing_applied_migrations() { let store = SqliteWorkspaceStore::in_memory().unwrap(); diff --git a/web/workspace/deno.json b/web/workspace/deno.json index f0f9491e..07317361 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -15,6 +15,7 @@ "@sveltejs/adapter-static": "npm:@sveltejs/adapter-static@3.0.9", "@sveltejs/kit": "npm:@sveltejs/kit@2.49.4", "@sveltejs/vite-plugin-svelte": "npm:@sveltejs/vite-plugin-svelte@6.2.1", + "@codemirror/autocomplete": "npm:@codemirror/autocomplete@6.20.0", "@codemirror/state": "npm:@codemirror/state@6.7.1", "@codemirror/view": "npm:@codemirror/view@6.43.8", "decodal-codemirror": "npm:decodal-codemirror@0.1.6", diff --git a/web/workspace/deno.lock b/web/workspace/deno.lock index 0242d46c..0811f3fa 100644 --- a/web/workspace/deno.lock +++ b/web/workspace/deno.lock @@ -3,6 +3,7 @@ "specifiers": { "jsr:@std/assert@*": "1.0.19", "jsr:@std/internal@^1.0.12": "1.0.14", + "npm:@codemirror/autocomplete@6.20.0": "6.20.0", "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", @@ -34,9 +35,19 @@ } }, "npm": { + "@codemirror/autocomplete@6.20.0": { + "integrity": "sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==", + "dependencies": [ + "@codemirror/language", + "@codemirror/state", + "@codemirror/view", + "@lezer/common" + ] + }, "@codemirror/language@6.12.4": { "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==", "dependencies": [ + "@codemirror/state", "@codemirror/view", "@lezer/common", "@lezer/highlight", @@ -988,6 +999,7 @@ }, "workspace": { "dependencies": [ + "npm:@codemirror/autocomplete@6.20.0", "npm:@codemirror/state@6.7.1", "npm:@codemirror/view@6.43.8", "npm:@sveltejs/adapter-static@3.0.9", diff --git a/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.svelte b/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.svelte index aab09791..ffeae315 100644 --- a/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.svelte +++ b/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.svelte @@ -21,6 +21,10 @@ let diagnostics = $state([]); let status = $state("Loading source tree…"); let busy = $state(false); + let draftChanges = $state([]); + let baseRevision = $state(0); + let baseDigest = $state(""); + let renamePath = $state(""); let toolchain: ConfigSourceToolchain | null = null; const paths = $derived( @@ -29,7 +33,7 @@ const selected = $derived( treeState && selectedPath ? treeState.snapshot.entries[selectedPath] : undefined, ); - const dirty = $derived(selected ? source !== selected.content : source.length > 0); + const dirty = $derived(draftChanges.length > 0 || (selected ? source !== selected.content : source.length > 0)); onMount(() => { toolchain = new ConfigSourceToolchain(); @@ -44,6 +48,11 @@ selectedPath = Object.keys(treeState.snapshot.entries).toSorted()[0] ?? ""; } source = selectedPath ? treeState.snapshot.entries[selectedPath].content : ""; + baseRevision = treeState.snapshot.revision; + baseDigest = treeState.snapshot.digest; + await toolchain?.setSnapshot(treeState.snapshot); + draftChanges = []; + renamePath = selectedPath; diagnostics = []; status = treeState.snapshot.revision === 0 ? "No committed sources yet. Create workspace.dcdl to begin." @@ -53,9 +62,23 @@ } } - function select(path: string) { + async function stageCurrent() { + const change = currentChange(); + if (!change || !toolchain) return; + draftChanges = [...draftChanges.filter((item) => !changeTouches(item, selectedPath)), change]; + const candidate = await toolchain.applyChanges([change]); + if (treeState) treeState = { ...treeState, snapshot: candidate }; + } + + function changeTouches(change: ConfigTreeChange, path: string): boolean { + return change.kind === "rename" ? change.from === path || change.to === path : change.path === path; + } + + async function select(path: string) { + await stageCurrent(); selectedPath = path; source = treeState?.snapshot.entries[path]?.content ?? ""; + renamePath = path; diagnostics = []; } @@ -81,7 +104,9 @@ function entrypoints(): string[] { if (!treeState) return []; - if (treeState.contract.entrypoints.length > 0) return treeState.contract.entrypoints; + const known = new Set(Object.keys(treeState.snapshot.entries)); + const configured = treeState.contract.entrypoints.filter((path) => known.has(path)); + if (configured.length > 0) return configured; if (treeState.snapshot.entries["workspace.dcdl"] || selectedPath === "workspace.dcdl") { return ["workspace.dcdl"]; } @@ -90,7 +115,7 @@ async function analyze() { if (!toolchain || !treeState || !selectedPath) return; - diagnostics = await toolchain.analyze(treeState.snapshot, selectedPath, source); + diagnostics = await toolchain.analyze(selectedPath, source); status = diagnostics.length === 0 ? "No diagnostics." : `${diagnostics.length} diagnostic(s).`; } @@ -106,16 +131,17 @@ async function preview() { if (!treeState) return; - const change = currentChange(); - if (!change) { + await stageCurrent(); + if (draftChanges.length === 0) { status = "No draft changes to preview."; return; } busy = true; try { const candidate = await previewConfigTree(workspaceId, { - changes: [change], + changes: draftChanges, entrypoints: entrypoints(), + toolchain_fingerprint: treeState.contract.fingerprint, }); diagnostics = []; status = `Preview valid · projection ${candidate.evaluation.projection_digest.slice(0, 20)}…`; @@ -128,19 +154,24 @@ async function commit() { if (!treeState) return; - const change = currentChange(); - if (!change) { + await stageCurrent(); + if (draftChanges.length === 0) { status = "No draft changes to commit."; return; } busy = true; try { treeState = await commitConfigTree(workspaceId, { - base_revision: treeState.snapshot.revision, - base_digest: treeState.snapshot.digest, - changes: [change], + base_revision: baseRevision, + base_digest: baseDigest, + changes: draftChanges, entrypoints: entrypoints(), + toolchain_fingerprint: treeState.contract.fingerprint, }); + draftChanges = []; + baseRevision = treeState.snapshot.revision; + baseDigest = treeState.snapshot.digest; + await toolchain?.setSnapshot(treeState.snapshot); source = treeState.snapshot.entries[selectedPath]?.content ?? ""; diagnostics = []; status = `Committed revision ${treeState.snapshot.revision}.`; @@ -161,29 +192,38 @@ } async function deleteEntry() { - if (!treeState || !selected) return; - busy = true; - try { - const remainingEntrypoints = treeState.contract.entrypoints.filter((path) => path !== selectedPath); - treeState = await commitConfigTree(workspaceId, { - base_revision: treeState.snapshot.revision, - base_digest: treeState.snapshot.digest, - changes: [{ - kind: "delete", - path: selectedPath, - expected_digest: selected.content_digest, - }], - entrypoints: remainingEntrypoints, - }); - selectedPath = Object.keys(treeState.snapshot.entries).toSorted()[0] ?? ""; - source = selectedPath ? treeState.snapshot.entries[selectedPath].content : ""; - diagnostics = []; - status = `Committed revision ${treeState.snapshot.revision}.`; - } catch (error) { - status = String(error); - } finally { - busy = false; - } + if (!treeState || !selected || !toolchain) return; + const change: ConfigTreeChange = { + kind: "delete", + path: selectedPath, + expected_digest: selected.content_digest, + }; + draftChanges = [...draftChanges.filter((item) => !changeTouches(item, selectedPath)), change]; + const candidate = await toolchain.applyChanges([change]); + treeState = { ...treeState, snapshot: candidate }; + selectedPath = Object.keys(candidate.entries).toSorted()[0] ?? ""; + source = selectedPath ? candidate.entries[selectedPath].content : ""; + renamePath = selectedPath; + status = "Delete staged. Preview and Commit to persist the candidate tree."; + } + + async function renameEntry() { + if (!treeState || !selected || !toolchain) return; + const to = renamePath.trim(); + if (!to || to === selectedPath) return; + await stageCurrent(); + const change: ConfigTreeChange = { + kind: "rename", + from: selectedPath, + to, + expected_digest: selected.content_digest, + }; + draftChanges = [...draftChanges.filter((item) => !changeTouches(item, selectedPath)), change]; + const candidate = await toolchain.applyChanges([change]); + treeState = { ...treeState, snapshot: candidate }; + selectedPath = to; + source = candidate.entries[to]?.content ?? ""; + status = `Rename to ${to} staged. Preview and Commit to persist.`; } @@ -216,6 +256,8 @@ {selectedPath || "Select or create a source"}
+ + @@ -223,7 +265,12 @@
- source = value} /> + source = value} + onComplete={(value, offset, explicit) => toolchain?.complete(selectedPath, value, offset, explicit) ?? Promise.resolve(null)} + />

{status}

{#if diagnostics.length > 0}
    diff --git a/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm.d.ts b/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm.d.ts index 88d3b53b..6e3d657c 100644 --- a/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm.d.ts +++ b/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm.d.ts @@ -3,19 +3,31 @@ export function analyze_snapshot(snapshot: any, entrypoint: string, source_override?: string | null): any; +export function apply_changes(changes: any): any; + +export function complete_current(entrypoint: string, source: string, utf8_byte_offset: number, explicit: boolean): any; + +export function evaluate_current(contract: any): any; + export function evaluate_snapshot(snapshot: any, contract: any): any; export function formatSource(source: string): string; export function format_source(source: string): string; +export function set_snapshot(snapshot: any): void; + export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module; export interface InitOutput { readonly memory: WebAssembly.Memory; readonly analyze_snapshot: (a: any, b: number, c: number, d: number, e: number) => [number, number, number]; + readonly apply_changes: (a: any) => [number, number, number]; + readonly complete_current: (a: number, b: number, c: number, d: number, e: number, f: number) => [number, number, number]; + 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_snapshot: (a: any) => [number, number]; readonly formatSource: (a: number, b: number) => [number, number]; readonly __wbindgen_malloc: (a: number, b: number) => number; readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number; diff --git a/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm.js b/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm.js index 0af013fc..f12b9440 100644 --- a/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm.js +++ b/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm.js @@ -18,6 +18,49 @@ export function analyze_snapshot(snapshot, entrypoint, source_override) { return takeFromExternrefTable0(ret[0]); } +/** + * @param {any} changes + * @returns {any} + */ +export function apply_changes(changes) { + const ret = wasm.apply_changes(changes); + if (ret[2]) { + throw takeFromExternrefTable0(ret[1]); + } + return takeFromExternrefTable0(ret[0]); +} + +/** + * @param {string} entrypoint + * @param {string} source + * @param {number} utf8_byte_offset + * @param {boolean} explicit + * @returns {any} + */ +export function complete_current(entrypoint, source, utf8_byte_offset, explicit) { + const ptr0 = passStringToWasm0(entrypoint, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + const ptr1 = passStringToWasm0(source, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + const ret = wasm.complete_current(ptr0, len0, ptr1, len1, utf8_byte_offset, explicit); + if (ret[2]) { + throw takeFromExternrefTable0(ret[1]); + } + return takeFromExternrefTable0(ret[0]); +} + +/** + * @param {any} contract + * @returns {any} + */ +export function evaluate_current(contract) { + const ret = wasm.evaluate_current(contract); + if (ret[2]) { + throw takeFromExternrefTable0(ret[1]); + } + return takeFromExternrefTable0(ret[0]); +} + /** * @param {any} snapshot * @param {any} contract @@ -75,6 +118,16 @@ export function format_source(source) { } } +/** + * @param {any} snapshot + */ +export function set_snapshot(snapshot) { + const ret = wasm.set_snapshot(snapshot); + if (ret[1]) { + throw takeFromExternrefTable0(ret[0]); + } +} + function __wbg_get_imports() { const import0 = { __proto__: null, @@ -199,6 +252,16 @@ function __wbg_get_imports() { const ret = result; return ret; }, + __wbg_instanceof_Map_a10a2795ef4bfe97: function(arg0) { + let result; + try { + result = arg0 instanceof Map; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }, __wbg_instanceof_Uint8Array_4b8da683deb25d72: function(arg0) { let result; try { diff --git a/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm_bg.wasm b/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm_bg.wasm index dbbca662..7b59d0fa 100644 Binary files a/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm_bg.wasm and b/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm_bg.wasm differ diff --git a/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm_bg.wasm.d.ts b/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm_bg.wasm.d.ts index ee4a225b..85f6acbf 100644 --- a/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm_bg.wasm.d.ts +++ b/web/workspace/src/lib/workspace/config-source/generated/config_source_wasm_bg.wasm.d.ts @@ -2,8 +2,12 @@ /* eslint-disable */ export const memory: WebAssembly.Memory; export const analyze_snapshot: (a: any, b: number, c: number, d: number, e: number) => [number, number, number]; +export const apply_changes: (a: any) => [number, number, number]; +export const complete_current: (a: number, b: number, c: number, d: number, e: number, f: number) => [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_snapshot: (a: any) => [number, number]; export const formatSource: (a: number, b: number) => [number, number]; export const __wbindgen_malloc: (a: number, b: number) => number; export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number; diff --git a/web/workspace/src/lib/workspace/config-source/toolchain.ts b/web/workspace/src/lib/workspace/config-source/toolchain.ts index b0f98fa9..d1164e64 100644 --- a/web/workspace/src/lib/workspace/config-source/toolchain.ts +++ b/web/workspace/src/lib/workspace/config-source/toolchain.ts @@ -1,75 +1,57 @@ -import type { - ConfigDiagnostic, - ConfigTreeSnapshot, - ToolchainContract, -} from "./types.ts"; -import type { - ConfigSourceWorkerRequest, - ConfigSourceWorkerResponse, -} from "./toolchain.worker.ts"; +import type { ConfigDiagnostic, ConfigTreeChange, ConfigTreeSnapshot, ToolchainContract } from "./types.ts"; +import type { ConfigSourceWorkerRequest, ConfigSourceWorkerResponse } from "./toolchain.worker.ts"; -type ConfigSourceWorkerCommand = +type Command = + | Omit, "id"> + | Omit, "id"> | Omit, "id"> | Omit, "id"> + | Omit, "id"> | Omit, "id">; export class ConfigSourceToolchain { #worker: Worker; #nextId = 1; - #pending = new Map< - number, - { resolve: (value: unknown) => void; reject: (reason: unknown) => void } - >(); + #pending = new Map 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) => { - 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) => { + 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); + }); } - analyze( - snapshot: ConfigTreeSnapshot, - path: string, - source?: string, - ): Promise { - return this.#request({ kind: "analyze", snapshot, path, source }); + setSnapshot(snapshot: ConfigTreeSnapshot): Promise { + return this.#request({ kind: "set_snapshot", snapshot }); } - - evaluate(snapshot: ConfigTreeSnapshot, contract: ToolchainContract) { - return this.#request({ kind: "evaluate", snapshot, contract }); + applyChanges(changes: ConfigTreeChange[]): Promise { + return this.#request({ kind: "apply_changes", changes }); + } + analyze(path: string, source?: string): Promise { + return this.#request({ kind: "analyze", path, source }); + } + evaluate(contract: ToolchainContract) { + return this.#request({ kind: "evaluate", contract }); + } + complete(path: string, source: string, utf8ByteOffset: number, explicit = false): Promise { + return this.#request({ kind: "complete", path, source, utf8ByteOffset, explicit }); } - format(source: string): Promise { 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(request: ConfigSourceWorkerCommand): Promise { + #request(request: Command): Promise { const id = this.#nextId++; return new Promise((resolve, reject) => { - this.#pending.set(id, { - resolve: (value) => resolve(value as T), - reject, - }); + this.#pending.set(id, { resolve: (value) => resolve(value as T), reject }); this.#worker.postMessage({ ...request, id }); }); } diff --git a/web/workspace/src/lib/workspace/config-source/toolchain.worker.ts b/web/workspace/src/lib/workspace/config-source/toolchain.worker.ts index aec5bc4f..9ca865f4 100644 --- a/web/workspace/src/lib/workspace/config-source/toolchain.worker.ts +++ b/web/workspace/src/lib/workspace/config-source/toolchain.worker.ts @@ -1,28 +1,19 @@ import init, { analyze_snapshot, - evaluate_snapshot, + apply_changes, + complete_current, + evaluate_current, format_source, + set_snapshot, } from "./generated/config_source_wasm.js"; -import type { - ConfigDiagnostic, - ConfigTreeSnapshot, - ToolchainContract, -} from "./types.ts"; +import type { ConfigTreeChange } from "./types.ts"; export type ConfigSourceWorkerRequest = - | { - id: number; - kind: "analyze"; - snapshot: ConfigTreeSnapshot; - path: string; - source?: string; - } - | { - id: number; - kind: "evaluate"; - snapshot: ConfigTreeSnapshot; - contract: ToolchainContract; - } + | { id: number; kind: "set_snapshot"; snapshot: unknown } + | { id: number; kind: "apply_changes"; changes: ConfigTreeChange[] } + | { id: number; kind: "analyze"; path: string; source?: string } + | { id: number; kind: "evaluate"; contract: unknown } + | { id: number; kind: "complete"; path: string; source: string; utf8ByteOffset: number; explicit: boolean } | { id: number; kind: "format"; source: string }; export type ConfigSourceWorkerResponse = @@ -30,24 +21,32 @@ export type ConfigSourceWorkerResponse = | { id: number; ok: false; error: unknown }; const ready = init(); +let snapshot: unknown = null; -self.onmessage = async ( - event: MessageEvent, -): Promise => { +self.onmessage = async (event: MessageEvent): Promise => { const request = event.data; try { await ready; let result: unknown; switch (request.kind) { + case "set_snapshot": + snapshot = request.snapshot; + set_snapshot(request.snapshot); + result = null; + break; + case "apply_changes": + snapshot = apply_changes(request.changes); + result = snapshot; + break; case "analyze": - result = analyze_snapshot( - request.snapshot, - request.path, - request.source, - ) as ConfigDiagnostic[]; + if (!snapshot) throw new Error("config source snapshot is not initialized"); + result = analyze_snapshot(snapshot, request.path, request.source); break; case "evaluate": - result = evaluate_snapshot(request.snapshot, request.contract); + result = evaluate_current(request.contract); + break; + case "complete": + result = complete_current(request.path, request.source, request.utf8ByteOffset, request.explicit); break; case "format": result = format_source(request.source); @@ -55,15 +54,6 @@ self.onmessage = async ( } self.postMessage({ id: request.id, ok: true, result }); } catch (error) { - self.postMessage({ - id: request.id, - ok: false, - error: normalizeError(error), - }); + self.postMessage({ id: request.id, ok: false, error: error instanceof Error ? error.message : error }); } }; - -function normalizeError(error: unknown): unknown { - if (error instanceof Error) return error.message; - return error; -} diff --git a/web/workspace/src/lib/workspace/config-source/types.ts b/web/workspace/src/lib/workspace/config-source/types.ts index 7f910c8e..f157a515 100644 --- a/web/workspace/src/lib/workspace/config-source/types.ts +++ b/web/workspace/src/lib/workspace/config-source/types.ts @@ -89,4 +89,5 @@ export interface ConfigCommitRequest { base_digest: string; changes: ConfigTreeChange[]; entrypoints: VirtualPath[]; + toolchain_fingerprint: string; } diff --git a/web/workspace/src/lib/workspace/settings/DecodalSourceEditor.svelte b/web/workspace/src/lib/workspace/settings/DecodalSourceEditor.svelte index 16db8214..6a9411d0 100644 --- a/web/workspace/src/lib/workspace/settings/DecodalSourceEditor.svelte +++ b/web/workspace/src/lib/workspace/settings/DecodalSourceEditor.svelte @@ -1,5 +1,6 @@