web: protect config schema wrapper

This commit is contained in:
2026-08-14 23:38:51 +09:00
parent 83a99541b2
commit 99b08e0f0c
6 changed files with 275 additions and 2 deletions
@@ -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<WorkspaceConfigTreeResponse | null>(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 @@
<div class="config-source-actions">
<input aria-label="Rename path" bind:value={renamePath} disabled={!selected || mainSelected || busy} />
<button type="button" onclick={renameEntry} disabled={!selected || mainSelected || renamePath === selectedPath || busy}>Rename</button>
{#if mainSelected && !mainSchemaWrapped}
<button type="button" onclick={wrapMainWithWorkspaceSchema} disabled={!selected || busy}>Wrap with WorkspaceConfigSchema</button>
{/if}
<button type="button" onclick={format} disabled={!selectedPath || busy}>Format</button>
<button type="button" onclick={analyze} disabled={!selectedPath || busy}>Analyze</button>
<button type="button" onclick={preview} disabled={!dirty || busy}>Preview</button>
@@ -322,6 +357,7 @@
<DecodalSourceEditor
value={source}
readonly={!selectedPath || busy}
fixedSchemaWrapper={mainSchemaWrapped}
onChange={(value) => source = value}
onComplete={(value, offset, explicit) => toolchain?.complete(selectedPath, value, offset, explicit) ?? Promise.resolve(null)}
/>
@@ -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,
});
}
}
@@ -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<CompletionResult | null>;
} = $props();
@@ -24,6 +30,7 @@
let host = $state<HTMLDivElement | null>(null);
let view = $state.raw<EditorView | null>(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);
}
});
</script>