Files
astrolabe/src/app/components/SpecEditor.tsx
T

565 lines
23 KiB
TypeScript

/**
* Spec Editor — the center pane (spec §03).
*
* A Monaco JSON editor bound to the active snippet, running uncontrolled (raw
* `monaco-editor`, per docs/architecture/08): created once, keystrokes push into
* the store's `draftText` buffer. The buffer is reloaded only when the store
* signals a programmatic load (`bufferEpoch`) or the view toggles — never
* keystroke-by-keystroke, which would fight the cursor.
*
* The pane header carries the Draft/Published toggle plus Publish and Revert
* (spec §03D). The published view is read-only — it shows the last published
* spec for reference; all editing happens on the draft. Render problems surface
* inline near the editor (spec §03E), mirroring the preview via PreviewStore.
*/
import { useEffect, useMemo, useRef, type RefObject } from 'react';
import { useShallow } from 'zustand/react/shallow';
// `edcore.main` is the full standalone editor — every feature contribution
// (folding, suggest widget, word operations like Cmd+Backspace, find, bracket
// colorization, multi-cursor, …) — but WITHOUT the `monaco-editor` barrel's
// basic-languages (sql, abap, solidity, …) we never use. We then add only the
// JSON language service. Full editor UX, JSON-only weight.
import * as monaco from 'monaco-editor/esm/vs/editor/edcore.main';
import 'monaco-editor/esm/vs/language/json/monaco.contribution';
import '../infrastructure/monaco-env'; // side-effect: wire workers before create
import { configureVegaLiteJson } from '../infrastructure/monaco-schema';
import { configureJsonFormatter, installFormatOnPaste } from '../infrastructure/monaco-format';
import { parseChartSpecText } from '@core/chart-builder';
import { openChartBuilderForEdit } from '../modals/ModalCoordinator';
import {
installSpecConfigActions,
runExtractConfig,
runExtractConfigToTheme,
runMergeChartTheme,
} from '../services/spec-config-actions';
import {
configureSpecTransformCodeActions,
installSpecTransformActions,
installSpecTransformCodeLens,
runMoveViewTo,
runSimplifyStructure,
runUnwrap,
runWrap,
runWrapContainer,
runWrapViews,
} from '../services/spec-transform-actions';
import { configureSpecDatasetHints } from '../services/spec-dataset-hints';
import { runExtract } from '../services/extract-action';
import { useAppStore } from '../stores/AppStore';
import { confirm } from '../stores/ConfirmStore';
import { useDatasetStore } from '../stores/DatasetStore';
import { hasExtractableData } from '../stores/ExtractStore';
import { publishActiveSnippet } from '../services/snippet-actions';
import { notify } from '../stores/NotificationStore';
import { usePreviewStore } from '../stores/PreviewStore';
import { selectActiveSnippet, selectShownText, useSnippetStore } from '../stores/SnippetStore';
import { useUserSettingsStore } from '../stores/UserSettingsStore';
import { Icon } from './Icon';
import { Button } from './Button';
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
import { SelectControl } from './SelectControl';
import {
NumberControl,
RangeControl,
ResetButton,
SettingFooter,
SettingRow,
SettingsPopover,
} from './SettingsPopover';
import type { EditorView } from '../stores/SnippetStore';
import styles from './SpecEditor.module.css';
/** The two editor views as a single-select set (spec §03D). */
const VIEW_OPTIONS: ReadonlyArray<SegmentedOption<EditorView>> = [
{ value: 'draft', label: 'Draft' },
{ value: 'published', label: 'Published' },
];
/** Editor syntax theme: Auto follows the app theme; overrides force one (spec §07). */
const EDITOR_THEME_OPTIONS: ReadonlyArray<SegmentedOption<string>> = [
{ value: 'auto', label: 'Auto' },
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' },
];
const ONOFF_OPTIONS: ReadonlyArray<SegmentedOption<'on' | 'off'>> = [
{ value: 'on', label: 'On' },
{ value: 'off', label: 'Off' },
];
/**
* Editor settings cluster (spec §07 → Editor), disclosed from the editor toolbar
* and applied live. The id matches the Cmd/Ctrl+, shortcut target (App.tsx).
*/
function EditorSettings() {
const editor = useUserSettingsStore((s) => s.saved.editor);
const setEditor = useUserSettingsStore((s) => s.setEditor);
const resetEditor = useUserSettingsStore((s) => s.resetEditor);
return (
<SettingsPopover id="editor-settings" label="Editor settings" title="Editor">
<SettingRow label="Font size" htmlFor="set-fontsize">
<RangeControl
id="set-fontsize"
min={10}
max={18}
value={editor.fontSize}
suffix="px"
onChange={(fontSize) => setEditor({ fontSize })}
/>
</SettingRow>
<SettingRow label="Theme">
<SegmentedControl
label="Editor theme"
options={EDITOR_THEME_OPTIONS}
value={editor.theme}
onChange={(theme) => setEditor({ theme })}
/>
</SettingRow>
<SettingRow label="Minimap">
<SegmentedControl
label="Minimap"
options={ONOFF_OPTIONS}
value={editor.minimap ? 'on' : 'off'}
onChange={(v) => setEditor({ minimap: v === 'on' })}
/>
</SettingRow>
<SettingRow label="Word wrap">
<SegmentedControl
label="Word wrap"
options={ONOFF_OPTIONS}
value={editor.wordWrap}
onChange={(wordWrap) => setEditor({ wordWrap })}
/>
</SettingRow>
<SettingRow label="Line numbers">
<SegmentedControl
label="Line numbers"
options={ONOFF_OPTIONS}
value={editor.lineNumbers}
onChange={(lineNumbers) => setEditor({ lineNumbers })}
/>
</SettingRow>
<SettingRow label="Tab size" htmlFor="set-tabsize">
<NumberControl
id="set-tabsize"
min={1}
max={8}
value={editor.tabSize}
onChange={(tabSize) => setEditor({ tabSize })}
/>
</SettingRow>
<SettingFooter>
<ResetButton onClick={resetEditor}>Reset editor defaults</ResetButton>
</SettingFooter>
</SettingsPopover>
);
}
// Register the bundled Vega-Lite schema once: resolves `$schema` locally (no
// network warning) and powers validation, autocomplete, and hover docs.
configureVegaLiteJson();
// Register the compact JSON formatter once (Format Document + format-on-paste, §03A).
configureJsonFormatter();
// Register the structural-transform refactors (the lightbulb) once, globally for
// JSON — like the schema/formatter, not per editor (docs/architecture/08).
configureSpecTransformCodeActions();
// Register the dataset-aware completion/hover/inlay providers once (docs/architecture/08).
configureSpecDatasetHints();
/** The two spec↔config operations, surfaced as an overflow menu (council:
* Carbon menu-buttons — overflow for additional options under space
* constraint; NN/g #6 — a visible home, with the Monaco context menu and F1
* palette as the #7 accelerators on the same code paths). */
const CONFIG_ACTIONS = [
{
value: 'merge',
label: 'Merge chart theme into spec',
detail: 'Write the active chart theme into the config block',
},
{
value: 'extract',
label: 'Extract config from spec',
detail: 'Remove the config block and copy it to the clipboard',
},
{
value: 'extract-theme',
label: 'Extract config to new theme',
detail: 'Save the config block as a custom chart theme and select it',
},
] as const;
type ConfigActionId = (typeof CONFIG_ACTIONS)[number]['value'];
/** Structural transforms, surfaced as a sibling menu to Config (the discoverable
* home; the lightbulb and F1 palette are the accelerators — see
* services/spec-transform-actions). They act on the selection, else the whole
* spec. */
const TRANSFORM_ACTIONS = [
{ value: 'layer', label: 'Wrap in layer', detail: 'Overlay marks on shared scales' },
{ value: 'hconcat', label: 'Wrap in horizontal concat', detail: 'Place views side by side' },
{ value: 'vconcat', label: 'Wrap in vertical concat', detail: 'Stack views top to bottom' },
{ value: 'facet', label: 'Wrap in facet', detail: 'Small multiples across a field' },
{ value: 'repeat', label: 'Wrap in repeat', detail: 'Repeat the chart across fields' },
{
value: 'simplify',
label: 'Simplify composition',
detail: 'Collapse a single-child layer/concat back to a unit',
},
] as const;
type TransformActionId = (typeof TRANSFORM_ACTIONS)[number]['value'];
function EditorToolbar({
editorRef,
}: {
editorRef: RefObject<monaco.editor.IStandaloneCodeEditor | null>;
}) {
const activeId = useSnippetStore((s) => s.activeSnippetId);
const editorView = useSnippetStore((s) => s.editorView);
const setEditorView = useSnippetStore((s) => s.setEditorView);
// Are there draft changes to revert? Use the live buffer in the draft view so
// the control responds before the auto-save debounce commits (spec §03D).
const dirty = useSnippetStore((s) => {
const active = selectActiveSnippet(s);
if (!active) return false;
const draft = s.editorView === 'draft' ? s.draftText : active.draftSpec;
return draft !== active.spec;
});
// Offer Extract only when the live draft carries data to lift out — inline
// `values` in any view, or a reference to a self-defined `datasets` entry (spec
// §03F → hidden when there is nothing extractable).
const canExtract = useSnippetStore(
(s) => s.activeSnippetId !== null && hasExtractableData(s.draftText),
);
// Offer "Open in builder" only when the active snippet's published spec is
// losslessly representable in the builder dialect AND its referenced dataset
// exists — the gate the builder's openForEdit enforces (spec §06 → Open in
// builder). Content-gated like Extract, so it is **hidden** when inapplicable
// (the toolbar's convention for content-gated actions — vs Revert/Config, which
// disable because they are merely state-gated; arch 10 §5). `datasetNames` is a
// shallow-stable string[] so the memo doesn't churn (MEMORY → stable selectors).
const activeSpec = useSnippetStore((s) => selectActiveSnippet(s)?.spec ?? null);
const datasetNames = useDatasetStore(useShallow((s) => s.datasets.map((d) => d.name)));
const canOpenInBuilder = useMemo(() => {
if (activeSpec === null) return false;
const config = parseChartSpecText(activeSpec);
return config !== null && datasetNames.includes(config.datasetName);
}, [activeSpec, datasetNames]);
const handleOpenInBuilder = () => {
const snippet = selectActiveSnippet(useSnippetStore.getState());
if (snippet) openChartBuilderForEdit(snippet);
};
// Extract is scoped to the view at the cursor (services/extract-action), so it
// goes through the editor handle like the wrap/config actions, not a bare
// openModal — the service captures the focused binding before opening the modal.
const handleExtract = () => {
const editor = editorRef.current;
if (editor) runExtract(editor);
};
// Publish + its success toast live in one place (services/snippet-actions) so
// the button and the Cmd/Ctrl+S shortcut (EventRouter) behave identically.
const handlePublish = publishActiveSnippet;
const handleConfigAction = (action: ConfigActionId) => {
const editor = editorRef.current;
if (!editor) return;
if (action === 'merge') runMergeChartTheme(editor);
else if (action === 'extract') void runExtractConfig(editor);
else runExtractConfigToTheme(editor);
};
const handleTransformAction = (action: TransformActionId) => {
const editor = editorRef.current;
if (!editor) return;
if (action === 'simplify') runUnwrap(editor);
else runWrap(editor, action);
};
const handleRevert = async () => {
const ok = await confirm({
title: 'Revert draft',
message:
'Discard all draft changes and restore the last published version? This cannot be undone.',
confirmLabel: 'Revert',
danger: true,
});
if (ok) {
useSnippetStore.getState().revert();
notify({
kind: 'success',
title: 'Draft reverted',
message: 'The editor was restored to the last published version.',
});
}
};
return (
<div className={styles.toolbar}>
<SegmentedControl
label="Editor view"
options={VIEW_OPTIONS}
value={editorView}
onChange={setEditorView}
/>
<span className={styles.spacer} />
{/* Secondary actions collapse to icon-only when the editor pane is narrow
(a @container query in the CSS), so "Extract to Dataset" no longer wraps
to two lines and Revert/Publish stay on one row. The label rides in
`title` (and the accessible name) when only the icon shows. Publish — the
one primary action — keeps its label at every width. */}
{canOpenInBuilder && (
<Button
className={styles.collapsible}
onClick={handleOpenInBuilder}
title="Open this chart in the visual builder to edit it"
aria-label="Open in builder"
>
<Icon name="chart" className={styles.actionIcon} />
<span className={styles.actionLabel}>Open in builder</span>
</Button>
)}
{canExtract && (
<Button
className={styles.collapsible}
onClick={handleExtract}
title="Extract embedded data into a reusable dataset"
aria-label="Extract to Dataset"
>
<Icon name="dataset" className={styles.actionIcon} />
<span className={styles.actionLabel}>Extract to Dataset</span>
</Button>
)}
<SelectControl
id="editor-transform-actions"
label="Spec transform actions"
heading="Transform"
options={TRANSFORM_ACTIONS}
onSelect={handleTransformAction}
triggerContent="Transform"
triggerTitle="Structural transforms — wrap the focused view in a composition, or simplify one"
disabled={activeId === null || editorView === 'published'}
/>
<SelectControl
id="editor-config-actions"
label="Spec config actions"
heading="Spec config"
options={CONFIG_ACTIONS}
onSelect={handleConfigAction}
triggerContent="Config"
triggerTitle="Spec config actions — merge the chart theme in, or extract the config out"
disabled={activeId === null || editorView === 'published'}
/>
<Button
className={styles.collapsible}
onClick={() => void handleRevert()}
disabled={activeId === null || !dirty}
title="Revert draft to the last published version"
aria-label="Revert"
>
<Icon name="revert" className={styles.actionIcon} />
<span className={styles.actionLabel}>Revert</span>
</Button>
<Button
variant="primary"
onClick={handlePublish}
disabled={activeId === null}
title="Publish (⌘/Ctrl+S)"
aria-keyshortcuts="Meta+S Control+S"
>
Publish
</Button>
<EditorSettings />
</div>
);
}
export function SpecEditor() {
const hostRef = useRef<HTMLDivElement>(null);
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
const activeId = useSnippetStore((s) => s.activeSnippetId);
const editorView = useSnippetStore((s) => s.editorView);
const bufferEpoch = useSnippetStore((s) => s.bufferEpoch);
const uiTheme = useAppStore((s) => s.uiTheme);
const revealTarget = useAppStore((s) => s.revealTarget);
const composeRequest = useAppStore((s) => s.composeRequest);
const error = usePreviewStore((s) => s.error);
// Editor preferences (spec §07 → Editor); applied live below as they change.
const editorPrefs = useUserSettingsStore((s) => s.saved.editor);
// Create the editor once, on mount.
useEffect(() => {
if (!hostRef.current) return;
// Seed from the user's current editor settings so the first paint matches.
const ed = useUserSettingsStore.getState().saved.editor;
const editor = monaco.editor.create(hostRef.current, {
value: selectShownText(useSnippetStore.getState()),
language: 'json',
automaticLayout: true,
minimap: { enabled: ed.minimap },
// The editor is a Plex Mono surface per the design language (doc §3.1).
// Monaco needs an explicit family string — it can't read the CSS token.
fontFamily: "'IBM Plex Mono', ui-monospace, 'SF Mono', Menlo, monospace",
fontSize: ed.fontSize,
tabSize: ed.tabSize,
wordWrap: ed.wordWrap,
lineNumbers: ed.lineNumbers,
folding: true,
showFoldingControls: 'always', // keep fold arrows visible, not only on hover
scrollBeyondLastLine: false,
// Vega-Lite enum values ("bar", "quantitative", …) live inside JSON
// strings, where Monaco disables auto-suggest by default — turn it on so
// those keywords are hinted as you type, not just on Ctrl+Space.
quickSuggestions: { other: true, comments: false, strings: true },
suggestOnTriggerCharacters: true,
});
editorRef.current = editor;
// Keystrokes feed the draft buffer — but only on the editable draft view.
// (Programmatic setValue while showing the read-only published spec must not
// overwrite the draft.)
const sub = editor.onDidChangeModelContent(() => {
if (useSnippetStore.getState().editorView === 'draft') {
useSnippetStore.getState().updateDraft(editor.getValue());
}
});
// Pasting reindents the whole spec to keep it consistently formatted (§03A).
// The reformat fires onDidChangeModelContent above, so the draft buffer picks
// up the formatted text. No-op on the read-only published view / invalid JSON.
const pasteSub = installFormatOnPaste(editor);
// Merge-chart-theme / extract-config actions (context menu + F1 palette,
// docs/chart-theming-scope.md §4.3). Edits land via onDidChangeModelContent
// above, so the draft buffer stays in sync like any other edit.
const configActionsSub = installSpecConfigActions(editor);
// Structural-transform actions in the F1 palette (the lightbulb is registered
// once, globally, above; the toolbar Transform menu is the home). Per editor,
// disposed below like the config actions.
const transformActionsSub = installSpecTransformActions(editor);
// Cursor-aware composition CodeLens (add view above/below, reorder) — per
// editor, because its commands need this editor's handle to apply the edit.
const codeLensSub = installSpecTransformCodeLens(editor);
// Cmd/Ctrl+S is owned globally by the EventRouter (docs/architecture/04 →
// "bind listeners in exactly one place"), which publishes before the
// interactive-context gate so it works while the editor has focus. Monaco
// binds no default for Cmd+S, so the keystroke bubbles to that one handler.
return () => {
sub.dispose();
pasteSub.dispose();
configActionsSub.dispose();
transformActionsSub.dispose();
codeLensSub.dispose();
editor.dispose();
editorRef.current = null;
};
}, []);
// Reload the buffer on a programmatic load (select/create/delete/revert →
// bufferEpoch) or a view toggle. Not on keystrokes: typing changes neither dep.
useEffect(() => {
const editor = editorRef.current;
if (!editor) return;
const state = useSnippetStore.getState();
const text = selectShownText(state);
if (editor.getValue() !== text) editor.setValue(text);
editor.updateOptions({
readOnly: state.activeSnippetId === null || state.editorView === 'published',
});
}, [bufferEpoch, editorView, activeId]);
// Apply editor preferences live as they change (spec §07 Apply → takes effect
// immediately). tabSize is a model option, so it's set on the model.
useEffect(() => {
const editor = editorRef.current;
if (!editor) return;
editor.updateOptions({
fontSize: editorPrefs.fontSize,
wordWrap: editorPrefs.wordWrap,
lineNumbers: editorPrefs.lineNumbers,
minimap: { enabled: editorPrefs.minimap },
});
editor.getModel()?.updateOptions({ tabSize: editorPrefs.tabSize });
}, [editorPrefs]);
// Editor theme: Auto follows the app UI theme; an explicit override forces one
// (spec §07 → Editor theme, provisional).
useEffect(() => {
const pref = editorPrefs.theme;
const effective = pref === 'auto' ? uiTheme : pref;
monaco.editor.setTheme(effective === 'dark' ? 'vs-dark' : 'vs');
}, [uiTheme, editorPrefs.theme]);
// Select + reveal a view's source range when the composition wireframe asks
// (arch 08 → composition wireframe). The nonce makes a repeat request re-fire.
useEffect(() => {
const editor = editorRef.current;
if (!editor || !revealTarget) return;
const model = editor.getModel();
if (!model) return;
const range = monaco.Range.fromPositions(
model.getPositionAt(revealTarget.offset),
model.getPositionAt(revealTarget.offset + revealTarget.length),
);
editor.setSelection(range);
editor.revealRangeInCenterIfOutsideViewport(range);
// Deliberately no focus(): the wireframe stays the active surface so the user
// can keep browsing blocks while the editor scrolls/selects to follow.
}, [revealTarget]);
// Apply a composition restructure when the wireframe asks (wireframe → editor;
// the editor owns the undoable edit). The nonce makes a repeat request re-fire.
useEffect(() => {
const editor = editorRef.current;
if (!editor || !composeRequest) return;
if (composeRequest.kind === 'move')
runMoveViewTo(editor, composeRequest.arrayPath, composeRequest.from, composeRequest.to);
else if (composeRequest.kind === 'simplify') runSimplifyStructure(editor);
else if (composeRequest.kind === 'wrap-container')
runWrapContainer(
editor,
composeRequest.containerPath,
composeRequest.sourcePath,
composeRequest.axis,
composeRequest.side,
);
else
runWrapViews(
editor,
composeRequest.targetPath,
composeRequest.sourcePath,
composeRequest.axis,
composeRequest.side,
);
}, [composeRequest]);
return (
<div className={styles.editorPane}>
<EditorToolbar editorRef={editorRef} />
<div className={styles.editorWrap}>
{activeId === null && <div className={styles.placeholder}>Select or create a snippet</div>}
<div className={styles.editor} ref={hostRef} />
</div>
{/* The single live region for render/parse errors: assertive, since the
user just caused it. The preview shows the same text visually but is
not a live region, so the message is announced once (doc §10.1). */}
{error !== null && (
<pre className={styles.error} role="alert">
{error}
</pre>
)}
</div>
);
}