mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Editor: spec transforms (wrap/simplify/add-view) and dataset-aware hints
This commit is contained in:
@@ -33,6 +33,14 @@ import {
|
||||
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 { useAppStore } from '../stores/AppStore';
|
||||
import { confirm } from '../stores/ConfirmStore';
|
||||
import { useDatasetStore } from '../stores/DatasetStore';
|
||||
@@ -149,6 +157,11 @@ function EditorSettings() {
|
||||
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
|
||||
@@ -174,6 +187,25 @@ const CONFIG_ACTIONS = [
|
||||
|
||||
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,
|
||||
}: {
|
||||
@@ -228,6 +260,13 @@ function EditorToolbar({
|
||||
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',
|
||||
@@ -284,6 +323,16 @@ function EditorToolbar({
|
||||
<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"
|
||||
@@ -376,6 +425,15 @@ export function SpecEditor() {
|
||||
// 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
|
||||
@@ -385,6 +443,8 @@ export function SpecEditor() {
|
||||
sub.dispose();
|
||||
pasteSub.dispose();
|
||||
configActionsSub.dispose();
|
||||
transformActionsSub.dispose();
|
||||
codeLensSub.dispose();
|
||||
editor.dispose();
|
||||
editorRef.current = null;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* The data context of the active draft (docs/architecture/08 → editor
|
||||
* augmentation): the columns/types/stats the spec can reference, and the fields
|
||||
* the editor hints offer. One resolver, shared by the transform actions
|
||||
* (facet/repeat field defaults) and the dataset-aware hints
|
||||
* (completion/hover/inlay).
|
||||
*
|
||||
* Source, in order: a **named library dataset** the draft references (its stored
|
||||
* profile), else the spec's **inline data** profiled on the fly (the "ghost
|
||||
* dataset" — `core/spec-inline-data` + `core/profile`, nothing stored). On top of
|
||||
* either, the spec's **derived** fields (transform `as`, `core/spec-fields`) are
|
||||
* added, so a `calculate` output is offered alongside the data columns.
|
||||
*
|
||||
* The result is memoized by draft text: providers fire often (every keystroke
|
||||
* completes, inlay refreshes on scroll), and profiling inline data each time would
|
||||
* be wasteful. App layer — reads stores via `getState`, outside React.
|
||||
*/
|
||||
|
||||
import type { Dataset } from '@core/dataset';
|
||||
import { type ColumnStats, profileData } from '@core/profile';
|
||||
import { derivedFieldNames } from '@core/spec-fields';
|
||||
import { inlineDataRows } from '@core/spec-inline-data';
|
||||
import { extractDatasetRefs } from '@core/spec-refs';
|
||||
import type { ColumnType } from '@core/type-inference';
|
||||
import { useDatasetStore } from '../stores/DatasetStore';
|
||||
import { useSnippetStore } from '../stores/SnippetStore';
|
||||
|
||||
/** A field offered to the editor hints. */
|
||||
export interface FieldHint {
|
||||
name: string;
|
||||
/** Source columns carry an inferred type; a spec-derived field's is unknown. */
|
||||
type: ColumnType | null;
|
||||
/** True when introduced by a spec transform rather than the data. */
|
||||
derived: boolean;
|
||||
}
|
||||
|
||||
/** Everything the hints need about the active draft's data. */
|
||||
export interface DataInfo {
|
||||
/** The library dataset's name, or null for inline data (the ghost dataset). */
|
||||
name: string | null;
|
||||
columnTypes: ReadonlyArray<{ name: string; type: ColumnType }>;
|
||||
columnStats: ReadonlyArray<ColumnStats>;
|
||||
/** Source columns plus derived fields, de-duplicated (source wins). */
|
||||
fields: ReadonlyArray<FieldHint>;
|
||||
}
|
||||
|
||||
const EMPTY: DataInfo = { name: null, columnTypes: [], columnStats: [], fields: [] };
|
||||
|
||||
function safeParse(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The first library dataset the draft references, or null. */
|
||||
function libraryDataset(draftText: string): Dataset | null {
|
||||
const refs = extractDatasetRefs(draftText);
|
||||
if (refs.length === 0) return null;
|
||||
const { datasets } = useDatasetStore.getState();
|
||||
for (const ref of refs) {
|
||||
const ds = datasets.find((d) => d.name === ref);
|
||||
if (ds) return ds;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function compute(draftText: string): DataInfo {
|
||||
const spec = safeParse(draftText);
|
||||
|
||||
// Source columns: a named library dataset wins; otherwise profile inline data.
|
||||
let name: string | null = null;
|
||||
let columnTypes: ReadonlyArray<{ name: string; type: ColumnType }> = [];
|
||||
let columnStats: ReadonlyArray<ColumnStats> = [];
|
||||
const library = libraryDataset(draftText);
|
||||
if (library && library.columnTypes.length > 0) {
|
||||
({ name, columnTypes, columnStats } = library);
|
||||
} else {
|
||||
const rows = inlineDataRows(spec);
|
||||
if (rows) {
|
||||
const profile = profileData(rows, 0);
|
||||
columnTypes = profile.columnTypes;
|
||||
columnStats = profile.columnStats;
|
||||
}
|
||||
}
|
||||
|
||||
const fields: FieldHint[] = columnTypes.map((c) => ({
|
||||
name: c.name,
|
||||
type: c.type,
|
||||
derived: false,
|
||||
}));
|
||||
const seen = new Set(fields.map((f) => f.name));
|
||||
for (const derived of derivedFieldNames(spec)) {
|
||||
if (!seen.has(derived)) {
|
||||
fields.push({ name: derived, type: null, derived: true });
|
||||
seen.add(derived);
|
||||
}
|
||||
}
|
||||
|
||||
return { name, columnTypes, columnStats, fields };
|
||||
}
|
||||
|
||||
// Keyed by draft text alone: this assumes a bound dataset's profile is stable
|
||||
// for a given draft. A re-import that changes columns under unchanged text serves
|
||||
// stale hints until the next keystroke — harmless, since hints are additive.
|
||||
let cache: { text: string; info: DataInfo } | null = null;
|
||||
|
||||
/** The active draft's data context, memoized by draft text. */
|
||||
export function dataInfo(): DataInfo {
|
||||
const text = useSnippetStore.getState().draftText;
|
||||
if (text.trim() === '') return EMPTY;
|
||||
if (cache && cache.text === text) return cache.info;
|
||||
const info = compute(text);
|
||||
cache = { text, info };
|
||||
return info;
|
||||
}
|
||||
|
||||
/** Source columns + inferred types available to the draft (for facet/repeat defaults). */
|
||||
export function boundColumns(): ReadonlyArray<{ name: string; type: ColumnType }> {
|
||||
return dataInfo().columnTypes;
|
||||
}
|
||||
|
||||
/** Source columns plus the spec's derived fields (for completion/inlay). */
|
||||
export function availableFields(): ReadonlyArray<FieldHint> {
|
||||
return dataInfo().fields;
|
||||
}
|
||||
@@ -40,11 +40,16 @@ import { useCustomThemeStore } from '../stores/CustomThemeStore';
|
||||
import { notify } from '../stores/NotificationStore';
|
||||
import { selectActiveSnippet, useSnippetStore } from '../stores/SnippetStore';
|
||||
|
||||
/** Parse the model's JSON, or toast (and return null) when it isn't a JSON object. */
|
||||
function parseSpecObject(model: monaco.editor.ITextModel): Record<string, unknown> | null {
|
||||
/**
|
||||
* Parse spec JSON, or toast (and return null) when it isn't a JSON object. Takes
|
||||
* the text (not the model) so it serves both whole-document and selection-scoped
|
||||
* callers — the config actions pass `model.getValue()`, the transform actions a
|
||||
* selection.
|
||||
*/
|
||||
export function parseSpecObject(text: string): Record<string, unknown> | null {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(model.getValue());
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
notify({
|
||||
kind: 'error',
|
||||
@@ -57,7 +62,7 @@ function parseSpecObject(model: monaco.editor.ITextModel): Record<string, unknow
|
||||
notify({
|
||||
kind: 'error',
|
||||
title: 'Spec is not a JSON object',
|
||||
message: 'Config actions need a top-level { … } Vega-Lite spec.',
|
||||
message: 'This action needs a top-level { … } Vega-Lite spec.',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
@@ -85,7 +90,7 @@ function replaceDocument(
|
||||
export function runMergeChartTheme(editor: monaco.editor.IStandaloneCodeEditor): void {
|
||||
const model = editor.getModel();
|
||||
if (!model) return;
|
||||
const spec = parseSpecObject(model);
|
||||
const spec = parseSpecObject(model.getValue());
|
||||
if (!spec) return;
|
||||
|
||||
const { chartTheme, uiTheme } = useAppStore.getState();
|
||||
@@ -122,7 +127,7 @@ function extractableConfig(editor: monaco.editor.IStandaloneCodeEditor): {
|
||||
} | null {
|
||||
const model = editor.getModel();
|
||||
if (!model) return null;
|
||||
const spec = parseSpecObject(model);
|
||||
const spec = parseSpecObject(model.getValue());
|
||||
if (!spec) return null;
|
||||
|
||||
const { spec: rest, config } = extractConfigFromSpec(spec);
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Dataset-aware editor hints (docs/architecture/08 → editor augmentation) — the
|
||||
* three things the Vega-Lite JSON schema *can't* know, because they depend on the
|
||||
* user's data and this spec:
|
||||
*
|
||||
* - **Completion** — in a `field` / `groupby` value, the bound dataset's real
|
||||
* column names (plus the spec's derived fields). The schema only knows `field`
|
||||
* takes a string; it can't list your columns. Enum values (`type`, `mark`, …)
|
||||
* are left to the schema — we add only what it lacks, no second source.
|
||||
* - **Hover** — a column's inferred type + cardinality/range (from the stored
|
||||
* profile); over a `calculate`/`filter`/`expr` string, a live validity check
|
||||
* via core/expr-validate. Monaco merges these with the schema's own hovers.
|
||||
* - **Inlay hints** — a faint `: <type>` beside each `field`, annotation without
|
||||
* touching the text.
|
||||
*
|
||||
* All three read the active draft's bound dataset (services/active-dataset) and
|
||||
* the cursor's JSON context (core/spec-cursor) at provide-time via `getState()` —
|
||||
* outside React. They register **once, globally for JSON** (like the schema and
|
||||
* formatter), not per editor. Suggestion-only: over- or under-listing is
|
||||
* harmless, which is why there is deliberately no "unknown field" diagnostic
|
||||
* (that would false-positive on every data-dependent derived column).
|
||||
*/
|
||||
|
||||
import * as monaco from 'monaco-editor/esm/vs/editor/edcore.main';
|
||||
import { defaultFieldType } from '@core/chart-builder';
|
||||
import { validateExpression } from '@core/expr-validate';
|
||||
import { stringValueAtOffset, valueKeyAtOffset } from '@core/spec-cursor';
|
||||
import type { ColumnType } from '@core/type-inference';
|
||||
import { useSnippetStore } from '../stores/SnippetStore';
|
||||
import { availableFields, dataInfo, type DataInfo, type FieldHint } from './active-dataset';
|
||||
|
||||
/** Property values that reference a data field (where column names belong). */
|
||||
const FIELD_KEYS = new Set(['field', 'groupby']);
|
||||
/** Property values that hold a Vega expression (where the validity check fires). */
|
||||
const EXPR_KEYS = new Set(['calculate', 'filter', 'expr']);
|
||||
|
||||
/** A short type label for a source field; null type (derived) is shown elsewhere. */
|
||||
const typeLabel = (type: ColumnType): string => defaultFieldType(type);
|
||||
|
||||
/** Markdown hover for a field hint: type + stats for source, a note for derived. */
|
||||
function fieldHoverContents(hint: FieldHint, info: DataInfo): { value: string }[] {
|
||||
if (hint.derived || hint.type === null) {
|
||||
return [{ value: `**${hint.name}** · _derived by a transform_` }];
|
||||
}
|
||||
const lines = [`**${hint.name}** · \`${typeLabel(hint.type)}\``];
|
||||
const stat = info.columnStats.find((s) => s.name === hint.name);
|
||||
if (stat) {
|
||||
if (stat.numericExtent)
|
||||
lines.push(`Range ${stat.numericExtent.min} – ${stat.numericExtent.max}`);
|
||||
lines.push(`${stat.distinct}${stat.distinctCapped ? '+' : ''} distinct`);
|
||||
}
|
||||
lines.push(info.name ? `_from dataset “${info.name}”_` : '_from the spec’s inline data_');
|
||||
return lines.map((value) => ({ value }));
|
||||
}
|
||||
|
||||
let registered = false;
|
||||
|
||||
/** Register the dataset-aware completion / hover / inlay providers once. */
|
||||
export function configureSpecDatasetHints(): void {
|
||||
if (registered) return;
|
||||
registered = true;
|
||||
|
||||
monaco.languages.registerCompletionItemProvider('json', {
|
||||
triggerCharacters: ['"'],
|
||||
provideCompletionItems(model, position) {
|
||||
// Field suggestions only edit the draft; nothing to offer on the published view.
|
||||
if (useSnippetStore.getState().editorView !== 'draft') return { suggestions: [] };
|
||||
const key = valueKeyAtOffset(model.getValue(), model.getOffsetAt(position));
|
||||
if (key === null || !FIELD_KEYS.has(key)) return { suggestions: [] };
|
||||
const fields = availableFields();
|
||||
if (fields.length === 0) return { suggestions: [] };
|
||||
|
||||
const word = model.getWordUntilPosition(position);
|
||||
const range = new monaco.Range(
|
||||
position.lineNumber,
|
||||
word.startColumn,
|
||||
position.lineNumber,
|
||||
word.endColumn,
|
||||
);
|
||||
return {
|
||||
suggestions: fields.map((f) => ({
|
||||
label: f.name,
|
||||
kind: f.derived
|
||||
? monaco.languages.CompletionItemKind.Variable
|
||||
: monaco.languages.CompletionItemKind.Field,
|
||||
detail: f.derived || f.type === null ? 'derived field' : typeLabel(f.type),
|
||||
insertText: f.name,
|
||||
range,
|
||||
})),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// All three providers annotate the draft only: they derive their field set from
|
||||
// the draft buffer (dataInfo), so gating to the draft view keeps the hints
|
||||
// consistent with the text they are computed from. The published view is a
|
||||
// read-only reference, where field hints are marginal.
|
||||
monaco.languages.registerHoverProvider('json', {
|
||||
provideHover(model, position) {
|
||||
if (useSnippetStore.getState().editorView !== 'draft') return null;
|
||||
const text = model.getValue();
|
||||
const offset = model.getOffsetAt(position);
|
||||
|
||||
// Over an expression value: a live validity check.
|
||||
const key = valueKeyAtOffset(text, offset);
|
||||
if (key !== null && EXPR_KEYS.has(key)) {
|
||||
const expr = stringValueAtOffset(text, offset);
|
||||
if (expr !== null) {
|
||||
const result = validateExpression(expr);
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
value: result.valid
|
||||
? '✓ Valid Vega expression'
|
||||
: `✗ ${result.error ?? 'Invalid expression'}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Over a field name: its type + stats.
|
||||
const word = model.getWordAtPosition(position);
|
||||
if (word) {
|
||||
const info = dataInfo();
|
||||
const hint = info.fields.find((f) => f.name === word.word);
|
||||
if (hint) {
|
||||
return {
|
||||
range: new monaco.Range(
|
||||
position.lineNumber,
|
||||
word.startColumn,
|
||||
position.lineNumber,
|
||||
word.endColumn,
|
||||
),
|
||||
contents: fieldHoverContents(hint, info),
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
monaco.languages.registerInlayHintsProvider('json', {
|
||||
provideInlayHints(model, range) {
|
||||
if (useSnippetStore.getState().editorView !== 'draft') return { hints: [], dispose() {} };
|
||||
const types = new Map(availableFields().map((f) => [f.name, f.type]));
|
||||
if (types.size === 0) return { hints: [], dispose() {} };
|
||||
const hints: monaco.languages.InlayHint[] = [];
|
||||
for (let line = range.startLineNumber; line <= range.endLineNumber; line++) {
|
||||
// First `field` per line; the compact format keeps each encoding channel
|
||||
// (and its lone field) on its own line, so one match per line suffices.
|
||||
const match = /"field"\s*:\s*"([^"]+)"/.exec(model.getLineContent(line));
|
||||
if (!match) continue;
|
||||
const type = types.get(match[1]);
|
||||
if (!type) continue; // unknown or derived (no type to annotate)
|
||||
hints.push({
|
||||
position: { lineNumber: line, column: match.index + match[0].length + 1 },
|
||||
label: `: ${typeLabel(type)}`,
|
||||
kind: monaco.languages.InlayHintKind.Type,
|
||||
paddingLeft: true,
|
||||
});
|
||||
}
|
||||
return { hints, dispose() {} };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
/**
|
||||
* Structural spec transforms as editor actions (docs/architecture/08 → editor
|
||||
* augmentation) — the refactor counterpart to spec-config-actions. Wrap the
|
||||
* focused view in a composition (layer / hconcat / vconcat / facet / repeat) or
|
||||
* collapse a single-child composition back to a unit, over the portable core
|
||||
* transforms (core/spec-transforms).
|
||||
*
|
||||
* **Scope** is the current selection when there is one (the user said exactly
|
||||
* what to target); otherwise the view the cursor sits in — an element of a
|
||||
* layer/concat or a facet/repeat child, resolved by core/spec-cursor — falling
|
||||
* back to the whole document for a flat unit spec with no inner view.
|
||||
*
|
||||
* **Surfacing** mirrors spec-config-actions' three-tier model:
|
||||
* - the editor toolbar's **Transform menu** (SpecEditor) is the discoverable
|
||||
* home, calling `runWrap` / `runUnwrap`;
|
||||
* - the **lightbulb** (`configureSpecTransformCodeActions`, registered once,
|
||||
* global per-language) offers the same transforms contextually at the cursor;
|
||||
* - the **F1 palette** (`installSpecTransformActions`, per editor) is the
|
||||
* keyboard accelerator. These are *not* added to the right-click menu — the
|
||||
* lightbulb already covers the in-place case, and config-actions hold the
|
||||
* three context-menu slots; nine items there would be a thicket.
|
||||
*
|
||||
* Edits go through `executeEdits` (toolbar/palette) or a `WorkspaceEdit` (the
|
||||
* lightbulb, which has no editor handle) — both build the replacement text via
|
||||
* the one `buildNext` + `formatScoped` path, so there is a single transform
|
||||
* path, only the application differs. ⌘Z restores the previous text; invalid
|
||||
* JSON no-ops with a toast; `!editorReadonly` hides the actions on the published
|
||||
* view, and the lightbulb is gated to the active draft.
|
||||
*/
|
||||
|
||||
import * as monaco from 'monaco-editor/esm/vs/editor/edcore.main';
|
||||
import { defaultFieldType } from '@core/chart-builder';
|
||||
import { formatJson } from '@core/json-format';
|
||||
import { isJsonObject, type JsonObject } from '@core/spec-config';
|
||||
import { findViewRange } from '@core/spec-cursor';
|
||||
import { appendView, compositionArrays, type SpecPath } from '@core/spec-insert';
|
||||
import {
|
||||
unwrapSingleton,
|
||||
wrapInConcat,
|
||||
wrapInFacet,
|
||||
wrapInLayer,
|
||||
wrapInRepeat,
|
||||
} from '@core/spec-transforms';
|
||||
import { notify } from '../stores/NotificationStore';
|
||||
import { useSnippetStore } from '../stores/SnippetStore';
|
||||
import { boundColumns } from './active-dataset';
|
||||
import { parseSpecObject } from './spec-config-actions';
|
||||
|
||||
/** The composition operators the wrap actions offer. */
|
||||
type WrapKind = 'layer' | 'hconcat' | 'vconcat' | 'facet' | 'repeat';
|
||||
|
||||
/** The slice of the document a transform reads and rewrites. */
|
||||
interface Scope {
|
||||
range: monaco.Range;
|
||||
text: string;
|
||||
/** Column the slice starts at (0-based), so re-indented output stays aligned. */
|
||||
baseCol: number;
|
||||
}
|
||||
|
||||
const isEmptyRange = (r: monaco.IRange): boolean =>
|
||||
r.startLineNumber === r.endLineNumber && r.startColumn === r.endColumn;
|
||||
|
||||
/** The whole document, as a scope. */
|
||||
function wholeDocument(model: monaco.editor.ITextModel): Scope {
|
||||
const range = model.getFullModelRange();
|
||||
return { range, text: model.getValue(), baseCol: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* The slice a transform acts on: an explicit selection if present; else the view
|
||||
* the cursor sits in (core/spec-cursor); else the whole document.
|
||||
*/
|
||||
function resolveScope(model: monaco.editor.ITextModel, range: monaco.IRange | null): Scope {
|
||||
if (!range) return wholeDocument(model);
|
||||
if (!isEmptyRange(range)) {
|
||||
const r = monaco.Range.lift(range);
|
||||
return { range: r, text: model.getValueInRange(r), baseCol: r.startColumn - 1 };
|
||||
}
|
||||
const offset = model.getOffsetAt({
|
||||
lineNumber: range.startLineNumber,
|
||||
column: range.startColumn,
|
||||
});
|
||||
const node = findViewRange(model.getValue(), offset);
|
||||
if (!node) return wholeDocument(model);
|
||||
const start = model.getPositionAt(node.offset);
|
||||
const end = model.getPositionAt(node.offset + node.length);
|
||||
const r = new monaco.Range(start.lineNumber, start.column, end.lineNumber, end.column);
|
||||
return { range: r, text: model.getValueInRange(r), baseCol: start.column - 1 };
|
||||
}
|
||||
|
||||
/** Indent every line after the first by `baseCol`, so a scoped edit stays aligned. */
|
||||
function reindent(text: string, baseCol: number): string {
|
||||
if (baseCol <= 0) return text;
|
||||
const pad = ' '.repeat(baseCol);
|
||||
return text
|
||||
.split('\n')
|
||||
.map((line, i) => (i === 0 ? line : pad + line))
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
/** Serialize the replacement in the app's compact JSON style, aligned to the scope. */
|
||||
function formatScoped(model: monaco.editor.ITextModel, scope: Scope, next: JsonObject): string {
|
||||
const raw = JSON.stringify(next);
|
||||
const formatted = formatJson(raw, { indent: model.getOptions().tabSize }) ?? raw;
|
||||
return reindent(formatted, scope.baseCol);
|
||||
}
|
||||
|
||||
/** A categorical column to facet by (first nominal/ordinal), or a placeholder. */
|
||||
function defaultFacet(): { field: string; type: string } {
|
||||
const cols = boundColumns();
|
||||
const categorical = cols.find((c) => {
|
||||
const t = defaultFieldType(c.type);
|
||||
return t === 'nominal' || t === 'ordinal';
|
||||
});
|
||||
const col = categorical ?? cols[0];
|
||||
return col
|
||||
? { field: col.name, type: defaultFieldType(col.type) }
|
||||
: { field: 'field', type: 'nominal' };
|
||||
}
|
||||
|
||||
/** Quantitative columns to repeat over + the channel to rewire, with fallbacks. */
|
||||
function defaultRepeat(spec: JsonObject): { fields: string[]; channel: string | null } {
|
||||
const cols = boundColumns();
|
||||
const numeric = cols
|
||||
.filter((c) => defaultFieldType(c.type) === 'quantitative')
|
||||
.map((c) => c.name);
|
||||
const fields = numeric.length > 0 ? numeric.slice(0, 3) : cols.slice(0, 2).map((c) => c.name);
|
||||
const encoding = isJsonObject(spec.encoding) ? spec.encoding : null;
|
||||
const channel = encoding ? ('y' in encoding ? 'y' : (Object.keys(encoding)[0] ?? null)) : null;
|
||||
return { fields: fields.length > 0 ? fields : ['field1', 'field2'], channel };
|
||||
}
|
||||
|
||||
/** Apply a wrap of the given kind to the parsed spec. */
|
||||
function buildNext(spec: JsonObject, kind: WrapKind): JsonObject {
|
||||
switch (kind) {
|
||||
case 'layer':
|
||||
return wrapInLayer(spec);
|
||||
case 'hconcat':
|
||||
return wrapInConcat(spec, 'h');
|
||||
case 'vconcat':
|
||||
return wrapInConcat(spec, 'v');
|
||||
case 'facet': {
|
||||
const { field, type } = defaultFacet();
|
||||
return wrapInFacet(spec, field, type);
|
||||
}
|
||||
case 'repeat': {
|
||||
const { fields, channel } = defaultRepeat(spec);
|
||||
return wrapInRepeat(spec, fields, channel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const WRAP_NOUN: Record<WrapKind, string> = {
|
||||
layer: 'a layer',
|
||||
hconcat: 'a horizontal concat',
|
||||
vconcat: 'a vertical concat',
|
||||
facet: 'a facet',
|
||||
repeat: 'a repeat',
|
||||
};
|
||||
|
||||
/** Replace the scope as one undoable edit (the toolbar / palette path). */
|
||||
function writeBack(
|
||||
editor: monaco.editor.IStandaloneCodeEditor,
|
||||
range: monaco.Range,
|
||||
text: string,
|
||||
): void {
|
||||
editor.pushUndoStop();
|
||||
editor.executeEdits('spec-transform', [{ range, text }]);
|
||||
editor.pushUndoStop();
|
||||
editor.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* The model, focused scope, and the spec parsed off it — the shared prologue of
|
||||
* the scoped actions, or null (after toasting on invalid JSON) when there is
|
||||
* nothing to act on.
|
||||
*/
|
||||
function resolveTarget(
|
||||
editor: monaco.editor.IStandaloneCodeEditor,
|
||||
): { model: monaco.editor.ITextModel; scope: Scope; spec: JsonObject } | null {
|
||||
const model = editor.getModel();
|
||||
if (!model) return null;
|
||||
const scope = resolveScope(model, editor.getSelection());
|
||||
const spec = parseSpecObject(scope.text);
|
||||
return spec ? { model, scope, spec } : null;
|
||||
}
|
||||
|
||||
/** Wrap the focused view (selection, else whole document) in a composition. */
|
||||
export function runWrap(editor: monaco.editor.IStandaloneCodeEditor, kind: WrapKind): void {
|
||||
const target = resolveTarget(editor);
|
||||
if (!target) return;
|
||||
const { model, scope, spec } = target;
|
||||
writeBack(editor, scope.range, formatScoped(model, scope, buildNext(spec, kind)));
|
||||
notify({
|
||||
kind: 'success',
|
||||
title: 'View wrapped',
|
||||
message: `Wrapped in ${WRAP_NOUN[kind]}. Undo with ⌘/Ctrl+Z.`,
|
||||
});
|
||||
}
|
||||
|
||||
/** Collapse a single-child layer/concat in the focused scope back to a unit. */
|
||||
export function runUnwrap(editor: monaco.editor.IStandaloneCodeEditor): void {
|
||||
const target = resolveTarget(editor);
|
||||
if (!target) return;
|
||||
const { model, scope, spec } = target;
|
||||
const next = unwrapSingleton(spec);
|
||||
if (!next) {
|
||||
notify({
|
||||
kind: 'info',
|
||||
title: 'Nothing to simplify',
|
||||
message: 'Select a layer or concat with a single child to collapse it.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
writeBack(editor, scope.range, formatScoped(model, scope, next));
|
||||
notify({
|
||||
kind: 'success',
|
||||
title: 'Composition simplified',
|
||||
message: 'Collapsed the single-child composition. Undo with ⌘/Ctrl+Z.',
|
||||
});
|
||||
}
|
||||
|
||||
/** Append an empty view to the composition at `path` (the CodeLens affordance). */
|
||||
function runAddView(editor: monaco.editor.IStandaloneCodeEditor, path: SpecPath): void {
|
||||
const model = editor.getModel();
|
||||
if (!model) return;
|
||||
const spec = parseSpecObject(model.getValue());
|
||||
if (!spec) return;
|
||||
const next = appendView(spec, path);
|
||||
if (!next) {
|
||||
notify({
|
||||
kind: 'info',
|
||||
title: 'Could not add a view',
|
||||
message: 'The composition changed — try the affordance again.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Whole-document edit (the change is structural and deep); reformatted in the
|
||||
// app's compact style, which is idempotent on an already-formatted draft.
|
||||
writeBack(editor, model.getFullModelRange(), formatScoped(model, wholeDocument(model), next));
|
||||
notify({
|
||||
kind: 'success',
|
||||
title: 'View added',
|
||||
message: `Added an empty view to the ${path[path.length - 1]}. Undo with ⌘/Ctrl+Z.`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the "+ Add view" CodeLens over each composition array (per editor —
|
||||
* the lens command needs the editor handle to apply the edit). Returns a
|
||||
* disposable; dispose on unmount.
|
||||
*/
|
||||
export function installSpecTransformCodeLens(
|
||||
editor: monaco.editor.IStandaloneCodeEditor,
|
||||
): monaco.IDisposable {
|
||||
const addViewCommand = editor.addCommand(0, (_accessor, path: SpecPath) =>
|
||||
runAddView(editor, path),
|
||||
);
|
||||
const provider = monaco.languages.registerCodeLensProvider('json', {
|
||||
provideCodeLenses(model) {
|
||||
if (useSnippetStore.getState().editorView !== 'draft') return { lenses: [], dispose() {} };
|
||||
const lenses = compositionArrays(model.getValue()).map((array) => {
|
||||
const { lineNumber } = model.getPositionAt(array.offset);
|
||||
return {
|
||||
range: new monaco.Range(lineNumber, 1, lineNumber, 1),
|
||||
command: {
|
||||
id: addViewCommand ?? '',
|
||||
title: '$(add) Add view',
|
||||
arguments: [array.path],
|
||||
},
|
||||
};
|
||||
});
|
||||
return { lenses, dispose() {} };
|
||||
},
|
||||
});
|
||||
return { dispose: () => provider.dispose() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the F1-palette actions on the editor (the keyboard accelerator).
|
||||
* Returns a disposable; dispose on editor unmount, like the other per-editor
|
||||
* installs. Deliberately no `contextMenuGroupId` — see the module header.
|
||||
*/
|
||||
export function installSpecTransformActions(
|
||||
editor: monaco.editor.IStandaloneCodeEditor,
|
||||
): monaco.IDisposable {
|
||||
const actions = [
|
||||
editor.addAction({
|
||||
id: 'astrolabe.wrap-layer',
|
||||
label: 'Wrap View in a Layer',
|
||||
precondition: '!editorReadonly',
|
||||
run: () => runWrap(editor, 'layer'),
|
||||
}),
|
||||
editor.addAction({
|
||||
id: 'astrolabe.wrap-hconcat',
|
||||
label: 'Wrap View in Horizontal Concat',
|
||||
precondition: '!editorReadonly',
|
||||
run: () => runWrap(editor, 'hconcat'),
|
||||
}),
|
||||
editor.addAction({
|
||||
id: 'astrolabe.wrap-vconcat',
|
||||
label: 'Wrap View in Vertical Concat',
|
||||
precondition: '!editorReadonly',
|
||||
run: () => runWrap(editor, 'vconcat'),
|
||||
}),
|
||||
editor.addAction({
|
||||
id: 'astrolabe.wrap-facet',
|
||||
label: 'Wrap View in a Facet',
|
||||
precondition: '!editorReadonly',
|
||||
run: () => runWrap(editor, 'facet'),
|
||||
}),
|
||||
editor.addAction({
|
||||
id: 'astrolabe.wrap-repeat',
|
||||
label: 'Wrap View in a Repeat',
|
||||
precondition: '!editorReadonly',
|
||||
run: () => runWrap(editor, 'repeat'),
|
||||
}),
|
||||
editor.addAction({
|
||||
id: 'astrolabe.unwrap',
|
||||
label: 'Simplify Single-Child Composition',
|
||||
precondition: '!editorReadonly',
|
||||
run: () => runUnwrap(editor),
|
||||
}),
|
||||
];
|
||||
return {
|
||||
dispose() {
|
||||
for (const action of actions) action.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The lightbulb has no editor handle, so it returns a `WorkspaceEdit` instead of
|
||||
* calling `executeEdits`; both paths build the replacement through `buildNext` +
|
||||
* `formatScoped`.
|
||||
*/
|
||||
function editAction(
|
||||
model: monaco.editor.ITextModel,
|
||||
scope: Scope,
|
||||
title: string,
|
||||
next: JsonObject,
|
||||
): monaco.languages.CodeAction {
|
||||
return {
|
||||
title,
|
||||
kind: 'refactor.rewrite',
|
||||
edit: {
|
||||
edits: [
|
||||
{
|
||||
resource: model.uri,
|
||||
versionId: model.getVersionId(),
|
||||
textEdit: { range: scope.range, text: formatScoped(model, scope, next) },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let codeActionsRegistered = false;
|
||||
|
||||
/**
|
||||
* Register the wrap/simplify refactors as code actions (the lightbulb) once,
|
||||
* globally for JSON — like the schema and formatter, not per editor. Idempotent.
|
||||
*/
|
||||
export function configureSpecTransformCodeActions(): void {
|
||||
if (codeActionsRegistered) return;
|
||||
codeActionsRegistered = true;
|
||||
|
||||
monaco.languages.registerCodeActionProvider('json', {
|
||||
provideCodeActions(model, range) {
|
||||
const empty = { actions: [], dispose() {} };
|
||||
// Global provider, no editor handle: gate on the store the way the run*
|
||||
// path is gated by `!editorReadonly` — only on the active snippet's draft.
|
||||
const snippet = useSnippetStore.getState();
|
||||
if (snippet.activeSnippetId === null || snippet.editorView !== 'draft') return empty;
|
||||
|
||||
const scope = resolveScope(model, range);
|
||||
let spec: unknown;
|
||||
try {
|
||||
spec = JSON.parse(scope.text);
|
||||
} catch {
|
||||
return empty;
|
||||
}
|
||||
if (!isJsonObject(spec)) return empty;
|
||||
|
||||
const actions: monaco.languages.CodeAction[] = [
|
||||
editAction(model, scope, 'Wrap view in a layer', buildNext(spec, 'layer')),
|
||||
editAction(model, scope, 'Wrap view in horizontal concat', buildNext(spec, 'hconcat')),
|
||||
editAction(model, scope, 'Wrap view in vertical concat', buildNext(spec, 'vconcat')),
|
||||
editAction(model, scope, 'Wrap view in a facet', buildNext(spec, 'facet')),
|
||||
editAction(model, scope, 'Wrap view in a repeat', buildNext(spec, 'repeat')),
|
||||
];
|
||||
const collapsed = unwrapSingleton(spec);
|
||||
if (collapsed)
|
||||
actions.push(editAction(model, scope, 'Simplify single-child composition', collapsed));
|
||||
|
||||
return { actions, dispose() {} };
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user