Files
astrolabe/src/app/services/spec-transform-actions.ts
T

416 lines
15 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.
/**
* 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 { boundColumnsAt } 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;
/** Whole-document offset of the focused view, for resolving its data binding. */
offset: number;
/** 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(), offset: 0, 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. `offset` is the
* cursor/selection-start position in the whole document, used to resolve the
* focused view's data binding (facet/repeat field defaults).
*/
function resolveScope(model: monaco.editor.ITextModel, range: monaco.IRange | null): Scope {
if (!range) return wholeDocument(model);
const offset = model.getOffsetAt({
lineNumber: range.startLineNumber,
column: range.startColumn,
});
if (!isEmptyRange(range)) {
const r = monaco.Range.lift(range);
return { range: r, text: model.getValueInRange(r), offset, baseCol: r.startColumn - 1 };
}
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), offset, 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(text: string, offset: number): { field: string; type: string } {
const cols = boundColumnsAt(text, offset);
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,
text: string,
offset: number,
): { fields: string[]; channel: string | null } {
const cols = boundColumnsAt(text, offset);
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 (scoped) spec. `text`/`offset` are
* the whole document and the focused view's position, used to resolve that view's
* data binding for the facet/repeat field defaults.
*/
function buildNext(spec: JsonObject, kind: WrapKind, text: string, offset: number): 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(text, offset);
return wrapInFacet(spec, field, type);
}
case 'repeat': {
const { fields, channel } = defaultRepeat(spec, text, offset);
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;
const next = buildNext(spec, kind, model.getValue(), scope.offset);
writeBack(editor, scope.range, formatScoped(model, scope, next));
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 viewSpec = spec;
const fullText = model.getValue();
const build = (kind: WrapKind) => buildNext(viewSpec, kind, fullText, scope.offset);
const actions: monaco.languages.CodeAction[] = [
editAction(model, scope, 'Wrap view in a layer', build('layer')),
editAction(model, scope, 'Wrap view in horizontal concat', build('hconcat')),
editAction(model, scope, 'Wrap view in vertical concat', build('vconcat')),
editAction(model, scope, 'Wrap view in a facet', build('facet')),
editAction(model, scope, 'Wrap view in a repeat', build('repeat')),
];
const collapsed = unwrapSingleton(spec);
if (collapsed)
actions.push(editAction(model, scope, 'Simplify single-child composition', collapsed));
return { actions, dispose() {} };
},
});
}