config: complete virtual tree editor contract

This commit is contained in:
2026-08-14 01:07:14 +09:00
parent 0a7e67e373
commit fa9bd8207f
15 changed files with 398 additions and 127 deletions
+94 -1
View File
@@ -1,9 +1,102 @@
use config_source::{ use config_source::{
ConfigTreeSnapshot, EvaluationResult, SnapshotEnvironment, ToolchainContract, VirtualPath, ConfigTreeChange, ConfigTreeSnapshot, EvaluationResult, SnapshotEnvironment, ToolchainContract,
VirtualPath,
}; };
use serde_wasm_bindgen::{Serializer, from_value}; use serde_wasm_bindgen::{Serializer, from_value};
use std::cell::RefCell;
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
thread_local! {
static SESSION: RefCell<Option<ConfigTreeSnapshot>> = 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<JsValue, JsValue> {
let changes: Vec<ConfigTreeChange> = 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<JsValue, JsValue> {
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<WasmCompletionItem>,
}
#[derive(serde::Serialize)]
struct WasmCompletionItem {
label: String,
kind: String,
detail: Option<String>,
priority: i32,
}
#[wasm_bindgen]
pub fn complete_current(
entrypoint: String,
source: String,
utf8_byte_offset: usize,
explicit: bool,
) -> Result<JsValue, JsValue> {
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] #[wasm_bindgen]
pub fn evaluate_snapshot(snapshot: JsValue, contract: JsValue) -> Result<JsValue, JsValue> { pub fn evaluate_snapshot(snapshot: JsValue, contract: JsValue) -> Result<JsValue, JsValue> {
let snapshot: ConfigTreeSnapshot = decode(snapshot)?; let snapshot: ConfigTreeSnapshot = decode(snapshot)?;
@@ -33,6 +33,7 @@ pub struct ConfigCommitRequest {
pub base_digest: String, pub base_digest: String,
pub changes: Vec<ConfigTreeChange>, pub changes: Vec<ConfigTreeChange>,
pub entrypoints: Vec<VirtualPath>, pub entrypoints: Vec<VirtualPath>,
pub toolchain_fingerprint: String,
} }
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@@ -65,6 +66,17 @@ impl SqliteWorkspaceStore {
current.snapshot.revision 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()) evaluate_candidate(current, &request.changes, request.entrypoints.clone())
} }
@@ -378,6 +390,12 @@ mod tests {
content: "{ broken = ; }".into(), content: "{ broken = ; }".into(),
}], }],
entrypoints: vec![path(DEFAULT_CONFIG_ENTRYPOINT)], 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(); .unwrap_err();
@@ -402,6 +420,12 @@ mod tests {
content: "{ answer = 42; }".into(), content: "{ answer = 42; }".into(),
}], }],
entrypoints: vec![path(DEFAULT_CONFIG_ENTRYPOINT)], entrypoints: vec![path(DEFAULT_CONFIG_ENTRYPOINT)],
toolchain_fingerprint: ToolchainContract::new(
DEFAULT_SCHEMA_VERSION,
vec![path(DEFAULT_CONFIG_ENTRYPOINT)],
DEFAULT_IMPORT_POLICY_VERSION,
)
.fingerprint,
}, },
) )
.unwrap(); .unwrap();
@@ -426,6 +450,12 @@ mod tests {
content: "{ answer = 42; }".into(), content: "{ answer = 42; }".into(),
}], }],
entrypoints: vec![path(DEFAULT_CONFIG_ENTRYPOINT)], 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 let candidate = store
.evaluate_workspace_config_candidate("w-config", &request) .evaluate_workspace_config_candidate("w-config", &request)
@@ -439,6 +469,31 @@ mod tests {
assert!(matches!(error, Error::WorkspaceConfigConflict(_))); 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] #[test]
fn migration_creates_config_authority_without_changing_applied_migrations() { fn migration_creates_config_authority_without_changing_applied_migrations() {
let store = SqliteWorkspaceStore::in_memory().unwrap(); let store = SqliteWorkspaceStore::in_memory().unwrap();
+1
View File
@@ -15,6 +15,7 @@
"@sveltejs/adapter-static": "npm:@sveltejs/adapter-static@3.0.9", "@sveltejs/adapter-static": "npm:@sveltejs/adapter-static@3.0.9",
"@sveltejs/kit": "npm:@sveltejs/kit@2.49.4", "@sveltejs/kit": "npm:@sveltejs/kit@2.49.4",
"@sveltejs/vite-plugin-svelte": "npm:@sveltejs/vite-plugin-svelte@6.2.1", "@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/state": "npm:@codemirror/state@6.7.1",
"@codemirror/view": "npm:@codemirror/view@6.43.8", "@codemirror/view": "npm:@codemirror/view@6.43.8",
"decodal-codemirror": "npm:decodal-codemirror@0.1.6", "decodal-codemirror": "npm:decodal-codemirror@0.1.6",
+12
View File
@@ -3,6 +3,7 @@
"specifiers": { "specifiers": {
"jsr:@std/assert@*": "1.0.19", "jsr:@std/assert@*": "1.0.19",
"jsr:@std/internal@^1.0.12": "1.0.14", "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/state@6.7.1": "6.7.1",
"npm:@codemirror/view@6.43.8": "6.43.8", "npm:@codemirror/view@6.43.8": "6.43.8",
"npm:@fontsource/ibm-plex-mono@5.3.0": "5.3.0", "npm:@fontsource/ibm-plex-mono@5.3.0": "5.3.0",
@@ -34,9 +35,19 @@
} }
}, },
"npm": { "npm": {
"@codemirror/autocomplete@6.20.0": {
"integrity": "sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==",
"dependencies": [
"@codemirror/language",
"@codemirror/state",
"@codemirror/view",
"@lezer/common"
]
},
"@codemirror/language@6.12.4": { "@codemirror/language@6.12.4": {
"integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==", "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==",
"dependencies": [ "dependencies": [
"@codemirror/state",
"@codemirror/view", "@codemirror/view",
"@lezer/common", "@lezer/common",
"@lezer/highlight", "@lezer/highlight",
@@ -988,6 +999,7 @@
}, },
"workspace": { "workspace": {
"dependencies": [ "dependencies": [
"npm:@codemirror/autocomplete@6.20.0",
"npm:@codemirror/state@6.7.1", "npm:@codemirror/state@6.7.1",
"npm:@codemirror/view@6.43.8", "npm:@codemirror/view@6.43.8",
"npm:@sveltejs/adapter-static@3.0.9", "npm:@sveltejs/adapter-static@3.0.9",
@@ -21,6 +21,10 @@
let diagnostics = $state<ConfigDiagnostic[]>([]); let diagnostics = $state<ConfigDiagnostic[]>([]);
let status = $state("Loading source tree…"); let status = $state("Loading source tree…");
let busy = $state(false); let busy = $state(false);
let draftChanges = $state<ConfigTreeChange[]>([]);
let baseRevision = $state(0);
let baseDigest = $state("");
let renamePath = $state("");
let toolchain: ConfigSourceToolchain | null = null; let toolchain: ConfigSourceToolchain | null = null;
const paths = $derived( const paths = $derived(
@@ -29,7 +33,7 @@
const selected = $derived( const selected = $derived(
treeState && selectedPath ? treeState.snapshot.entries[selectedPath] : undefined, 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(() => { onMount(() => {
toolchain = new ConfigSourceToolchain(); toolchain = new ConfigSourceToolchain();
@@ -44,6 +48,11 @@
selectedPath = Object.keys(treeState.snapshot.entries).toSorted()[0] ?? ""; selectedPath = Object.keys(treeState.snapshot.entries).toSorted()[0] ?? "";
} }
source = selectedPath ? treeState.snapshot.entries[selectedPath].content : ""; source = selectedPath ? treeState.snapshot.entries[selectedPath].content : "";
baseRevision = treeState.snapshot.revision;
baseDigest = treeState.snapshot.digest;
await toolchain?.setSnapshot(treeState.snapshot);
draftChanges = [];
renamePath = selectedPath;
diagnostics = []; diagnostics = [];
status = treeState.snapshot.revision === 0 status = treeState.snapshot.revision === 0
? "No committed sources yet. Create workspace.dcdl to begin." ? "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; selectedPath = path;
source = treeState?.snapshot.entries[path]?.content ?? ""; source = treeState?.snapshot.entries[path]?.content ?? "";
renamePath = path;
diagnostics = []; diagnostics = [];
} }
@@ -81,7 +104,9 @@
function entrypoints(): string[] { function entrypoints(): string[] {
if (!treeState) return []; 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") { if (treeState.snapshot.entries["workspace.dcdl"] || selectedPath === "workspace.dcdl") {
return ["workspace.dcdl"]; return ["workspace.dcdl"];
} }
@@ -90,7 +115,7 @@
async function analyze() { async function analyze() {
if (!toolchain || !treeState || !selectedPath) return; 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).`; status = diagnostics.length === 0 ? "No diagnostics." : `${diagnostics.length} diagnostic(s).`;
} }
@@ -106,16 +131,17 @@
async function preview() { async function preview() {
if (!treeState) return; if (!treeState) return;
const change = currentChange(); await stageCurrent();
if (!change) { if (draftChanges.length === 0) {
status = "No draft changes to preview."; status = "No draft changes to preview.";
return; return;
} }
busy = true; busy = true;
try { try {
const candidate = await previewConfigTree(workspaceId, { const candidate = await previewConfigTree(workspaceId, {
changes: [change], changes: draftChanges,
entrypoints: entrypoints(), entrypoints: entrypoints(),
toolchain_fingerprint: treeState.contract.fingerprint,
}); });
diagnostics = []; diagnostics = [];
status = `Preview valid · projection ${candidate.evaluation.projection_digest.slice(0, 20)}…`; status = `Preview valid · projection ${candidate.evaluation.projection_digest.slice(0, 20)}…`;
@@ -128,19 +154,24 @@
async function commit() { async function commit() {
if (!treeState) return; if (!treeState) return;
const change = currentChange(); await stageCurrent();
if (!change) { if (draftChanges.length === 0) {
status = "No draft changes to commit."; status = "No draft changes to commit.";
return; return;
} }
busy = true; busy = true;
try { try {
treeState = await commitConfigTree(workspaceId, { treeState = await commitConfigTree(workspaceId, {
base_revision: treeState.snapshot.revision, base_revision: baseRevision,
base_digest: treeState.snapshot.digest, base_digest: baseDigest,
changes: [change], changes: draftChanges,
entrypoints: entrypoints(), 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 ?? ""; source = treeState.snapshot.entries[selectedPath]?.content ?? "";
diagnostics = []; diagnostics = [];
status = `Committed revision ${treeState.snapshot.revision}.`; status = `Committed revision ${treeState.snapshot.revision}.`;
@@ -161,29 +192,38 @@
} }
async function deleteEntry() { async function deleteEntry() {
if (!treeState || !selected) return; if (!treeState || !selected || !toolchain) return;
busy = true; const change: ConfigTreeChange = {
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", kind: "delete",
path: selectedPath, path: selectedPath,
expected_digest: selected.content_digest, expected_digest: selected.content_digest,
}], };
entrypoints: remainingEntrypoints, draftChanges = [...draftChanges.filter((item) => !changeTouches(item, selectedPath)), change];
}); const candidate = await toolchain.applyChanges([change]);
selectedPath = Object.keys(treeState.snapshot.entries).toSorted()[0] ?? ""; treeState = { ...treeState, snapshot: candidate };
source = selectedPath ? treeState.snapshot.entries[selectedPath].content : ""; selectedPath = Object.keys(candidate.entries).toSorted()[0] ?? "";
diagnostics = []; source = selectedPath ? candidate.entries[selectedPath].content : "";
status = `Committed revision ${treeState.snapshot.revision}.`; renamePath = selectedPath;
} catch (error) { status = "Delete staged. Preview and Commit to persist the candidate tree.";
status = String(error);
} finally {
busy = false;
} }
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.`;
} }
</script> </script>
@@ -216,6 +256,8 @@
<strong>{selectedPath || "Select or create a source"}</strong> <strong>{selectedPath || "Select or create a source"}</strong>
</div> </div>
<div class="config-source-actions"> <div class="config-source-actions">
<input aria-label="Rename path" bind:value={renamePath} disabled={!selected || busy} />
<button type="button" onclick={renameEntry} disabled={!selected || renamePath === selectedPath || busy}>Rename</button>
<button type="button" onclick={format} disabled={!selectedPath || busy}>Format</button> <button type="button" onclick={format} disabled={!selectedPath || busy}>Format</button>
<button type="button" onclick={analyze} disabled={!selectedPath || busy}>Analyze</button> <button type="button" onclick={analyze} disabled={!selectedPath || busy}>Analyze</button>
<button type="button" onclick={preview} disabled={!dirty || busy}>Preview</button> <button type="button" onclick={preview} disabled={!dirty || busy}>Preview</button>
@@ -223,7 +265,12 @@
<button class="danger" type="button" onclick={deleteEntry} disabled={!selected || busy}>Delete</button> <button class="danger" type="button" onclick={deleteEntry} disabled={!selected || busy}>Delete</button>
</div> </div>
</header> </header>
<DecodalSourceEditor value={source} readonly={!selectedPath || busy} onChange={(value) => source = value} /> <DecodalSourceEditor
value={source}
readonly={!selectedPath || busy}
onChange={(value) => source = value}
onComplete={(value, offset, explicit) => toolchain?.complete(selectedPath, value, offset, explicit) ?? Promise.resolve(null)}
/>
<p class="config-source-status" aria-live="polite">{status}</p> <p class="config-source-status" aria-live="polite">{status}</p>
{#if diagnostics.length > 0} {#if diagnostics.length > 0}
<ol class="config-source-diagnostics"> <ol class="config-source-diagnostics">
@@ -3,19 +3,31 @@
export function analyze_snapshot(snapshot: any, entrypoint: string, source_override?: string | null): any; 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 evaluate_snapshot(snapshot: any, contract: any): any;
export function formatSource(source: string): string; export function formatSource(source: string): string;
export function format_source(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 type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module;
export interface InitOutput { export interface InitOutput {
readonly memory: WebAssembly.Memory; readonly memory: WebAssembly.Memory;
readonly analyze_snapshot: (a: any, b: number, c: number, d: number, e: number) => [number, number, number]; 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 evaluate_snapshot: (a: any, b: any) => [number, number, number];
readonly format_source: (a: number, b: number) => [number, 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 formatSource: (a: number, b: number) => [number, number];
readonly __wbindgen_malloc: (a: number, b: number) => number; readonly __wbindgen_malloc: (a: number, b: number) => number;
readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number; readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
@@ -18,6 +18,49 @@ export function analyze_snapshot(snapshot, entrypoint, source_override) {
return takeFromExternrefTable0(ret[0]); 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} snapshot
* @param {any} contract * @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() { function __wbg_get_imports() {
const import0 = { const import0 = {
__proto__: null, __proto__: null,
@@ -199,6 +252,16 @@ function __wbg_get_imports() {
const ret = result; const ret = result;
return ret; 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) { __wbg_instanceof_Uint8Array_4b8da683deb25d72: function(arg0) {
let result; let result;
try { try {
@@ -2,8 +2,12 @@
/* eslint-disable */ /* eslint-disable */
export const memory: WebAssembly.Memory; export const memory: WebAssembly.Memory;
export const analyze_snapshot: (a: any, b: number, c: number, d: number, e: number) => [number, number, number]; 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 evaluate_snapshot: (a: any, b: any) => [number, number, number];
export const format_source: (a: number, b: number) => [number, 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 formatSource: (a: number, b: number) => [number, number];
export const __wbindgen_malloc: (a: number, b: number) => number; export const __wbindgen_malloc: (a: number, b: number) => number;
export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number; export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
@@ -1,75 +1,57 @@
import type { import type { ConfigDiagnostic, ConfigTreeChange, ConfigTreeSnapshot, ToolchainContract } from "./types.ts";
ConfigDiagnostic, import type { ConfigSourceWorkerRequest, ConfigSourceWorkerResponse } from "./toolchain.worker.ts";
ConfigTreeSnapshot,
ToolchainContract,
} from "./types.ts";
import type {
ConfigSourceWorkerRequest,
ConfigSourceWorkerResponse,
} from "./toolchain.worker.ts";
type ConfigSourceWorkerCommand = type Command =
| Omit<Extract<ConfigSourceWorkerRequest, { kind: "set_snapshot" }>, "id">
| Omit<Extract<ConfigSourceWorkerRequest, { kind: "apply_changes" }>, "id">
| Omit<Extract<ConfigSourceWorkerRequest, { kind: "analyze" }>, "id"> | Omit<Extract<ConfigSourceWorkerRequest, { kind: "analyze" }>, "id">
| Omit<Extract<ConfigSourceWorkerRequest, { kind: "evaluate" }>, "id"> | Omit<Extract<ConfigSourceWorkerRequest, { kind: "evaluate" }>, "id">
| Omit<Extract<ConfigSourceWorkerRequest, { kind: "complete" }>, "id">
| Omit<Extract<ConfigSourceWorkerRequest, { kind: "format" }>, "id">; | Omit<Extract<ConfigSourceWorkerRequest, { kind: "format" }>, "id">;
export class ConfigSourceToolchain { export class ConfigSourceToolchain {
#worker: Worker; #worker: Worker;
#nextId = 1; #nextId = 1;
#pending = new Map< #pending = new Map<number, { resolve: (value: unknown) => void; reject: (reason: unknown) => void }>();
number,
{ resolve: (value: unknown) => void; reject: (reason: unknown) => void }
>();
constructor( constructor(worker = new Worker(new URL("./toolchain.worker.ts", import.meta.url), { type: "module" })) {
worker = new Worker(new URL("./toolchain.worker.ts", import.meta.url), {
type: "module",
}),
) {
this.#worker = worker; this.#worker = worker;
worker.addEventListener( worker.addEventListener("message", (event: MessageEvent<ConfigSourceWorkerResponse>) => {
"message",
(event: MessageEvent<ConfigSourceWorkerResponse>) => {
const pending = this.#pending.get(event.data.id); const pending = this.#pending.get(event.data.id);
if (!pending) return; if (!pending) return;
this.#pending.delete(event.data.id); this.#pending.delete(event.data.id);
if (event.data.ok) pending.resolve(event.data.result); if (event.data.ok) pending.resolve(event.data.result);
else pending.reject(event.data.error); else pending.reject(event.data.error);
}, });
);
} }
analyze( setSnapshot(snapshot: ConfigTreeSnapshot): Promise<void> {
snapshot: ConfigTreeSnapshot, return this.#request({ kind: "set_snapshot", snapshot });
path: string,
source?: string,
): Promise<ConfigDiagnostic[]> {
return this.#request({ kind: "analyze", snapshot, path, source });
} }
applyChanges(changes: ConfigTreeChange[]): Promise<ConfigTreeSnapshot> {
evaluate(snapshot: ConfigTreeSnapshot, contract: ToolchainContract) { return this.#request({ kind: "apply_changes", changes });
return this.#request({ kind: "evaluate", snapshot, contract }); }
analyze(path: string, source?: string): Promise<ConfigDiagnostic[]> {
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<import("@codemirror/autocomplete").CompletionResult | null> {
return this.#request({ kind: "complete", path, source, utf8ByteOffset, explicit });
} }
format(source: string): Promise<string> { format(source: string): Promise<string> {
return this.#request({ kind: "format", source }); return this.#request({ kind: "format", source });
} }
close(): void { close(): void {
this.#worker.terminate(); this.#worker.terminate();
for (const pending of this.#pending.values()) { for (const pending of this.#pending.values()) pending.reject(new Error("config source toolchain was closed"));
pending.reject(new Error("config source toolchain was closed"));
}
this.#pending.clear(); this.#pending.clear();
} }
#request<T>(request: Command): Promise<T> {
#request<T>(request: ConfigSourceWorkerCommand): Promise<T> {
const id = this.#nextId++; const id = this.#nextId++;
return new Promise<T>((resolve, reject) => { return new Promise<T>((resolve, reject) => {
this.#pending.set(id, { this.#pending.set(id, { resolve: (value) => resolve(value as T), reject });
resolve: (value) => resolve(value as T),
reject,
});
this.#worker.postMessage({ ...request, id }); this.#worker.postMessage({ ...request, id });
}); });
} }
@@ -1,28 +1,19 @@
import init, { import init, {
analyze_snapshot, analyze_snapshot,
evaluate_snapshot, apply_changes,
complete_current,
evaluate_current,
format_source, format_source,
set_snapshot,
} from "./generated/config_source_wasm.js"; } from "./generated/config_source_wasm.js";
import type { import type { ConfigTreeChange } from "./types.ts";
ConfigDiagnostic,
ConfigTreeSnapshot,
ToolchainContract,
} from "./types.ts";
export type ConfigSourceWorkerRequest = export type ConfigSourceWorkerRequest =
| { | { id: number; kind: "set_snapshot"; snapshot: unknown }
id: number; | { id: number; kind: "apply_changes"; changes: ConfigTreeChange[] }
kind: "analyze"; | { id: number; kind: "analyze"; path: string; source?: string }
snapshot: ConfigTreeSnapshot; | { id: number; kind: "evaluate"; contract: unknown }
path: string; | { id: number; kind: "complete"; path: string; source: string; utf8ByteOffset: number; explicit: boolean }
source?: string;
}
| {
id: number;
kind: "evaluate";
snapshot: ConfigTreeSnapshot;
contract: ToolchainContract;
}
| { id: number; kind: "format"; source: string }; | { id: number; kind: "format"; source: string };
export type ConfigSourceWorkerResponse = export type ConfigSourceWorkerResponse =
@@ -30,24 +21,32 @@ export type ConfigSourceWorkerResponse =
| { id: number; ok: false; error: unknown }; | { id: number; ok: false; error: unknown };
const ready = init(); const ready = init();
let snapshot: unknown = null;
self.onmessage = async ( self.onmessage = async (event: MessageEvent<ConfigSourceWorkerRequest>): Promise<void> => {
event: MessageEvent<ConfigSourceWorkerRequest>,
): Promise<void> => {
const request = event.data; const request = event.data;
try { try {
await ready; await ready;
let result: unknown; let result: unknown;
switch (request.kind) { 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": case "analyze":
result = analyze_snapshot( if (!snapshot) throw new Error("config source snapshot is not initialized");
request.snapshot, result = analyze_snapshot(snapshot, request.path, request.source);
request.path,
request.source,
) as ConfigDiagnostic[];
break; break;
case "evaluate": 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; break;
case "format": case "format":
result = format_source(request.source); result = format_source(request.source);
@@ -55,15 +54,6 @@ self.onmessage = async (
} }
self.postMessage({ id: request.id, ok: true, result }); self.postMessage({ id: request.id, ok: true, result });
} catch (error) { } catch (error) {
self.postMessage({ self.postMessage({ id: request.id, ok: false, error: error instanceof Error ? error.message : error });
id: request.id,
ok: false,
error: normalizeError(error),
});
} }
}; };
function normalizeError(error: unknown): unknown {
if (error instanceof Error) return error.message;
return error;
}
@@ -89,4 +89,5 @@ export interface ConfigCommitRequest {
base_digest: string; base_digest: string;
changes: ConfigTreeChange[]; changes: ConfigTreeChange[];
entrypoints: VirtualPath[]; entrypoints: VirtualPath[];
toolchain_fingerprint: string;
} }
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import { autocompletion, type CompletionContext, type CompletionResult } from '@codemirror/autocomplete';
import { EditorState } from '@codemirror/state'; import { EditorState } from '@codemirror/state';
import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection } from '@codemirror/view'; import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection } from '@codemirror/view';
import { decodal } from 'decodal-codemirror'; import { decodal } from 'decodal-codemirror';
@@ -9,11 +10,13 @@
readonly = false, readonly = false,
ariaLabel = 'Decodal source', ariaLabel = 'Decodal source',
onChange = (_value: string) => {}, onChange = (_value: string) => {},
onComplete = undefined,
}: { }: {
value?: string; value?: string;
readonly?: boolean; readonly?: boolean;
ariaLabel?: string; ariaLabel?: string;
onChange?: (value: string) => void; onChange?: (value: string) => void;
onComplete?: (source: string, utf8ByteOffset: number, explicit: boolean) => Promise<CompletionResult | null>;
} = $props(); } = $props();
let host = $state<HTMLDivElement | null>(null); let host = $state<HTMLDivElement | null>(null);
@@ -39,6 +42,7 @@
const initialValue = untrack(() => value); const initialValue = untrack(() => value);
const initialReadonly = untrack(() => readonly); const initialReadonly = untrack(() => readonly);
const handleChange = untrack(() => onChange); const handleChange = untrack(() => onChange);
const handleComplete = untrack(() => onComplete);
const editor = new EditorView({ const editor = new EditorView({
parent: host, parent: host,
state: EditorState.create({ state: EditorState.create({
@@ -48,6 +52,11 @@
drawSelection(), drawSelection(),
highlightActiveLine(), highlightActiveLine(),
decodal(), decodal(),
...(handleComplete ? [autocompletion({ override: [async (context: CompletionContext) => {
const doc = context.state.doc.toString();
const utf8ByteOffset = new TextEncoder().encode(doc.slice(0, context.pos)).byteLength;
return await handleComplete(doc, utf8ByteOffset, context.explicit);
}] })] : []),
keymap.of([]), keymap.of([]),
EditorState.readOnly.of(initialReadonly), EditorState.readOnly.of(initialReadonly),
EditorView.editable.of(!initialReadonly), EditorView.editable.of(!initialReadonly),
@@ -572,7 +572,8 @@
color: var(--text-muted); color: var(--text-muted);
font-size: 0.75rem; font-size: 0.75rem;
} }
.config-source-create input { .config-source-create input,
.config-source-actions input {
min-width: 0; min-width: 0;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 0.45rem; border-radius: 0.45rem;
+3 -2
View File
@@ -24,12 +24,13 @@ Deno.test("config source API stays workspace-scoped and separates preview from c
await fetchConfigTree("w/one", fetcher); await fetchConfigTree("w/one", fetcher);
await fetchConfigEntry("w/one", "profiles/main.dcdl", fetcher); await fetchConfigEntry("w/one", "profiles/main.dcdl", fetcher);
await previewConfigTree("w/one", { changes: [], entrypoints: [] }, fetcher); await previewConfigTree("w/one", { changes: [], entrypoints: [], toolchain_fingerprint: "sha256:toolchain" }, fetcher);
await commitConfigTree("w/one", { await commitConfigTree("w/one", {
base_revision: 4, base_revision: 4,
base_digest: "sha256:base", base_digest: "sha256:base",
changes: [], changes: [],
entrypoints: [], entrypoints: [],
toolchain_fingerprint: "sha256:toolchain",
}, fetcher); }, fetcher);
assertEquals(calls.map((call) => call.url), [ assertEquals(calls.map((call) => call.url), [
@@ -52,7 +53,7 @@ Deno.test("config source API surfaces failed evaluation instead of treating it a
)) as typeof fetch; )) as typeof fetch;
let message = ""; let message = "";
try { try {
await previewConfigTree("w", { changes: [], entrypoints: [] }, fetcher); await previewConfigTree("w", { changes: [], entrypoints: [], toolchain_fingerprint: "sha256:toolchain" }, fetcher);
} catch (error) { } catch (error) {
message = String(error); message = String(error);
} }