mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
216797ff9b
Drag a view's box (or Alt+up/down) to reorder it within its container; drag onto another view's edge to pair the two in a new row/column, move across containers, or insert. Each leaf shows a glyph of its mark type. Core: spec-restructure.wrapViews (wrap, with flatten-to-insert, collapse, and data-pin invariants) and spec-insert.moveViewTo; the editor applies every drag as one undoable edit via AppStore.composeRequest, keeping the editor the single text source.
665 lines
24 KiB
TypeScript
665 lines
24 KiB
TypeScript
/**
|
||
* 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 {
|
||
compositionTargetAt,
|
||
elementOffset,
|
||
insertView,
|
||
moveView,
|
||
moveViewTo,
|
||
type SpecPath,
|
||
} from '@core/spec-insert';
|
||
import { wrapViews, type DropAxis } from '@core/spec-restructure';
|
||
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). `focus`
|
||
* returns focus to the editor afterwards — true for editor-originated actions,
|
||
* false when the wireframe drove the edit and must keep its own focus (APG: a
|
||
* keyboard reorder stays on the moved item for consecutive moves).
|
||
*/
|
||
function writeBack(
|
||
editor: monaco.editor.IStandaloneCodeEditor,
|
||
range: monaco.Range,
|
||
text: string,
|
||
focus = true,
|
||
): void {
|
||
editor.pushUndoStop();
|
||
editor.executeEdits('spec-transform', [{ range, text }]);
|
||
editor.pushUndoStop();
|
||
if (focus) 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.',
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Apply a structural array edit (insert / move) as one undoable whole-document
|
||
* rewrite, then put the cursor on the affected view so the lenses re-anchor to it
|
||
* and a repeated click keeps acting on the same view. `followIndex` is the view's
|
||
* index in `arrayPath` *after* the edit. Reformatted in the app's compact style,
|
||
* which is idempotent on an already-formatted draft.
|
||
*
|
||
* `successTitle` of null suppresses the success toast (the wireframe gives its own
|
||
* feedback — visible move + live-region announcement — so a per-move toast would
|
||
* double up); `focusEditor` of false keeps focus off the editor for the same path.
|
||
*/
|
||
function applyArrayEdit(
|
||
editor: monaco.editor.IStandaloneCodeEditor,
|
||
build: (spec: JsonObject) => JsonObject | null,
|
||
arrayPath: SpecPath,
|
||
followIndex: number,
|
||
successTitle: string | null,
|
||
focusEditor = true,
|
||
): void {
|
||
const model = editor.getModel();
|
||
if (!model) return;
|
||
const spec = parseSpecObject(model.getValue());
|
||
if (!spec) return;
|
||
const next = build(spec);
|
||
if (!next) {
|
||
notify({
|
||
kind: 'info',
|
||
title: 'Could not change the composition',
|
||
message: 'The composition changed — try the affordance again.',
|
||
});
|
||
return;
|
||
}
|
||
const formatted = formatScoped(model, wholeDocument(model), next);
|
||
writeBack(editor, model.getFullModelRange(), formatted, focusEditor);
|
||
const offset = elementOffset(formatted, arrayPath, followIndex);
|
||
if (offset !== null) {
|
||
const pos = model.getPositionAt(offset);
|
||
editor.setPosition(pos);
|
||
editor.revealPositionInCenterIfOutsideViewport(pos);
|
||
}
|
||
if (successTitle)
|
||
notify({ kind: 'success', title: successTitle, message: 'Undo with ⌘/Ctrl+Z.' });
|
||
}
|
||
|
||
/** Insert an empty view at `index` of the composition at `arrayPath`. */
|
||
function runAddView(
|
||
editor: monaco.editor.IStandaloneCodeEditor,
|
||
arrayPath: SpecPath,
|
||
index: number,
|
||
): void {
|
||
applyArrayEdit(editor, (s) => insertView(s, arrayPath, index), arrayPath, index, 'View added');
|
||
}
|
||
|
||
/** Swap the view at `index` with its sibling `delta` steps away (±1). */
|
||
function runMoveView(
|
||
editor: monaco.editor.IStandaloneCodeEditor,
|
||
arrayPath: SpecPath,
|
||
index: number,
|
||
delta: number,
|
||
): void {
|
||
applyArrayEdit(
|
||
editor,
|
||
(s) => moveView(s, arrayPath, index, delta),
|
||
arrayPath,
|
||
index + delta,
|
||
delta < 0 ? 'View moved up' : 'View moved down',
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Reorder the view at `from` to `to` within the composition at `arrayPath` — the
|
||
* composition wireframe's drag/keyboard reorder. No success toast and no editor
|
||
* focus-steal: the wireframe owns the feedback and keeps focus on the moved box.
|
||
*/
|
||
export function runMoveViewTo(
|
||
editor: monaco.editor.IStandaloneCodeEditor,
|
||
arrayPath: SpecPath,
|
||
from: number,
|
||
to: number,
|
||
): void {
|
||
applyArrayEdit(editor, (s) => moveViewTo(s, arrayPath, from, to), arrayPath, to, null, false);
|
||
}
|
||
|
||
/**
|
||
* Restructure the spec by pairing the dragged `sourcePath` view beside the drop
|
||
* `targetPath` view in a new concat of `axis` — the wireframe's cross-container
|
||
* drag (wrap, move-in, collapse, all in core/spec-restructure). One undoable edit;
|
||
* no toast and no editor focus-steal, as with the reorder path. A degenerate or
|
||
* stale drop surfaces an info toast rather than silently doing nothing.
|
||
*/
|
||
export function runWrapViews(
|
||
editor: monaco.editor.IStandaloneCodeEditor,
|
||
targetPath: SpecPath,
|
||
sourcePath: SpecPath,
|
||
axis: DropAxis,
|
||
side: 'before' | 'after',
|
||
): void {
|
||
const model = editor.getModel();
|
||
if (!model) return;
|
||
const spec = parseSpecObject(model.getValue());
|
||
if (!spec) return;
|
||
const next = wrapViews(spec, targetPath, sourcePath, axis, side);
|
||
if (!next) {
|
||
notify({
|
||
kind: 'info',
|
||
title: 'Could not restructure',
|
||
message: 'That drop isn’t possible here — the composition may have changed.',
|
||
});
|
||
return;
|
||
}
|
||
writeBack(
|
||
editor,
|
||
model.getFullModelRange(),
|
||
formatScoped(model, wholeDocument(model), next),
|
||
false,
|
||
);
|
||
}
|
||
|
||
/** Insert a view above/below the one the cursor is in (the keyboard path). */
|
||
function runInsertRelative(
|
||
editor: monaco.editor.IStandaloneCodeEditor,
|
||
where: 'above' | 'below',
|
||
): void {
|
||
const model = editor.getModel();
|
||
const pos = editor.getPosition();
|
||
if (!model || !pos) return;
|
||
const target = compositionTargetAt(model.getValue(), model.getOffsetAt(pos));
|
||
if (!target) {
|
||
notify({
|
||
kind: 'info',
|
||
title: 'No composition here',
|
||
message: 'Place the cursor in a view inside a layer or concat.',
|
||
});
|
||
return;
|
||
}
|
||
if (!target.element) {
|
||
runAddView(editor, target.arrayPath, 0); // empty composition → its first view
|
||
return;
|
||
}
|
||
runAddView(
|
||
editor,
|
||
target.arrayPath,
|
||
where === 'above' ? target.element.index : target.element.index + 1,
|
||
);
|
||
}
|
||
|
||
/** Move the view the cursor is in up/down among its siblings (the keyboard path). */
|
||
function runMoveRelative(editor: monaco.editor.IStandaloneCodeEditor, delta: number): void {
|
||
const model = editor.getModel();
|
||
const pos = editor.getPosition();
|
||
if (!model || !pos) return;
|
||
const target = compositionTargetAt(model.getValue(), model.getOffsetAt(pos));
|
||
if (!target?.element) {
|
||
notify({
|
||
kind: 'info',
|
||
title: 'No view to move',
|
||
message: 'Place the cursor in a view inside a layer or concat.',
|
||
});
|
||
return;
|
||
}
|
||
const to = target.element.index + delta;
|
||
if (to < 0 || to >= target.count) {
|
||
notify({
|
||
kind: 'info',
|
||
title: delta < 0 ? 'Already first' : 'Already last',
|
||
message: 'This view is at the edge of its composition.',
|
||
});
|
||
return;
|
||
}
|
||
runMoveView(editor, target.arrayPath, target.element.index, delta);
|
||
}
|
||
|
||
/**
|
||
* Install the cursor-aware composition CodeLens (per editor — the lens commands
|
||
* need this editor's handle to apply the edit). Over the view the cursor sits in
|
||
* it offers `+ Add view above/below` at the view's edges and `↑/↓ Move` to
|
||
* reorder it among its siblings; an empty composition gets a single `+ Add view`.
|
||
* Returns a disposable; dispose on unmount.
|
||
*/
|
||
export function installSpecTransformCodeLens(
|
||
editor: monaco.editor.IStandaloneCodeEditor,
|
||
): monaco.IDisposable {
|
||
const addCmd = editor.addCommand(0, (_a, path: SpecPath, index: number) =>
|
||
runAddView(editor, path, index),
|
||
);
|
||
const moveCmd = editor.addCommand(0, (_a, path: SpecPath, index: number, delta: number) =>
|
||
runMoveView(editor, path, index, delta),
|
||
);
|
||
|
||
const lens = (
|
||
line: number,
|
||
title: string,
|
||
id: string | null,
|
||
args: unknown[],
|
||
): monaco.languages.CodeLens => ({
|
||
range: new monaco.Range(line, 1, line, 1),
|
||
command: { id: id ?? '', title, arguments: args },
|
||
});
|
||
|
||
// The lenses depend on the cursor, so signal a refresh when the enclosing view
|
||
// changes — keyed so typing within one view does not re-render them.
|
||
const onDidChange = new monaco.Emitter<monaco.languages.CodeLensProvider>();
|
||
const codeLensProvider: monaco.languages.CodeLensProvider = {
|
||
onDidChange: onDidChange.event,
|
||
provideCodeLenses(model) {
|
||
if (useSnippetStore.getState().editorView !== 'draft') return { lenses: [], dispose() {} };
|
||
const pos = editor.getPosition();
|
||
if (!pos) return { lenses: [], dispose() {} };
|
||
const target = compositionTargetAt(model.getValue(), model.getOffsetAt(pos));
|
||
if (!target) return { lenses: [], dispose() {} };
|
||
const lenses: monaco.languages.CodeLens[] = [];
|
||
if (!target.element) {
|
||
// Off a view, only an empty composition gets an affordance — the array's
|
||
// own line stays clean otherwise.
|
||
if (target.count === 0) {
|
||
const line = model.getPositionAt(target.arrayOffset).lineNumber;
|
||
lenses.push(lens(line, '$(add) Add view', addCmd, [target.arrayPath, 0]));
|
||
}
|
||
return { lenses, dispose() {} };
|
||
}
|
||
const { index, offset, length } = target.element;
|
||
// "above" actions sit on the view's first line, "below" on the line after
|
||
// its last — so up-actions group at the top, down-actions at the bottom.
|
||
const topLine = model.getPositionAt(offset).lineNumber;
|
||
const bottomLine = Math.min(
|
||
model.getLineCount(),
|
||
model.getPositionAt(offset + length).lineNumber + 1,
|
||
);
|
||
lenses.push(lens(topLine, '$(add) Add view above', addCmd, [target.arrayPath, index]));
|
||
if (index > 0)
|
||
lenses.push(lens(topLine, '$(arrow-up) Move up', moveCmd, [target.arrayPath, index, -1]));
|
||
lenses.push(lens(bottomLine, '$(add) Add view below', addCmd, [target.arrayPath, index + 1]));
|
||
if (index < target.count - 1)
|
||
lenses.push(
|
||
lens(bottomLine, '$(arrow-down) Move down', moveCmd, [target.arrayPath, index, 1]),
|
||
);
|
||
return { lenses, dispose() {} };
|
||
},
|
||
};
|
||
const registration = monaco.languages.registerCodeLensProvider('json', codeLensProvider);
|
||
|
||
let lastKey = '';
|
||
const cursorSub = editor.onDidChangeCursorPosition(() => {
|
||
const model = editor.getModel();
|
||
const pos = editor.getPosition();
|
||
const target =
|
||
model && pos && useSnippetStore.getState().editorView === 'draft'
|
||
? compositionTargetAt(model.getValue(), model.getOffsetAt(pos))
|
||
: null;
|
||
const key = target
|
||
? JSON.stringify([target.arrayPath, target.element?.index ?? -1, target.count])
|
||
: '';
|
||
if (key !== lastKey) {
|
||
lastKey = key;
|
||
onDidChange.fire(codeLensProvider);
|
||
}
|
||
});
|
||
|
||
return {
|
||
dispose() {
|
||
cursorSub.dispose();
|
||
onDidChange.dispose();
|
||
registration.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),
|
||
}),
|
||
editor.addAction({
|
||
id: 'astrolabe.insert-view-above',
|
||
label: 'Insert View Above',
|
||
precondition: '!editorReadonly',
|
||
run: () => runInsertRelative(editor, 'above'),
|
||
}),
|
||
editor.addAction({
|
||
id: 'astrolabe.insert-view-below',
|
||
label: 'Insert View Below',
|
||
precondition: '!editorReadonly',
|
||
run: () => runInsertRelative(editor, 'below'),
|
||
}),
|
||
editor.addAction({
|
||
id: 'astrolabe.move-view-up',
|
||
label: 'Move View Up',
|
||
precondition: '!editorReadonly',
|
||
run: () => runMoveRelative(editor, -1),
|
||
}),
|
||
editor.addAction({
|
||
id: 'astrolabe.move-view-down',
|
||
label: 'Move View Down',
|
||
precondition: '!editorReadonly',
|
||
run: () => runMoveRelative(editor, 1),
|
||
}),
|
||
];
|
||
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() {} };
|
||
},
|
||
});
|
||
}
|