web: protect config schema wrapper
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user