diff --git a/web/workspace/deno.json b/web/workspace/deno.json index 06f4bf98..19f331df 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -6,7 +6,7 @@ "dev": "deno run -A npm:vite@7.2.7 dev", "dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787", "check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json", - "test": "deno test --allow-read=src,test --allow-env=LOG,VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/tasks.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/sidebar/repository-nav.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts", + "test": "deno test --allow-read=src,test --allow-env=LOG,VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/tasks.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/sidebar/repository-nav.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts", "build": "deno run -A npm:vite@7.2.7 build", "preview": "deno run -A npm:vite@7.2.7 preview" }, diff --git a/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.svelte b/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.svelte index cfb9142b..bd4ca71a 100644 --- a/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.svelte +++ b/web/workspace/src/lib/workspace/config-source/ConfigSourceEditor.svelte @@ -14,6 +14,8 @@ } from "./types.ts"; const MAIN_ENTRYPOINT = "main.dcdl"; + const WORKSPACE_SCHEMA_ASSERTION = "WorkspaceConfigSchema"; + const WORKSPACE_SCHEMA_SUFFIX = `} as ${WORKSPACE_SCHEMA_ASSERTION}`; let { workspaceId }: { workspaceId: string } = $props(); let treeState = $state(null); @@ -40,6 +42,7 @@ treeState && selectedPath ? treeState.snapshot.entries[selectedPath] : undefined, ); const mainSelected = $derived(selectedPath === MAIN_ENTRYPOINT); + const mainSchemaWrapped = $derived(mainSelected && hasWorkspaceSchemaWrapper(source)); const dirty = $derived(draftChanges.length > 0 || (selected ? source !== selected.content : source.length > 0)); const commitReady = $derived(dirty && preflightDigest === treeState?.snapshot.digest); @@ -114,6 +117,35 @@ return [MAIN_ENTRYPOINT]; } + function hasWorkspaceSchemaWrapper(value: string): boolean { + const sourceWithoutTrailingWhitespace = value.trimEnd(); + return value.startsWith("{") && sourceWithoutTrailingWhitespace.endsWith(WORKSPACE_SCHEMA_SUFFIX); + } + + async function wrapMainWithWorkspaceSchema() { + if (!toolchain || !mainSelected || mainSchemaWrapped || busy) return; + const candidate = source.trim(); + if (!candidate.startsWith("{") || !candidate.endsWith("}")) { + status = "WorkspaceConfigSchema can only wrap a top-level object source."; + return; + } + busy = true; + try { + source = await toolchain.format(`${candidate} as ${WORKSPACE_SCHEMA_ASSERTION}`); + diagnostics = await toolchain.analyze(selectedPath, source); + preflightDigest = ""; + candidateContract = null; + conflict = false; + status = diagnostics.length === 0 + ? "WorkspaceConfigSchema wrapper staged. Preview and Commit to persist it." + : `${diagnostics.length} diagnostic(s) after adding WorkspaceConfigSchema.`; + } catch (error) { + status = String(error); + } finally { + busy = false; + } + } + async function analyze() { if (!toolchain || !treeState || !selectedPath) return; diagnostics = await toolchain.analyze(selectedPath, source); @@ -312,6 +344,9 @@
+ {#if mainSelected && !mainSchemaWrapped} + + {/if} @@ -322,6 +357,7 @@ source = value} onComplete={(value, offset, explicit) => toolchain?.complete(selectedPath, value, offset, explicit) ?? Promise.resolve(null)} /> diff --git a/web/workspace/src/lib/workspace/config-source/fixed-schema-wrapper.ts b/web/workspace/src/lib/workspace/config-source/fixed-schema-wrapper.ts new file mode 100644 index 00000000..7aac6bb4 --- /dev/null +++ b/web/workspace/src/lib/workspace/config-source/fixed-schema-wrapper.ts @@ -0,0 +1,88 @@ +import { + EditorSelection, + EditorState, + type Extension, + Prec, +} from "@codemirror/state"; +import { Decoration, EditorView, keymap } from "@codemirror/view"; + +const WORKSPACE_SCHEMA_SUFFIX = "} as WorkspaceConfigSchema"; +const fixedWrapperMark = Decoration.mark({ + class: "cm-fixed-schema-wrapper", +}); + +export type FixedWrapperBounds = { + bodyFrom: number; + bodyTo: number; +}; + +export function fixedWrapperBounds( + state: EditorState, +): FixedWrapperBounds | null { + const source = state.doc.toString(); + if (!source.startsWith("{")) return null; + const sourceWithoutTrailingWhitespace = source.trimEnd(); + if (!sourceWithoutTrailingWhitespace.endsWith(WORKSPACE_SCHEMA_SUFFIX)) { + return null; + } + const bodyTo = sourceWithoutTrailingWhitespace.length - + WORKSPACE_SCHEMA_SUFFIX.length; + if (bodyTo < 1) return null; + return { bodyFrom: 1, bodyTo }; +} + +function fixedWrapperDecorations(state: EditorState) { + const bounds = fixedWrapperBounds(state); + if (!bounds) return Decoration.none; + return Decoration.set([ + fixedWrapperMark.range(0, bounds.bodyFrom), + fixedWrapperMark.range(bounds.bodyTo, state.doc.length), + ]); +} + +export function fixedSchemaWrapperExtension(): Extension { + return [ + EditorView.decorations.of((editor) => + fixedWrapperDecorations(editor.state) + ), + EditorView.atomicRanges.of((editor) => + fixedWrapperDecorations(editor.state) + ), + EditorState.transactionFilter.of((transaction) => { + const bounds = fixedWrapperBounds(transaction.startState); + if (!bounds || !transaction.docChanged) return transaction; + let allowed = true; + transaction.changes.iterChangedRanges((from, to) => { + if (from < bounds.bodyFrom || to > bounds.bodyTo) allowed = false; + }); + return allowed ? transaction : []; + }), + Prec.highest( + keymap.of([{ + key: "Mod-a", + run: (editor) => { + const bounds = fixedWrapperBounds(editor.state); + if (!bounds) return false; + editor.dispatch({ + selection: EditorSelection.range(bounds.bodyFrom, bounds.bodyTo), + }); + return true; + }, + }]), + ), + ]; +} + +export function moveSelectionIntoFixedWrapper(editor: EditorView) { + const bounds = fixedWrapperBounds(editor.state); + if (!bounds) return; + const selectionInsideBody = editor.state.selection.ranges.every((range) => + range.from >= bounds.bodyFrom && range.to <= bounds.bodyTo + ); + if (!selectionInsideBody) { + editor.dispatch({ + selection: EditorSelection.cursor(bounds.bodyFrom), + filter: false, + }); + } +} diff --git a/web/workspace/src/lib/workspace/settings/DecodalSourceEditor.svelte b/web/workspace/src/lib/workspace/settings/DecodalSourceEditor.svelte index 4f4b5667..12623b97 100644 --- a/web/workspace/src/lib/workspace/settings/DecodalSourceEditor.svelte +++ b/web/workspace/src/lib/workspace/settings/DecodalSourceEditor.svelte @@ -6,17 +6,23 @@ import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection } from '@codemirror/view'; import { tags } from '@lezer/highlight'; import { decodal } from 'decodal-codemirror'; + import { + fixedSchemaWrapperExtension, + moveSelectionIntoFixedWrapper, + } from '$lib/workspace/config-source/fixed-schema-wrapper.ts'; let { value = '', readonly = false, ariaLabel = 'Decodal source', + fixedSchemaWrapper = false, onChange = (_value: string) => {}, onComplete = undefined, }: { value?: string; readonly?: boolean; ariaLabel?: string; + fixedSchemaWrapper?: boolean; onChange?: (value: string) => void; onComplete?: (source: string, utf16Offset: number, explicit: boolean) => Promise; } = $props(); @@ -24,6 +30,7 @@ let host = $state(null); let view = $state.raw(null); const readonlyCompartment = new Compartment(); + const fixedSchemaWrapperCompartment = new Compartment(); const syntaxTheme = HighlightStyle.define([ { tag: tags.keyword, color: 'var(--accent)', fontWeight: '700' }, @@ -47,6 +54,11 @@ '&.cm-focused': { outline: '1px solid var(--accent-muted)', outlineOffset: '-1px' }, '.cm-scroller': { fontFamily: 'var(--font-mono)', minHeight: '24rem' }, '.cm-content': { padding: '0.75rem 0', caretColor: 'var(--text-strong)' }, + '.cm-fixed-schema-wrapper': { + color: 'var(--text-muted)', + backgroundColor: 'var(--interactive-muted)', + fontWeight: '600', + }, '.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)', @@ -61,6 +73,7 @@ if (!host || untrack(() => view)) return; const initialValue = untrack(() => value); const initialReadonly = untrack(() => readonly); + const initialFixedSchemaWrapper = untrack(() => fixedSchemaWrapper); const handleChange = untrack(() => onChange); const handleComplete = untrack(() => onComplete); const editor = new EditorView({ @@ -78,6 +91,9 @@ return await handleComplete(doc, context.pos, context.explicit); }] })] : []), keymap.of([]), + fixedSchemaWrapperCompartment.of( + initialFixedSchemaWrapper ? fixedSchemaWrapperExtension() : [], + ), readonlyCompartment.of([ EditorState.readOnly.of(initialReadonly), EditorView.editable.of(!initialReadonly), @@ -90,6 +106,7 @@ }), }); view = editor; + if (initialFixedSchemaWrapper) moveSelectionIntoFixedWrapper(editor); return () => { editor.destroy(); view = null; @@ -108,11 +125,27 @@ }); }); + $effect(() => { + const editor = view; + const enabled = fixedSchemaWrapper; + if (!editor) return; + editor.dispatch({ + effects: fixedSchemaWrapperCompartment.reconfigure( + enabled ? fixedSchemaWrapperExtension() : [], + ), + }); + if (enabled) moveSelectionIntoFixedWrapper(editor); + }); + $effect(() => { if (!view) return; const current = view.state.doc.toString(); if (current !== value) { - view.dispatch({ changes: { from: 0, to: current.length, insert: value } }); + view.dispatch({ + changes: { from: 0, to: current.length, insert: value }, + filter: false, + }); + if (fixedSchemaWrapper) moveSelectionIntoFixedWrapper(view); } }); diff --git a/web/workspace/test/config-source/editor-state.test.ts b/web/workspace/test/config-source/editor-state.test.ts index 12f16926..af9b9cad 100644 --- a/web/workspace/test/config-source/editor-state.test.ts +++ b/web/workspace/test/config-source/editor-state.test.ts @@ -72,4 +72,32 @@ Deno.test("Decodal editor follows readonly prop changes after mount", async () = !source.includes("--border-subtle"), "CodeMirror theme must use workspace tokens that actually exist", ); + assert( + source.includes("fixedSchemaWrapperCompartment.reconfigure") && + source.includes("fixedSchemaWrapperExtension()") && + source.includes("filter: false"), + "fixed schema wrapper protection should react to the main source and permit authoritative synchronization", + ); +}); + +Deno.test("main config wrapper is an explicit canonical draft conversion", async () => { + const source = await Deno.readTextFile( + new URL( + "../../src/lib/workspace/config-source/ConfigSourceEditor.svelte", + import.meta.url, + ), + ); + + assert( + source.includes("Wrap with WorkspaceConfigSchema") && + source.includes("wrapMainWithWorkspaceSchema") && + source.includes("fixedSchemaWrapper={mainSchemaWrapped}"), + "legacy main sources should offer an explicit conversion before fixed wrapper mode is enabled", + ); + assert( + source.includes("source.trim()") && + source.includes("toolchain.format") && + source.includes("wrapper staged. Preview and Commit"), + "conversion should remain a formatted local draft until the normal commit flow", + ); }); diff --git a/web/workspace/test/config-source/fixed-schema-wrapper.test.ts b/web/workspace/test/config-source/fixed-schema-wrapper.test.ts new file mode 100644 index 00000000..a85e4dfd --- /dev/null +++ b/web/workspace/test/config-source/fixed-schema-wrapper.test.ts @@ -0,0 +1,88 @@ +declare const Deno: { + test(name: string, fn: () => Promise | void): void; +}; + +import { EditorState } from "@codemirror/state"; +import { + fixedSchemaWrapperExtension, + fixedWrapperBounds, +} from "../../src/lib/workspace/config-source/fixed-schema-wrapper.ts"; + +function assertEquals(actual: unknown, expected: unknown, message: string) { + if (actual !== expected) { + throw new Error( + `${message}: expected ${String(expected)}, got ${String(actual)}`, + ); + } +} + +function wrappedState(source = "{} as WorkspaceConfigSchema\n") { + return EditorState.create({ + doc: source, + extensions: [fixedSchemaWrapperExtension()], + }); +} + +Deno.test("fixed schema wrapper allows edits only inside the asserted object", () => { + const state = wrappedState(); + const bounds = fixedWrapperBounds(state); + if (!bounds) throw new Error("canonical wrapper should be recognized"); + assertEquals(bounds.bodyFrom, 1, "body should start after the opening brace"); + assertEquals( + bounds.bodyTo, + 1, + "empty body should end before the closing brace", + ); + + const inserted = state.update({ + changes: { from: bounds.bodyFrom, insert: " profile = {}; " }, + }); + assertEquals( + inserted.newDoc.toString(), + "{ profile = {}; } as WorkspaceConfigSchema\n", + "body insertion should be accepted", + ); + + const prefixEdit = state.update({ changes: { from: 0, to: 1, insert: "[" } }); + assertEquals( + prefixEdit.newDoc.toString(), + state.doc.toString(), + "opening wrapper edit should be rejected", + ); + + const suffixEdit = state.update({ + changes: { from: bounds.bodyTo, to: bounds.bodyTo + 1, insert: "]" }, + }); + assertEquals( + suffixEdit.newDoc.toString(), + state.doc.toString(), + "schema assertion edit should be rejected", + ); + + const replaceAll = state.update({ + changes: { from: 0, to: state.doc.length, insert: "{}" }, + }); + assertEquals( + replaceAll.newDoc.toString(), + state.doc.toString(), + "a replacement touching fixed and editable ranges should be rejected atomically", + ); +}); + +Deno.test("authoritative synchronization can bypass the fixed wrapper filter", () => { + const state = wrappedState(); + const synchronized = state.update({ + changes: { from: 0, to: state.doc.length, insert: "{}" }, + filter: false, + }); + assertEquals( + synchronized.newDoc.toString(), + "{}", + "programmatic source replacement should bypass user transaction filters", + ); + assertEquals( + fixedWrapperBounds(synchronized.state), + null, + "bare source should not expose fixed wrapper ranges", + ); +});