From 404809ab6ee89b96eac1c9ae0f2884e0285627b9 Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 15 Aug 2026 01:28:25 +0900 Subject: [PATCH] config: format sources before commit --- crates/workspace-server/src/config_source.rs | 39 ++++++- .../config-source/ConfigSourceEditor.svelte | 109 ++++++++++++------ .../test/config-source/editor-state.test.ts | 30 +++++ 3 files changed, 143 insertions(+), 35 deletions(-) diff --git a/crates/workspace-server/src/config_source.rs b/crates/workspace-server/src/config_source.rs index da3e63af..092248b1 100644 --- a/crates/workspace-server/src/config_source.rs +++ b/crates/workspace-server/src/config_source.rs @@ -456,6 +456,40 @@ fn normalize_main_config_schema_assertion( ConfigTreeSnapshot::from_entries(revision, entries).map_err(config_error) } +fn format_candidate_sources( + snapshot: ConfigTreeSnapshot, + changes: &[ConfigTreeChange], +) -> Result { + let mut paths = std::collections::BTreeSet::from([main_config_path()]); + for change in changes { + match change { + ConfigTreeChange::Create { path, .. } | ConfigTreeChange::Update { path, .. } => { + paths.insert(path.clone()); + } + ConfigTreeChange::Rename { to, .. } => { + paths.insert(to.clone()); + } + ConfigTreeChange::Delete { .. } => {} + } + } + + let environment = SnapshotEnvironment::new(snapshot.clone()); + let revision = snapshot.revision; + let mut entries = Vec::with_capacity(snapshot.entries.len()); + for entry in snapshot.entries.into_values() { + if paths.contains(&entry.path) && entry.content_type == ConfigContentType::Decodal { + let formatted = environment.format(&entry.content).map_err(config_error)?; + entries.push( + ConfigEntry::new(entry.path, entry.content_type, formatted) + .map_err(config_error)?, + ); + } else { + entries.push(entry); + } + } + ConfigTreeSnapshot::from_entries(revision, entries).map_err(config_error) +} + fn evaluate_candidate( current: WorkspaceConfigState, changes: &[ConfigTreeChange], @@ -465,6 +499,7 @@ fn evaluate_candidate( let snapshot = current.snapshot.apply(changes).map_err(config_error)?; ensure_main_entrypoint(&snapshot)?; let snapshot = normalize_main_config_schema_assertion(snapshot)?; + let snapshot = format_candidate_sources(snapshot, changes)?; let contract = main_config_contract_with_schema(schema_bundle); let evaluation = SnapshotEnvironment::new(snapshot.clone()) .evaluate_contract(&contract) @@ -1322,7 +1357,7 @@ mod tests { } #[tokio::test] - async fn candidate_normalizes_main_schema_assertion_only_for_entrypoint() { + async fn candidate_normalizes_main_and_formats_changed_decodal_sources() { let store = open_store().await; let current = store.load_workspace_config("w-config").unwrap().unwrap(); let candidate = store @@ -1356,7 +1391,7 @@ mod tests { .get(&path("module.dcdl")) .unwrap() .content, - "{}" + "{}\n" ); } diff --git a/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.svelte b/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.svelte index d3ca12e0..dc243431 100644 --- a/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.svelte +++ b/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.svelte @@ -130,28 +130,72 @@ } } + async function formatDraftSources() { + if (!toolchain || !treeState || !baseSnapshot) return; + await stageCurrent(); + const paths = new Set(); + for (const change of draftChanges) { + if (change.kind === "create" || change.kind === "update") paths.add(change.path); + if (change.kind === "rename") paths.add(change.to); + } + + let candidate = treeState.snapshot; + let formattedAny = false; + for (const path of paths) { + const entry = candidate.entries[path]; + if (!entry || entry.content_type !== "decodal") continue; + const formatted = await toolchain.format(entry.content); + if (formatted === entry.content) continue; + candidate = await toolchain.applyChanges([{ + kind: "update", + path, + expected_digest: entry.content_digest, + content: formatted, + }]); + formattedAny = true; + } + if (!formattedAny) return; + + treeState = { ...treeState, snapshot: candidate }; + draftChanges = await toolchain.changesBetween(baseSnapshot, candidate); + source = candidate.entries[selectedPath]?.content ?? source; + preflightDigest = ""; + candidateContract = null; + conflict = false; + } + + async function requestCandidatePreview() { + if (!toolchain) throw new Error("config source toolchain is unavailable"); + const candidate = await previewConfigTree(workspaceId, { + changes: draftChanges, + entrypoints: entrypoints(), + }); + await toolchain.evaluate(candidate.contract); + diagnostics = []; + candidateContract = candidate.contract; + preflightDigest = candidate.snapshot.digest; + return candidate; + } + + function recordCandidateError(error: unknown) { + const message = String(error); + conflict = message.includes("conflict") || message.includes("base revision/digest mismatch"); + status = conflict ? `${message} Reload the authoritative tree before editing again.` : message; + } + async function preview() { if (!treeState) return; - await stageCurrent(); - if (draftChanges.length === 0) { - status = "No draft changes to preview."; - return; - } busy = true; try { - const candidate = await previewConfigTree(workspaceId, { - changes: draftChanges, - entrypoints: entrypoints(), - }); - await toolchain?.evaluate(candidate.contract); - diagnostics = []; - candidateContract = candidate.contract; - preflightDigest = candidate.snapshot.digest; + await formatDraftSources(); + if (draftChanges.length === 0) { + status = "No draft changes to preview."; + return; + } + const candidate = await requestCandidatePreview(); status = `Preview valid · projection ${candidate.evaluation.projection_digest.slice(0, 20)}…`; } catch (error) { - const message = String(error); - conflict = message.includes("conflict") || message.includes("base revision/digest mismatch"); - status = conflict ? `${message} Reload the authoritative tree before editing again.` : message; + recordCandidateError(error); } finally { busy = false; } @@ -159,39 +203,38 @@ async function commit() { if (!treeState) return; - await stageCurrent(); - if (draftChanges.length === 0) { - status = "No draft changes to commit."; - return; - } - if (preflightDigest !== treeState.snapshot.digest || !candidateContract) { - status = "Preview the complete candidate successfully before Commit."; - return; - } busy = true; try { + await formatDraftSources(); + if (draftChanges.length === 0) { + status = "No draft changes to commit."; + return; + } + if (preflightDigest !== treeState.snapshot.digest || !candidateContract) { + await requestCandidatePreview(); + } + const contract = candidateContract; + if (!contract) throw new Error("candidate preview did not return a toolchain contract"); treeState = await commitConfigTree(workspaceId, { base_revision: baseRevision, base_digest: baseDigest, changes: draftChanges, - entrypoints: candidateContract.entrypoints, - toolchain_fingerprint: candidateContract.fingerprint, + entrypoints: contract.entrypoints, + toolchain_fingerprint: contract.fingerprint, }); draftChanges = []; preflightDigest = ""; - candidateContract = null; - conflict = false; + candidateContract = null; + conflict = false; baseSnapshot = $state.snapshot(treeState.snapshot); baseRevision = treeState.snapshot.revision; baseDigest = treeState.snapshot.digest; await toolchain?.setSnapshot(treeState.snapshot, treeState.contract.schema_bundle); source = treeState.snapshot.entries[selectedPath]?.content ?? ""; diagnostics = []; - status = `Committed revision ${treeState.snapshot.revision}.`; + status = `Committed formatted revision ${treeState.snapshot.revision}.`; } catch (error) { - const message = String(error); - conflict = message.includes("conflict") || message.includes("base revision/digest mismatch"); - status = conflict ? `${message} Reload the authoritative tree before editing again.` : message; + recordCandidateError(error); } finally { busy = false; } diff --git a/web/workspace/test/config-source/editor-state.test.ts b/web/workspace/test/config-source/editor-state.test.ts index 4c0a7b8a..2e4c23f8 100644 --- a/web/workspace/test/config-source/editor-state.test.ts +++ b/web/workspace/test/config-source/editor-state.test.ts @@ -98,3 +98,33 @@ Deno.test("main entrypoint always enables the fixed schema wrapper", async () => "the authoritative main source should not require an optional client-side conversion", ); }); + +Deno.test("preview and commit format every draft Decodal source", async () => { + const source = await Deno.readTextFile( + new URL( + "../../src/lib/workspace/config-source/ConfigSourceEditor.svelte", + import.meta.url, + ), + ); + + assert( + source.includes("async function formatDraftSources()") && + source.includes('change.kind === "create" || change.kind === "update"') && + source.includes('change.kind === "rename"') && + source.includes('entry.content_type !== "decodal"') && + source.includes("await toolchain.format(entry.content)"), + "all changed Decodal entries should be formatted rather than only the selected source", + ); + assert( + source.includes("await formatDraftSources();") && + source.includes("await requestCandidatePreview();") && + source.includes("Committed formatted revision"), + "preview and commit should format first and refresh stale preflight before persistence", + ); + assert( + !source.includes( + "Preview the complete candidate successfully before Commit.", + ), + "format-driven candidate changes should trigger automatic preflight instead of a dead-end error", + ); +});