/** * 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 = { 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(); 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() {} }; }, }); }