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:
@@ -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