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

516 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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,
runUnwrap,
runWrap,
} 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 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);
// " Add view" CodeLens over composition arrays — per editor, because its
// command needs 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]);
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>
);
}