diff --git a/src/platform/plugins/shared/workflows_management/common/lib/yaml/get_step_nodes_with_type.ts b/src/platform/plugins/shared/workflows_management/common/lib/yaml/get_step_nodes_with_type.ts index f15d1eeb23424..13c5b5029f3af 100644 --- a/src/platform/plugins/shared/workflows_management/common/lib/yaml/get_step_nodes_with_type.ts +++ b/src/platform/plugins/shared/workflows_management/common/lib/yaml/get_step_nodes_with_type.ts @@ -10,6 +10,15 @@ import type { Document, YAMLMap } from 'yaml'; import { isMap, isPair, isScalar, visit } from 'yaml'; +export function isStepLikeMap(item: unknown): item is YAMLMap { + if (!item || !isMap(item)) return false; + const items = (item as YAMLMap).items; + if (!items) return false; + const hasName = items.some((p) => isPair(p) && isScalar(p.key) && p.key.value === 'name'); + const hasType = items.some((p) => isPair(p) && isScalar(p.key) && p.key.value === 'type'); + return hasName && hasType; +} + export function getStepNodesWithType(yamlDocument: Document): YAMLMap[] { const stepNodes: YAMLMap[] = []; diff --git a/src/platform/plugins/shared/workflows_management/common/lib/yaml/get_steps_else_key_offsets.ts b/src/platform/plugins/shared/workflows_management/common/lib/yaml/get_steps_else_key_offsets.ts new file mode 100644 index 0000000000000..a61286c67bbdd --- /dev/null +++ b/src/platform/plugins/shared/workflows_management/common/lib/yaml/get_steps_else_key_offsets.ts @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { Document, Node } from 'yaml'; +import { isScalar, isSeq, visit } from 'yaml'; + +export interface StepsElseKeyOffsets { + stepsKeyStartOffsets: number[]; + elseKeyStartOffsets: number[]; +} + +export function getStepsAndElseKeyOffsets(document: Document): StepsElseKeyOffsets { + const stepsKeyStartOffsets: number[] = []; + const elseKeyStartOffsets: number[] = []; + + visit(document, { + Pair(_key, pair) { + if (!pair.key || !isScalar(pair.key)) return; + const keyVal = pair.key.value; + if (keyVal !== 'steps' && keyVal !== 'else') return; + const keyNode = pair.key as Node; + if (!keyNode.range) return; + const startOffset = keyNode.range[0]; + if (keyVal === 'steps') { + stepsKeyStartOffsets.push(startOffset); + } else { + elseKeyStartOffsets.push(startOffset); + } + }, + }); + + return { stepsKeyStartOffsets, elseKeyStartOffsets }; +} + +export interface BlockKeyInfo { + keyStartOffset: number; + rangeStart: number; + rangeEnd: number; +} + +export function getInnermostBlockContainingOffset( + document: Document, + cursorOffset: number +): BlockKeyInfo | null { + const candidates: BlockKeyInfo[] = []; + + visit(document, { + Pair(_key, pair) { + if (!pair.key || !isScalar(pair.key)) return; + const keyVal = pair.key.value; + if (keyVal !== 'steps' && keyVal !== 'else') return; + const seq = pair.value; + if (!isSeq(seq) || !seq.range) return; + const keyNode = pair.key as Node; + if (!keyNode.range) return; + const [rangeStart, , rangeEnd] = seq.range as number[]; + if (cursorOffset >= rangeStart && cursorOffset <= rangeEnd) { + candidates.push({ + keyStartOffset: keyNode.range[0], + rangeStart, + rangeEnd, + }); + } + }, + }); + + if (candidates.length === 0) return null; + candidates.sort((a, b) => a.rangeEnd - a.rangeStart - (b.rangeEnd - b.rangeStart)); + return candidates[0]; +} diff --git a/src/platform/plugins/shared/workflows_management/common/lib/yaml/index.ts b/src/platform/plugins/shared/workflows_management/common/lib/yaml/index.ts index f62adf3027188..11c5524ae95d9 100644 --- a/src/platform/plugins/shared/workflows_management/common/lib/yaml/index.ts +++ b/src/platform/plugins/shared/workflows_management/common/lib/yaml/index.ts @@ -8,9 +8,15 @@ */ export { getPathAtOffset, getPathFromAncestors } from '@kbn/workflows/common/utils/yaml'; +export { + getInnermostBlockContainingOffset, + getStepsAndElseKeyOffsets, + type BlockKeyInfo, + type StepsElseKeyOffsets, +} from './get_steps_else_key_offsets'; export { getStepNodeAtPosition } from './get_step_node_at_position'; export { getStepNode } from './get_step_node'; -export { getStepNodesWithType } from './get_step_nodes_with_type'; +export { getStepNodesWithType, isStepLikeMap } from './get_step_nodes_with_type'; export { getTriggerNodes, getTriggersPair } from './get_trigger_nodes'; export { getTriggerNodesWithType } from './get_trigger_nodes_with_type'; export { parseWorkflowYamlToJSON } from './parse_workflow_yaml_to_json'; diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/insert_step_snippet.test.ts b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/insert_step_snippet.test.ts index 63455562ef671..f95107cc962b2 100644 --- a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/insert_step_snippet.test.ts +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/insert_step_snippet.test.ts @@ -155,11 +155,12 @@ steps: full: true, withStepsSection: false, }); + // Cursor is between the two nested steps (line 10); new step inserts after the first, so at line 12 with nested indent (6). expect(model.pushEditOperations).toHaveBeenCalledWith( null, [ { - range: new monaco.Range(11, 1, 11, 1), + range: new monaco.Range(12, 1, 12, 1), text: prependIndentToLines(snippetText, 6), }, ], @@ -205,4 +206,620 @@ steps: expect(mockEditor.pushUndoStop).toHaveBeenCalledTimes(2); }); + + it('should insert step when YAML is empty', () => { + const inputYaml = ``; + const model = createFakeMonacoModel(inputYaml); + const yamlDocument = parseDocument(inputYaml); + insertStepSnippet(model as unknown as monaco.editor.ITextModel, yamlDocument, 'http'); + + expect(generateBuiltInStepSnippetSpy).toHaveBeenCalledWith('http', { + full: true, + withStepsSection: true, + }); + + const snippetText = generateBuiltInStepSnippetModule.generateBuiltInStepSnippet('http', { + full: true, + withStepsSection: true, + }); + + expect(model.pushEditOperations).toHaveBeenCalledWith( + null, + [ + { + range: new monaco.Range(2, 1, 2, 1), + text: snippetText, + }, + ], + expect.any(Function) + ); + }); + + it('should insert step when steps section exists but is empty (steps:)', () => { + const inputYaml = `steps:`; + const model = createFakeMonacoModel(inputYaml); + const yamlDocument = parseDocument(inputYaml); + insertStepSnippet(model as unknown as monaco.editor.ITextModel, yamlDocument, 'http'); + + expect(generateBuiltInStepSnippetSpy).toHaveBeenCalledWith('http', { + full: true, + withStepsSection: false, + }); + + const snippetText = generateBuiltInStepSnippetModule.generateBuiltInStepSnippet('http', { + full: true, + withStepsSection: false, + }); + expect(model.pushEditOperations).toHaveBeenCalledWith( + null, + [ + { + range: new monaco.Range(2, 1, 2, 1), + text: prependIndentToLines(snippetText, 2), + }, + ], + expect.any(Function) + ); + }); + + it('should replace flow-style empty array (steps: [])', () => { + const inputYaml = `steps: []`; + const model = createFakeMonacoModel(inputYaml); + const yamlDocument = parseDocument(inputYaml); + insertStepSnippet(model as unknown as monaco.editor.ITextModel, yamlDocument, 'http'); + + expect(generateBuiltInStepSnippetSpy).toHaveBeenCalledWith('http', { + full: true, + withStepsSection: false, + }); + + const snippetText = generateBuiltInStepSnippetModule.generateBuiltInStepSnippet('http', { + full: true, + withStepsSection: false, + }); + const expectedText = `steps:\n${prependIndentToLines(snippetText, 2)}`; + expect(model.pushEditOperations).toHaveBeenCalledWith( + null, + [ + { + range: expect.any(monaco.Range), + text: expectedText, + }, + ], + expect.any(Function) + ); + }); + + it('should replace empty item (steps:\\n -)', () => { + const inputYaml = `steps: + -`; + const model = createFakeMonacoModel(inputYaml); + const yamlDocument = parseDocument(inputYaml); + insertStepSnippet(model as unknown as monaco.editor.ITextModel, yamlDocument, 'http'); + + expect(generateBuiltInStepSnippetSpy).toHaveBeenCalledWith('http', { + full: true, + withStepsSection: false, + }); + + const snippetText = generateBuiltInStepSnippetModule.generateBuiltInStepSnippet('http', { + full: true, + withStepsSection: false, + }); + // Should replace the empty - with the step + expect(model.pushEditOperations).toHaveBeenCalledWith( + null, + [ + { + range: expect.any(monaco.Range), + text: prependIndentToLines(snippetText, 2), + }, + ], + expect.any(Function) + ); + }); + + it('should insert step after comment when steps has trailing spaces', () => { + const inputYaml = `steps: + ### comment + - name: existing_step + type: http`; + const model = createFakeMonacoModel(inputYaml); + const yamlDocument = parseDocument(inputYaml); + insertStepSnippet(model as unknown as monaco.editor.ITextModel, yamlDocument, 'http'); + + expect(generateBuiltInStepSnippetSpy).toHaveBeenCalledWith('http', { + full: true, + withStepsSection: false, + }); + + const snippetText = generateBuiltInStepSnippetModule.generateBuiltInStepSnippet('http', { + full: true, + withStepsSection: false, + }); + // Should insert after the comment, before existing_step + expect(model.pushEditOperations).toHaveBeenCalledWith( + null, + [ + { + range: expect.any(monaco.Range), + text: prependIndentToLines(snippetText, 2), + }, + ], + expect.any(Function) + ); + }); + + it('should replace empty line when cursor is on empty line between comment and step', () => { + const inputYaml = `steps: + ### hello world + + - name: existing_step + type: http`; + const model = createFakeMonacoModel(inputYaml); + const yamlDocument = parseDocument(inputYaml); + // Cursor is on the empty line between comment and step (line 3) + insertStepSnippet( + model as unknown as monaco.editor.ITextModel, + yamlDocument, + 'http', + new monaco.Position(3, 1) + ); + + expect(generateBuiltInStepSnippetSpy).toHaveBeenCalledWith('http', { + full: true, + withStepsSection: false, + }); + + const snippetText = generateBuiltInStepSnippetModule.generateBuiltInStepSnippet('http', { + full: true, + withStepsSection: false, + }); + // Should replace the empty line (line 3) with the step + expect(model.pushEditOperations).toHaveBeenCalledWith( + null, + [ + { + range: expect.any(monaco.Range), + text: prependIndentToLines(snippetText, 2), + }, + ], + expect.any(Function) + ); + }); + + it('should insert step at cursor position when cursor is on steps: line', () => { + const inputYaml = `steps: + ## Hello + - name: existing_step + type: http`; + const model = createFakeMonacoModel(inputYaml); + const yamlDocument = parseDocument(inputYaml); + // Cursor is on the steps: line + insertStepSnippet( + model as unknown as monaco.editor.ITextModel, + yamlDocument, + 'http', + new monaco.Position(1, 7) + ); + + expect(generateBuiltInStepSnippetSpy).toHaveBeenCalledWith('http', { + full: true, + withStepsSection: false, + }); + + const snippetText = generateBuiltInStepSnippetModule.generateBuiltInStepSnippet('http', { + full: true, + withStepsSection: false, + }); + + expect(model.pushEditOperations).toHaveBeenCalled(); + const callArgs = (model.pushEditOperations as jest.Mock).mock.calls[0]; + expect(callArgs[0]).toBe(null); + expect(callArgs[1]).toHaveLength(1); + expect(callArgs[1][0].range).toBeInstanceOf(monaco.Range); + // Normalize the expected text (remove any existing trailing newlines) and add the comment line + let expectedTextWithoutNewline = prependIndentToLines(snippetText, 2); + // Remove trailing newlines without regex + while ( + expectedTextWithoutNewline.length > 0 && + expectedTextWithoutNewline[expectedTextWithoutNewline.length - 1] === '\n' + ) { + expectedTextWithoutNewline = expectedTextWithoutNewline.slice(0, -1); + } + // The insertion includes the comment line to preserve it + const expectedText = `${expectedTextWithoutNewline}\n ## Hello\n`; + expect(callArgs[1][0].text).toBe(expectedText); + expect(callArgs[2]).toBeInstanceOf(Function); + }); + + it('should insert step at end when cursor is at end of last step', () => { + const inputYaml = `steps: + - name: first_step + type: http + with: + url: https://example.com`; + const model = createFakeMonacoModel(inputYaml); + const yamlDocument = parseDocument(inputYaml); + // Cursor is at the end of the last step + insertStepSnippet( + model as unknown as monaco.editor.ITextModel, + yamlDocument, + 'http', + new monaco.Position(5, 30) + ); + + expect(generateBuiltInStepSnippetSpy).toHaveBeenCalledWith('http', { + full: true, + withStepsSection: false, + }); + + const snippetText = generateBuiltInStepSnippetModule.generateBuiltInStepSnippet('http', { + full: true, + withStepsSection: false, + }); + // Should insert after the last step + expect(model.pushEditOperations).toHaveBeenCalledWith( + null, + [ + { + range: expect.any(monaco.Range), + text: prependIndentToLines(snippetText, 2), + }, + ], + expect.any(Function) + ); + }); + + it('should insert step on the newline after a step with root indent', () => { + const inputYaml = `steps: + - name: first_step + type: http + with: + url: https://example.com + +`; + const model = createFakeMonacoModel(inputYaml); + const yamlDocument = parseDocument(inputYaml); + // Cursor is on the empty line (line 6) after the last step + insertStepSnippet( + model as unknown as monaco.editor.ITextModel, + yamlDocument, + 'http', + new monaco.Position(6, 1) + ); + + expect(generateBuiltInStepSnippetSpy).toHaveBeenCalledWith('http', { + full: true, + withStepsSection: false, + }); + + const snippetText = generateBuiltInStepSnippetModule.generateBuiltInStepSnippet('http', { + full: true, + withStepsSection: false, + }); + const callArgs = (model.pushEditOperations as jest.Mock).mock.calls[0]; + expect(callArgs[1][0].range.startLineNumber).toBe(6); + expect(callArgs[1][0].range.startColumn).toBe(1); + expect(callArgs[1][0].text).toBe(prependIndentToLines(snippetText, 2)); + }); + + it('should insert step on next line with root indent when cursor is on steps: line', () => { + const inputYaml = `steps: + - name: first_step + type: http`; + const model = createFakeMonacoModel(inputYaml); + const yamlDocument = parseDocument(inputYaml); + // Cursor is on the "steps:" line (line 1) + insertStepSnippet( + model as unknown as monaco.editor.ITextModel, + yamlDocument, + 'http', + new monaco.Position(1, 3) + ); + + expect(generateBuiltInStepSnippetSpy).toHaveBeenCalledWith('http', { + full: true, + withStepsSection: false, + }); + + const snippetText = generateBuiltInStepSnippetModule.generateBuiltInStepSnippet('http', { + full: true, + withStepsSection: false, + }); + const callArgs = (model.pushEditOperations as jest.Mock).mock.calls[0]; + // Should insert on line 2 (next line after "steps:") + expect(callArgs[1][0].range.startLineNumber).toBe(2); + expect(callArgs[1][0].range.startColumn).toBe(1); + expect(callArgs[1][0].text).toContain(prependIndentToLines(snippetText, 2).trim()); + }); + + it('should insert step on following line when cursor is on comment line', () => { + const inputYaml = `steps: + ## Hello world + - name: existing_step + type: http`; + const model = createFakeMonacoModel(inputYaml); + const yamlDocument = parseDocument(inputYaml); + // Cursor is on the comment line + insertStepSnippet( + model as unknown as monaco.editor.ITextModel, + yamlDocument, + 'http', + new monaco.Position(2, 5) + ); + + expect(generateBuiltInStepSnippetSpy).toHaveBeenCalledWith('http', { + full: true, + withStepsSection: false, + }); + + const snippetText = generateBuiltInStepSnippetModule.generateBuiltInStepSnippet('http', { + full: true, + withStepsSection: false, + }); + // Should insert on the following line (line 3), before existing_step + expect(model.pushEditOperations).toHaveBeenCalledWith( + null, + [ + { + range: expect.any(monaco.Range), + text: prependIndentToLines(snippetText, 2), + }, + ], + expect.any(Function) + ); + }); + + it('should replace empty line when cursor is on empty line', () => { + const inputYaml = `steps: + + - name: existing_step + type: http`; + const model = createFakeMonacoModel(inputYaml); + const yamlDocument = parseDocument(inputYaml); + // Cursor is on the empty line (line 2) + insertStepSnippet( + model as unknown as monaco.editor.ITextModel, + yamlDocument, + 'http', + new monaco.Position(2, 1) + ); + + expect(generateBuiltInStepSnippetSpy).toHaveBeenCalledWith('http', { + full: true, + withStepsSection: false, + }); + + const snippetText = generateBuiltInStepSnippetModule.generateBuiltInStepSnippet('http', { + full: true, + withStepsSection: false, + }); + // Should replace the empty line (line 2) with the step + expect(model.pushEditOperations).toHaveBeenCalledWith( + null, + [ + { + range: expect.any(monaco.Range), + text: prependIndentToLines(snippetText, 2), + }, + ], + expect.any(Function) + ); + }); + + it('should replace empty line at end of steps section when cursor is on it', () => { + const inputYaml = `steps: + - name: existing_step + type: http + +`; + const model = createFakeMonacoModel(inputYaml); + const yamlDocument = parseDocument(inputYaml); + // Cursor is on the empty line at the end (line 4) + insertStepSnippet( + model as unknown as monaco.editor.ITextModel, + yamlDocument, + 'http', + new monaco.Position(4, 1) + ); + + expect(generateBuiltInStepSnippetSpy).toHaveBeenCalledWith('http', { + full: true, + withStepsSection: false, + }); + + const snippetText = generateBuiltInStepSnippetModule.generateBuiltInStepSnippet('http', { + full: true, + withStepsSection: false, + }); + // Should replace the empty line (line 4) with the step + expect(model.pushEditOperations).toHaveBeenCalledWith( + null, + [ + { + range: expect.any(monaco.Range), + text: prependIndentToLines(snippetText, 2), + }, + ], + expect.any(Function) + ); + }); + + it('should replace empty line right after steps: when cursor is on it', () => { + const inputYaml = `steps: + + - name: existing_step + type: http`; + const model = createFakeMonacoModel(inputYaml); + const yamlDocument = parseDocument(inputYaml); + // Cursor is on the empty line right after steps: (line 2) + insertStepSnippet( + model as unknown as monaco.editor.ITextModel, + yamlDocument, + 'http', + new monaco.Position(2, 1) + ); + + expect(generateBuiltInStepSnippetSpy).toHaveBeenCalledWith('http', { + full: true, + withStepsSection: false, + }); + + const snippetText = generateBuiltInStepSnippetModule.generateBuiltInStepSnippet('http', { + full: true, + withStepsSection: false, + }); + // Should replace the empty line (line 2) with the step + expect(model.pushEditOperations).toHaveBeenCalledWith( + null, + [ + { + range: expect.any(monaco.Range), + text: prependIndentToLines(snippetText, 2), + }, + ], + expect.any(Function) + ); + }); + + it('should handle steps with trailing spaces and comments', () => { + const inputYaml = `steps: + ### Hello world`; + const model = createFakeMonacoModel(inputYaml); + const yamlDocument = parseDocument(inputYaml); + insertStepSnippet(model as unknown as monaco.editor.ITextModel, yamlDocument, 'http'); + + expect(generateBuiltInStepSnippetSpy).toHaveBeenCalledWith('http', { + full: true, + withStepsSection: false, + }); + + const snippetText = generateBuiltInStepSnippetModule.generateBuiltInStepSnippet('http', { + full: true, + withStepsSection: false, + }); + + expect(model.pushEditOperations).toHaveBeenCalledWith( + null, + [ + { + range: expect.any(monaco.Range), + text: `\n${prependIndentToLines(snippetText, 2)}`, + }, + ], + expect.any(Function) + ); + }); + + describe('nested steps indent', () => { + it('should use nested foreach steps indent when inserting inside foreach steps block', () => { + const inputYaml = `steps: + - name: loop + type: foreach + foreach: "{{ x }}" + steps: + - name: inner + type: console`; + const model = createFakeMonacoModel(inputYaml); + const yamlDocument = parseDocument(inputYaml); + // Cursor on empty line after inner step (line 8) + insertStepSnippet( + model as unknown as monaco.editor.ITextModel, + yamlDocument, + 'http', + new monaco.Position(8, 1) + ); + + const snippetText = generateBuiltInStepSnippetModule.generateBuiltInStepSnippet('http', { + full: true, + withStepsSection: false, + }); + // Foreach steps: key at 4 spaces, so step indent 6 + expect(model.pushEditOperations).toHaveBeenCalledWith( + null, + [ + { + range: expect.any(monaco.Range), + text: prependIndentToLines(snippetText, 6), + }, + ], + expect.any(Function) + ); + }); + + it('should use nested if steps indent when inserting inside if step block', () => { + const inputYaml = `steps: + - name: check + type: if + condition: "x" + steps: + - name: then_step + type: console`; + const model = createFakeMonacoModel(inputYaml); + const yamlDocument = parseDocument(inputYaml); + // Cursor on empty line after then_step (line 8) + insertStepSnippet( + model as unknown as monaco.editor.ITextModel, + yamlDocument, + 'http', + new monaco.Position(8, 1) + ); + + const snippetText = generateBuiltInStepSnippetModule.generateBuiltInStepSnippet('http', { + full: true, + withStepsSection: false, + }); + // If steps: key at 4 spaces, so step indent 6 + expect(model.pushEditOperations).toHaveBeenCalledWith( + null, + [ + { + range: expect.any(monaco.Range), + text: prependIndentToLines(snippetText, 6), + }, + ], + expect.any(Function) + ); + }); + + it('should use nested if steps indent when inserting inside foreach then if step block', () => { + const inputYaml = `steps: + - name: loop + type: foreach + foreach: "{{ x }}" + steps: + - name: check + type: if + condition: "x" + steps: + - name: then_step + type: console`; + const model = createFakeMonacoModel(inputYaml); + const yamlDocument = parseDocument(inputYaml); + // Cursor on empty line after then_step (line 12), inside foreach -> if -> steps + insertStepSnippet( + model as unknown as monaco.editor.ITextModel, + yamlDocument, + 'http', + new monaco.Position(12, 1) + ); + + const snippetText = generateBuiltInStepSnippetModule.generateBuiltInStepSnippet('http', { + full: true, + withStepsSection: false, + }); + // If steps: key at 8 spaces (inside foreach), so step indent 10 + expect(model.pushEditOperations).toHaveBeenCalledWith( + null, + [ + { + range: expect.any(monaco.Range), + text: prependIndentToLines(snippetText, 10), + }, + ], + expect.any(Function) + ); + }); + }); }); diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/insert_step_snippet.ts b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/insert_step_snippet.ts index 0f20cfbfe3fe9..ce22181de592d 100644 --- a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/insert_step_snippet.ts +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/insert_step_snippet.ts @@ -7,85 +7,626 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { Document } from 'yaml'; +import { + type Document, + isMap, + isPair, + isScalar, + isSeq, + type Pair, + parseDocument, + visit, +} from 'yaml'; import { monaco } from '@kbn/monaco'; import { isBuiltInStepType } from '@kbn/workflows'; import { generateBuiltInStepSnippet } from './generate_builtin_step_snippet'; import { generateConnectorSnippet } from './generate_connector_snippet'; -import { getStepNodeAtPosition, getStepNodesWithType } from '../../../../../common/lib/yaml'; +import { + createReplacementRange, + findFirstEmptyItem, + findLastCommentLine, + getInsertRangeAndTextForSteps, + getSectionKeyInfo, +} from './snippet_insertion_utils'; +import { + getStepNodeAtPosition, + getStepNodesWithType, + getStepsAndElseKeyOffsets, + isStepLikeMap, +} from '../../../../../common/lib/yaml'; import { getIndentLevelFromLineNumber } from '../get_indent_level'; import { prependIndentToLines } from '../prepend_indent_to_lines'; import { getMonacoRangeFromYamlNode } from '../utils'; -// Algorithm: -// 1. If no cursor position is provided, find the next line after the last step node in root range -// 2. If cursor position is provided, get next line after the end of step node nearest to the cursor (including nested steps, e.g. foreach, if, etc.) -// 3. If no step nodes found, add "steps:" section in the first line of yaml +type StepNodeLike = ReturnType[number]; + +function getStepIndentForInsertLine( + document: Document, + model: monaco.editor.ITextModel, + insertLineNumber: number, + rootStepsKeyLineNumber: number +): number { + const { stepsKeyStartOffsets, elseKeyStartOffsets } = getStepsAndElseKeyOffsets(document); + const allKeyOffsets = [...stepsKeyStartOffsets, ...elseKeyStartOffsets]; + let owningKeyLine: number = rootStepsKeyLineNumber; + for (const keyOffset of allKeyOffsets) { + const keyLine = model.getPositionAt(keyOffset)?.lineNumber; + if (keyLine != null && keyLine <= insertLineNumber && keyLine > owningKeyLine) { + owningKeyLine = keyLine; + } + } + return getIndentLevelFromLineNumber(model, owningKeyLine) + 2; +} + +function getStepsAndElseKeyLines( + document: Document, + model: monaco.editor.ITextModel +): { stepsKeyLines: Set; elseKeyLines: Set } { + const { stepsKeyStartOffsets, elseKeyStartOffsets } = getStepsAndElseKeyOffsets(document); + const toLineSet = (offsets: number[]) => + new Set( + offsets + .map((off) => model.getPositionAt(off)?.lineNumber) + .filter((line): line is number => line != null) + ); + return { + stepsKeyLines: toLineSet(stepsKeyStartOffsets), + elseKeyLines: toLineSet(elseKeyStartOffsets), + }; +} + +function findStepWithMaxEndOffsetAtOrBefore( + nodes: StepNodeLike[], + model: monaco.editor.ITextModel, + cursorOffset: number +): StepNodeLike | null { + let best: StepNodeLike | null = null; + let bestEndOffset = -1; + for (const node of nodes) { + const range = getMonacoRangeFromYamlNode(model, node); + if (range) { + const endOffset = model.getOffsetAt( + new monaco.Position(range.endLineNumber, range.endColumn) + ); + if (endOffset <= cursorOffset && endOffset > bestEndOffset) { + bestEndOffset = endOffset; + best = node; + } + } + } + return best; +} + +function getStepsInInnermostBlockContainingCursor( + document: Document, + model: monaco.editor.ITextModel, + cursorOffset: number +): StepNodeLike[] { + const candidates: Array<{ seq: { items?: unknown[] }; rangeStart: number; rangeEnd: number }> = + []; + visit(document, { + Pair(_key, pair) { + if (!pair.key || !isScalar(pair.key)) return; + const keyVal = pair.key.value; + if (keyVal !== 'steps' && keyVal !== 'else') return; + const seq = pair.value; + if (!isSeq(seq) || !seq.range) return; + const [rangeStart, , rangeEnd] = seq.range as number[]; + if (cursorOffset >= rangeStart && cursorOffset <= rangeEnd) { + candidates.push({ seq: seq as { items?: unknown[] }, rangeStart, rangeEnd }); + } + }, + }); + if (candidates.length === 0) return []; + candidates.sort((a, b) => { + const spanA = a.rangeEnd - a.rangeStart; + const spanB = b.rangeEnd - b.rangeStart; + return spanA - spanB; + }); + const innermost = candidates[0].seq; + if (!innermost.items || innermost.items.length === 0) return []; + const steps: StepNodeLike[] = []; + for (const item of innermost.items) { + if (isStepLikeMap(item)) steps.push(item as StepNodeLike); + } + return steps; +} + +function getLastRootStepEndLine(model: monaco.editor.ITextModel, stepsPair: Pair): number | null { + const sequence = stepsPair.value; + if (!isSeq(sequence) || !sequence.items || sequence.items.length === 0) return null; + const lastItem = sequence.items[sequence.items.length - 1]; + if (!isStepLikeMap(lastItem)) return null; + const node = lastItem as { range?: number[] }; + if (!node.range) return null; + const pos = model.getPositionAt(node.range[2]); + return pos?.lineNumber ?? null; +} + +function getStepsPair(yamlDocument: Document): Pair | null { + if (!yamlDocument?.contents || !isMap(yamlDocument.contents)) { + return null; + } + + const contents = yamlDocument.contents; + if (!('items' in contents) || !contents.items) { + return null; + } + + const stepsPair = contents.items.find( + (item) => isPair(item) && isScalar(item.key) && item.key.value === 'steps' + ); + + return isPair(stepsPair) ? stepsPair : null; +} + +function getLastThenStepBeforeElseLine( + document: Document, + model: monaco.editor.ITextModel, + elseKeyLineNumber: number +): StepNodeLike | null { + let result: StepNodeLike | null = null; + visit(document, { + Pair(_key, pair, path) { + if (!pair.key || !isScalar(pair.key) || pair.key.value !== 'else') return; + const keyNode = pair.key as { range?: number[] }; + if (!keyNode.range) return; + const line = model.getPositionAt(keyNode.range[0])?.lineNumber; + if (line !== elseKeyLineNumber) return; + const pathEnd = path.length >= 1 ? path[path.length - 1] : null; + const parent = + pathEnd && isPair(pathEnd) && path.length >= 2 ? path[path.length - 2] : pathEnd; + if (!parent || !isMap(parent)) return; + if ((parent as { get?: (k: string) => unknown }).get?.('type') !== 'if') return; + const stepsSeq = (parent as { get?: (k: string) => unknown }).get?.('steps'); + if (!isSeq(stepsSeq) || !stepsSeq.items?.length) return; + const lastThen = stepsSeq.items[stepsSeq.items.length - 1]; + if (isMap(lastThen)) result = lastThen as StepNodeLike; + }, + }); + return result; +} + +function findStepNodeToInsertAfter( + document: Document, + model: monaco.editor.ITextModel, + cursorPosition: monaco.Position | null | undefined, + stepNodes: ReturnType, + stepsKeyRange: monaco.Range | null, + stepsKeyLines: Set, + elseKeyLines: Set +) { + if (cursorPosition && stepsKeyRange) { + const cursorLine = cursorPosition.lineNumber; + if (cursorLine < stepsKeyRange.startLineNumber) { + return null; + } + const isOnAnyStepsKeyLine = stepsKeyLines.has(cursorLine); + const isOnElseKeyLine = elseKeyLines.has(cursorLine); + const cursorLineContent = model.getLineContent(cursorLine); + const cursorLineTrimmed = cursorLineContent.trim(); + const isOnCommentLine = cursorLineTrimmed.startsWith('#'); + if (isOnAnyStepsKeyLine || isOnCommentLine) { + return null; + } + if (isOnElseKeyLine) { + return null; + } + if (elseKeyLines.has(cursorLine + 1)) { + const lastThen = getLastThenStepBeforeElseLine(document, model, cursorLine + 1); + if (lastThen) return lastThen; + } + + const stepAtCursor = getStepNodeAtPosition(document, model.getOffsetAt(cursorPosition)); + if (stepAtCursor) { + if (stepAtCursor.get('type') === 'if') { + const elsePair = stepAtCursor.items?.find( + (item): item is Pair => isPair(item) && isScalar(item.key) && item.key.value === 'else' + ); + const elseKeyRange = + elsePair?.key && typeof (elsePair.key as { range?: number[] }).range !== 'undefined' + ? (elsePair.key as { range: number[] }).range + : null; + const elseKeyLine = + elseKeyRange != null ? model.getPositionAt(elseKeyRange[0])?.lineNumber : null; + if (elseKeyLine != null && cursorLine < elseKeyLine) { + const stepsSeq = stepAtCursor.get('steps'); + if (isSeq(stepsSeq) && stepsSeq.items && stepsSeq.items.length > 0) { + const lastThenStep = stepsSeq.items[stepsSeq.items.length - 1]; + if (isMap(lastThenStep)) { + return lastThenStep; + } + } + } + } + const cursorOffset = model.getOffsetAt(cursorPosition); + const stepsInBlock = getStepsInInnermostBlockContainingCursor(document, model, cursorOffset); + if (stepsInBlock.length > 0) { + const stepRangeOfCursor = getMonacoRangeFromYamlNode(model, stepAtCursor); + const nestedStepsInsideCurrentStep = + stepRangeOfCursor && + stepsInBlock.every((s) => { + const r = getMonacoRangeFromYamlNode(model, s); + return ( + r && + r.startLineNumber >= stepRangeOfCursor.startLineNumber && + r.endLineNumber <= stepRangeOfCursor.endLineNumber + ); + }); + if (nestedStepsInsideCurrentStep) { + const bestStep = findStepWithMaxEndOffsetAtOrBefore(stepsInBlock, model, cursorOffset); + return bestStep ?? stepsInBlock[stepsInBlock.length - 1]; + } + } + return stepAtCursor; + } + + const cursorOffset = model.getOffsetAt(cursorPosition); + const stepsInBlock = getStepsInInnermostBlockContainingCursor(document, model, cursorOffset); + const nodesToConsider = stepsInBlock.length > 0 ? stepsInBlock : stepNodes; + const bestStep = findStepWithMaxEndOffsetAtOrBefore(nodesToConsider, model, cursorOffset); + if (bestStep) return bestStep; + if (stepsInBlock.length > 0) return stepsInBlock[stepsInBlock.length - 1]; + } + + return stepNodes.length > 0 ? stepNodes[stepNodes.length - 1] : null; +} + +function handleFlowArrayReplacement( + model: monaco.editor.ITextModel, + stepsPair: Pair, + stepsKeyRange: monaco.Range, + expectedIndent: number +): { replaceRange: monaco.Range | null; indentLevel: number; isReplacingFlowArray: boolean } { + const sequence = stepsPair.value; + if (!isSeq(sequence) || sequence.flow !== true || (sequence.items && sequence.items.length > 0)) { + return { replaceRange: null, indentLevel: 0, isReplacingFlowArray: false }; + } + + const sequenceRange = getMonacoRangeFromYamlNode(model, sequence); + if (!sequenceRange) { + return { replaceRange: null, indentLevel: 0, isReplacingFlowArray: false }; + } + + const replaceRange = + stepsKeyRange.startLineNumber === sequenceRange.startLineNumber + ? new monaco.Range( + stepsKeyRange.startLineNumber, + 1, + stepsKeyRange.startLineNumber, + model.getLineMaxColumn(stepsKeyRange.startLineNumber) + ) + : sequenceRange; + + return { + replaceRange, + indentLevel: expectedIndent, + isReplacingFlowArray: true, + }; +} + +function getInsertPointAfterStep( + model: monaco.editor.ITextModel, + stepNode: ReturnType[number], + cursorPosition: monaco.Position | null | undefined +): { insertAtLineNumber: number; indentLevel: number } | null { + const stepRange = getMonacoRangeFromYamlNode(model, stepNode); + if (!stepRange) { + return null; + } + + let insertAtLineNumber = stepRange.endLineNumber; + + if (cursorPosition) { + const { lineNumber: cursorLine, column: cursorColumn } = cursorPosition; + const cursorLineContent = model.getLineContent(cursorLine); + const cursorLineEmpty = cursorLineContent.trim() === ''; + const stepIndent = getIndentLevelFromLineNumber(model, stepRange.startLineNumber); + const cursorLineIndent = getIndentLevelFromLineNumber(model, cursorLine); + const isAfterStep = + cursorLine > stepRange.endLineNumber || + (cursorLine === stepRange.endLineNumber && cursorColumn > stepRange.endColumn); + if ( + cursorLineEmpty && + (cursorLineIndent >= stepIndent || cursorLine >= stepRange.startLineNumber) + ) { + insertAtLineNumber = cursorLine; + } else if (isAfterStep && (cursorLineEmpty || cursorLineIndent >= stepIndent)) { + insertAtLineNumber = cursorLine; + } + } + + return { + insertAtLineNumber, + indentLevel: getIndentLevelFromLineNumber(model, stepRange.startLineNumber), + }; +} + +function getDefaultInsertPoint( + document: Document, + model: monaco.editor.ITextModel, + stepsPair: Pair, + stepsKeyRange: monaco.Range, + expectedIndent: number, + cursorPosition: monaco.Position | null | undefined, + stepNodes: ReturnType, + stepsKeyLines: Set, + elseKeyLines: Set +): { + insertAtLineNumber: number; + indentLevel: number; + insertAfterComment: boolean; + commentCount?: number; +} { + const rootLine = stepsKeyRange.startLineNumber; + const atLine = (lineNum: number) => ({ + insertAtLineNumber: lineNum, + indentLevel: getStepIndentForInsertLine(document, model, lineNum, rootLine), + insertAfterComment: false, + }); + + if (cursorPosition) { + const cursorLine = cursorPosition.lineNumber; + if (cursorLine >= stepsKeyRange.startLineNumber) { + const cursorInsertPoint = getInsertPointFromCursor( + document, + model, + cursorPosition, + stepsKeyRange, + stepNodes, + stepsKeyLines, + elseKeyLines + ); + if (cursorInsertPoint) return cursorInsertPoint; + const insertAt = model.getLineContent(cursorLine).trim() === '' ? cursorLine : cursorLine + 1; + return atLine(insertAt); + } + } + + const lastCommentLine = findLastCommentLine(model, stepsKeyRange); + if (lastCommentLine) { + return { + insertAtLineNumber: lastCommentLine.lineNumber, + indentLevel: lastCommentLine.indentLevel, + commentCount: lastCommentLine.commentCount, + insertAfterComment: true, + }; + } + + const lastRootStepEndLine = getLastRootStepEndLine(model, stepsPair); + const insertAtLineNumber = + lastRootStepEndLine != null ? lastRootStepEndLine + 1 : stepsKeyRange.startLineNumber + 1; + return atLine(insertAtLineNumber); +} + +function getInsertPointFromCursor( + document: Document, + model: monaco.editor.ITextModel, + cursorPosition: monaco.Position, + stepsKeyRange: monaco.Range, + stepNodes: ReturnType, + stepsKeyLines: Set, + elseKeyLines: Set +): { + insertAtLineNumber: number; + indentLevel: number; + insertAfterComment: boolean; + commentCount?: number; +} | null { + const cursorLine = cursorPosition.lineNumber; + const rootLine = stepsKeyRange.startLineNumber; + const at = (insertAtLineNumber: number, insertAfterComment: boolean, commentCount?: number) => ({ + insertAtLineNumber, + indentLevel: getStepIndentForInsertLine(document, model, insertAtLineNumber, rootLine), + insertAfterComment, + ...(commentCount !== undefined && { commentCount }), + }); + + if (cursorLine < stepsKeyRange.startLineNumber) return null; + + const cursorLineContent = model.getLineContent(cursorLine); + const cursorLineTrimmed = cursorLineContent.trim(); + const isCursorLineEmpty = cursorLineTrimmed === ''; + const isCursorLineComment = cursorLineTrimmed.startsWith('#'); + const isOnAnyStepsKeyLine = stepsKeyLines.has(cursorLine); + + if (isOnAnyStepsKeyLine) return at(cursorLine + 1, false); + if (elseKeyLines.has(cursorLine)) return at(cursorLine + 1, false); + if (isCursorLineEmpty) return at(cursorLine, false); + if (isCursorLineComment) return at(cursorLine, true); + + if (stepNodes.length > 0) { + const lastStepNode = stepNodes[stepNodes.length - 1]; + const lastStepRange = getMonacoRangeFromYamlNode(model, lastStepNode); + if (lastStepRange && cursorLine >= lastStepRange.endLineNumber) { + const cursorLineIndent = getIndentLevelFromLineNumber(model, cursorLine); + const lastStepIndent = getIndentLevelFromLineNumber(model, lastStepRange.startLineNumber); + if (isCursorLineEmpty || cursorLineIndent >= lastStepIndent) return at(cursorLine, false); + } + } + + return at(cursorLine + 1, false); +} export function insertStepSnippet( model: monaco.editor.ITextModel, - yamlDocument: Document, + yamlDocument: Document | null, stepType: string, cursorPosition?: monaco.Position | null, editor?: monaco.editor.IStandaloneCodeEditor ) { - let snippetText = ''; - // we need it to be 1-indexed - const lastLineNumber = model.getLineCount(); - let insertStepsSection = false; - // by default, insert at line after the last line of the yaml file - let insertAtLineNumber = lastLineNumber + 1; - const stepNodes = getStepNodesWithType(yamlDocument); - let nearestStepNode = null; - if (cursorPosition) { - const absolutePosition = model.getOffsetAt(cursorPosition); - nearestStepNode = getStepNodeAtPosition(yamlDocument, absolutePosition); - } - const stepNode = - nearestStepNode || (stepNodes.length > 0 ? stepNodes[stepNodes.length - 1] : null); - let indentLevel = 0; - if (!stepNode) { - insertStepsSection = true; - indentLevel = 2; - } else { - const stepRange = getMonacoRangeFromYamlNode(model, stepNode); - if (stepRange) { - insertAtLineNumber = - stepRange.endLineNumber === lastLineNumber ? lastLineNumber + 1 : stepRange.endLineNumber; - // get indent from the first line of the step node - indentLevel = getIndentLevelFromLineNumber(model, stepRange.startLineNumber); + let document: Document; + try { + document = yamlDocument || parseDocument(model.getValue()); + } catch (error) { + return; + } + + const stepsPair = getStepsPair(document); + const stepNodes = getStepNodesWithType(document); + + if (!stepsPair) { + const lineCount = model.getLineCount(); + const insertText = isBuiltInStepType(stepType) + ? generateBuiltInStepSnippet(stepType, { full: true, withStepsSection: true }) + : generateConnectorSnippet(stepType, { full: true, withStepsSection: true }); + + if (editor) editor.pushUndoStop(); + + const insertAtLineNumber = lineCount > 0 ? lineCount + 1 : 1; + const { range, text } = getInsertRangeAndTextForSteps( + model, + null, + false, + insertAtLineNumber, + insertText + ); + + model.pushEditOperations(null, [{ range, text }], () => null); + if (editor) { + editor.pushUndoStop(); } + + return; } - if (isBuiltInStepType(stepType)) { - snippetText = generateBuiltInStepSnippet(stepType, { - full: true, - withStepsSection: insertStepsSection, - }); - } else { - snippetText = generateConnectorSnippet(stepType, { - full: true, - withStepsSection: insertStepsSection, - }); + const sectionInfo = getSectionKeyInfo(model, stepsPair); + if (!sectionInfo.range) { + return; } + const stepsKeyRange = sectionInfo.range; + const expectedIndent = sectionInfo.indentLevel; + const { stepsKeyLines, elseKeyLines } = getStepsAndElseKeyLines(document, model); - // Create separate undo boundary for each snippet insertion - if (editor) { - editor.pushUndoStop(); + const stepNode = findStepNodeToInsertAfter( + document, + model, + cursorPosition, + stepNodes, + stepsKeyRange, + stepsKeyLines, + elseKeyLines + ); + + const replacement = handleFlowArrayReplacement(model, stepsPair, stepsKeyRange, expectedIndent); + const { isReplacingFlowArray } = replacement; + let { replaceRange, indentLevel } = replacement; + + if (!replaceRange) { + const firstEmptyItem = findFirstEmptyItem(model, stepsPair); + if (firstEmptyItem) { + replaceRange = createReplacementRange(model, firstEmptyItem.lineNumber); + indentLevel = firstEmptyItem.indentLevel; + } + } + + let insertAtLineNumber = 1; + let insertAfterComment = false; + let commentCount: number | undefined; + + if (!replaceRange) { + if (stepNode) { + const insertPoint = getInsertPointAfterStep(model, stepNode, cursorPosition); + if (insertPoint) { + insertAtLineNumber = insertPoint.insertAtLineNumber; + indentLevel = insertPoint.indentLevel; + insertAfterComment = true; + } + } else { + const defaultPoint = getDefaultInsertPoint( + document, + model, + stepsPair, + stepsKeyRange, + expectedIndent, + cursorPosition, + stepNodes, + stepsKeyLines, + elseKeyLines + ); + insertAtLineNumber = defaultPoint.insertAtLineNumber; + indentLevel = defaultPoint.indentLevel; + insertAfterComment = defaultPoint.insertAfterComment; + commentCount = defaultPoint.commentCount; + } } - model.pushEditOperations( - null, - [ - { - range: new monaco.Range(insertAtLineNumber, 1, insertAtLineNumber, 1), - // if we are inserting the steps section, we don't need to indent the snippet, the step is at root level - text: insertStepsSection ? snippetText : prependIndentToLines(snippetText, indentLevel), - }, - ], - () => null + const snippetText = isBuiltInStepType(stepType) + ? generateBuiltInStepSnippet(stepType, { full: true, withStepsSection: false }) + : generateConnectorSnippet(stepType, { full: true, withStepsSection: false }); + + if (editor) editor.pushUndoStop(); + + if ( + cursorPosition && + cursorPosition.lineNumber >= stepsKeyRange.startLineNumber && + model.getLineContent(cursorPosition.lineNumber).trim() === '' + ) { + insertAtLineNumber = cursorPosition.lineNumber; + insertAfterComment = true; + } + + const insertLineForIndent = replaceRange ? replaceRange.startLineNumber : insertAtLineNumber; + indentLevel = getStepIndentForInsertLine( + document, + model, + insertLineForIndent, + stepsKeyRange.startLineNumber ); + const insertText = prependIndentToLines(snippetText, indentLevel); + + const finalInsertText = + replaceRange && isReplacingFlowArray && stepsKeyRange + ? replaceRange.startLineNumber === stepsKeyRange.startLineNumber && + replaceRange.endLineNumber === stepsKeyRange.startLineNumber + ? `steps:\n${insertText}` + : `\n${insertText}` + : insertText; + + const cursorOnElseLine = + cursorPosition !== undefined && + cursorPosition !== null && + elseKeyLines.has(cursorPosition.lineNumber); + + if ( + !replaceRange && + !stepNode && + insertAtLineNumber >= 1 && + !cursorOnElseLine && + cursorPosition + ) { + const lineToCheck = insertAfterComment ? insertAtLineNumber + 1 : insertAtLineNumber; + const atElseKeyLine = elseKeyLines.has(lineToCheck); + const cursorOffset = model.getOffsetAt(cursorPosition); + const stepsInBlock = getStepsInInnermostBlockContainingCursor(document, model, cursorOffset); + const stepStartLines = new Set( + stepsInBlock + .map((s) => getMonacoRangeFromYamlNode(model, s)?.startLineNumber) + .filter((line): line is number => line != null) + ); + const atFirstStepOfElseBlock = + lineToCheck > 1 && elseKeyLines.has(lineToCheck - 1) && stepStartLines.has(lineToCheck); + if (atElseKeyLine || atFirstStepOfElseBlock) { + insertAtLineNumber = atElseKeyLine ? lineToCheck : lineToCheck - 1; + insertAfterComment = false; + } + } + + const { range, text } = getInsertRangeAndTextForSteps( + model, + replaceRange, + insertAfterComment, + insertAtLineNumber, + finalInsertText, + commentCount, + isReplacingFlowArray, + cursorOnElseLine, + elseKeyLines + ); + + model.pushEditOperations(null, [{ range, text }], () => null); + if (editor) { editor.pushUndoStop(); } diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/insert_trigger_snippet.ts b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/insert_trigger_snippet.ts index 0ad21cc5ba85b..f97ddeb42d27d 100644 --- a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/insert_trigger_snippet.ts +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/insert_trigger_snippet.ts @@ -7,248 +7,22 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { type Document, isNode, parseDocument, type Range } from 'yaml'; -import { isMap, isPair, isScalar, isSeq } from 'yaml'; -import { monaco } from '@kbn/monaco'; +import { type Document, parseDocument } from 'yaml'; +import { isSeq } from 'yaml'; +import type { monaco } from '@kbn/monaco'; import type { TriggerType } from '@kbn/workflows'; import { generateTriggerSnippet } from './generate_trigger_snippet'; +import { + createReplacementRange, + findFirstEmptyItem, + findLastCommentLine, + getInsertRangeAndTextForTriggers, + getSectionKeyInfo, +} from './snippet_insertion_utils'; import { getTriggerNodes, getTriggersPair } from '../../../../../common/lib/yaml'; import { getIndentLevelFromLineNumber } from '../get_indent_level'; import { prependIndentToLines } from '../prepend_indent_to_lines'; -import { getMonacoRangeFromYamlNode, getMonacoRangeFromYamlRange } from '../utils'; - -/** - * Gets the triggers key range and calculates the expected indent level for array items - */ -function getTriggersKeyInfo( - model: monaco.editor.ITextModel, - triggersPair: ReturnType -): { range: monaco.Range | null; indentLevel: number } { - if (!triggersPair?.key) { - return { range: null, indentLevel: 2 }; - } - - const triggersKey = triggersPair.key; - const triggersKeyRange = - isNode(triggersKey) && triggersKey.range - ? getMonacoRangeFromYamlRange(model, triggersKey.range as Range) - : null; - - const indentLevel = triggersKeyRange - ? getIndentLevelFromLineNumber(model, triggersKeyRange.startLineNumber) + 2 - : 2; - - return { range: triggersKeyRange, indentLevel }; -} - -/** - * Creates a replacement range for an empty item line - */ -function createReplacementRange(model: monaco.editor.ITextModel, lineNumber: number): monaco.Range { - const nextLineNumber = lineNumber + 1; - if (nextLineNumber <= model.getLineCount()) { - return new monaco.Range(lineNumber, 1, nextLineNumber, 1); - } - const lineEndColumn = model.getLineMaxColumn(lineNumber); - return new monaco.Range(lineNumber, 1, lineNumber, lineEndColumn); -} - -/** - * Checks if a YAML node represents an empty item - * An empty item is one that doesn't have a 'type' field, which is required for triggers - */ -function isEmptyItem(item: unknown): boolean { - if (!item) { - return true; - } - - if (isNode(item) && isScalar(item)) { - const value = item.value; - return value === null || value === undefined || value === ''; - } - - if (isNode(item) && isMap(item)) { - if (!('items' in item) || !item.items || item.items.length === 0) { - return true; - } - - const hasTypeField = item.items.some( - (pairItem) => isPair(pairItem) && isScalar(pairItem.key) && pairItem.key.value === 'type' - ); - return !hasTypeField; - } - - return false; -} - -/** - * Finds the last comment line in the triggers section - */ -function findLastCommentLine( - model: monaco.editor.ITextModel, - triggersPair: ReturnType, - triggersKeyRange: monaco.Range | null -): { lineNumber: number; indentLevel: number; commentCount: number } | null { - if (!triggersKeyRange) { - return null; - } - - const startLine = triggersKeyRange.endLineNumber + 1; - const maxLines = model.getLineCount(); - const triggersIndent = triggersKeyRange.startColumn - 1; - let lastCommentLine: number | null = null; - let commentCount = 0; - - for (let lineNum = startLine; lineNum <= maxLines; lineNum++) { - const lineContent = model.getLineContent(lineNum); - const trimmed = lineContent.trim(); - - const lineIndent = getIndentLevelFromLineNumber(model, lineNum); - if (trimmed && lineIndent <= triggersIndent) { - break; - } - - if (trimmed) { - if (trimmed.charAt(0) === '#') { - lastCommentLine = lineNum; - commentCount++; - } else { - return null; - } - } - } - - if (lastCommentLine !== null) { - const indentLevel = getIndentLevelFromLineNumber(model, lastCommentLine); - return { lineNumber: lastCommentLine, indentLevel, commentCount }; - } - - return null; -} - -/** - * Determines the insertion range and modifies the insert text based on the insertion context - */ -function getInsertRangeAndText( - model: monaco.editor.ITextModel, - replaceRange: monaco.Range | null, - insertAfterComment: boolean, - insertAtLineNumber: number, - insertText: string, - commentCount?: number, - isReplacingFlowArray?: boolean -): { range: monaco.Range; text: string } { - if (replaceRange) { - // When replacing a flow-style empty array ([]), prepend newline to start on a new line - const text = isReplacingFlowArray ? `\n${insertText}` : insertText; - return { range: replaceRange, text }; - } - - if (insertAfterComment) { - // If there are multiple consecutive comments, insert at a new line after all comments - if (commentCount && commentCount > 1) { - const nextLineNumber = insertAtLineNumber + 1; - if (nextLineNumber <= model.getLineCount()) { - const nextLineContent = model.getLineContent(nextLineNumber); - if (nextLineContent.trim() === '') { - const lineAfterNext = nextLineNumber + 1; - if (lineAfterNext > model.getLineCount()) { - const range = new monaco.Range(lineAfterNext, 1, lineAfterNext, 1); - return { range, text: insertText }; - } - const lineAfterNextContent = model.getLineContent(lineAfterNext); - if (lineAfterNextContent.trim() === '') { - const range = new monaco.Range(lineAfterNext, 1, lineAfterNext, 1); - return { range, text: insertText }; - } - } - } - } - // Single comment/trigger or no special handling needed - const currentLineContent = model.getLineContent(insertAtLineNumber); - const nextLineNumber = insertAtLineNumber + 1; - - // If the next line exists and is empty, replace it to avoid extra blank lines - if (nextLineNumber <= model.getLineCount()) { - const nextLineContent = model.getLineContent(nextLineNumber); - if (nextLineContent.trim() === '') { - const lineAfterNext = nextLineNumber + 1; - if (lineAfterNext <= model.getLineCount()) { - const range = new monaco.Range(nextLineNumber, 1, lineAfterNext, 1); - return { range, text: insertText }; - } else { - const emptyLineEndColumn = model.getLineMaxColumn(nextLineNumber); - const range = new monaco.Range(nextLineNumber, 1, nextLineNumber, emptyLineEndColumn); - return { range, text: insertText }; - } - } - } - - const lineEndColumn = model.getLineMaxColumn(insertAtLineNumber); - const range = new monaco.Range( - insertAtLineNumber, - lineEndColumn, - insertAtLineNumber, - lineEndColumn - ); - const text = currentLineContent.trim() ? `\n${insertText}` : insertText; - return { range, text }; - } - - if (insertAtLineNumber > model.getLineCount()) { - const lastLineNumber = model.getLineCount(); - const lastLineEndColumn = model.getLineMaxColumn(lastLineNumber); - const range = new monaco.Range( - lastLineNumber, - lastLineEndColumn, - lastLineNumber, - lastLineEndColumn - ); - return { range, text: `\n${insertText}` }; - } - - const range = new monaco.Range(insertAtLineNumber, 1, insertAtLineNumber, 1); - const targetLine = model.getLineContent(insertAtLineNumber); - if (targetLine.trim()) { - return { range, text: `${insertText}\n` }; - } - - return { range, text: insertText }; -} - -/** - * Finds the first empty item in the triggers sequence - * Returns null if no empty items are found - */ -function findFirstEmptyItem( - model: monaco.editor.ITextModel, - triggersPair: ReturnType -): { lineNumber: number; indentLevel: number } | null { - if (!triggersPair) { - return null; - } - - if (!triggersPair.value || !isSeq(triggersPair.value)) { - return null; - } - - const sequence = triggersPair.value; - if (!sequence.items || sequence.items.length === 0) { - return null; - } - - for (const item of sequence.items) { - if (isEmptyItem(item) && isNode(item) && item.range) { - const itemRange = getMonacoRangeFromYamlRange(model, item.range as Range); - if (itemRange) { - const indentLevel = getIndentLevelFromLineNumber(model, itemRange.startLineNumber); - return { lineNumber: itemRange.startLineNumber, indentLevel }; - } - } - } - - return null; -} +import { getMonacoRangeFromYamlNode } from '../utils'; // Algorithm: // 1. Check if triggers section exists (even if empty or has empty items) @@ -286,10 +60,7 @@ export function insertTriggerSnippet( if (triggersPair) { insertTriggersSection = false; - const { range: keyRange, indentLevel: expectedIndent } = getTriggersKeyInfo( - model, - triggersPair - ); + const { range: keyRange, indentLevel: expectedIndent } = getSectionKeyInfo(model, triggersPair); triggersKeyRange = keyRange; // Check if triggers is a flow-style empty array (triggers: []) @@ -321,7 +92,7 @@ export function insertTriggerSnippet( insertAfterComment = true; } } else if (triggersKeyRange) { - const lastCommentLine = findLastCommentLine(model, triggersPair, triggersKeyRange); + const lastCommentLine = findLastCommentLine(model, triggersKeyRange); if (lastCommentLine) { insertAtLineNumber = lastCommentLine.lineNumber; indentLevel = lastCommentLine.indentLevel; @@ -350,7 +121,7 @@ export function insertTriggerSnippet( ? triggerSnippet : prependIndentToLines(triggerSnippet, indentLevel); - const { range: insertRange, text: finalInsertText } = getInsertRangeAndText( + const { range, text } = getInsertRangeAndTextForTriggers( model, replaceRange, insertAfterComment, @@ -360,16 +131,7 @@ export function insertTriggerSnippet( isReplacingFlowArray ); - model.pushEditOperations( - null, - [ - { - range: insertRange, - text: finalInsertText, - }, - ], - () => null - ); + model.pushEditOperations(null, [{ range, text }], () => null); if (editor) { editor.pushUndoStop(); diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/snippet_insertion_utils.ts b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/snippet_insertion_utils.ts new file mode 100644 index 0000000000000..8f975016fd331 --- /dev/null +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/snippet_insertion_utils.ts @@ -0,0 +1,409 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { isMap, isNode, isPair, isScalar, isSeq, type Pair, type Range } from 'yaml'; +import { monaco } from '@kbn/monaco'; +import { getIndentLevelFromLineNumber } from '../get_indent_level'; +import { getMonacoRangeFromYamlRange } from '../utils'; + +function removeTrailingNewlines(text: string): string { + let end = text.length; + while (end > 0 && text[end - 1] === '\n') { + end--; + } + return text.slice(0, end); +} + +function getLineContent(model: monaco.editor.ITextModel, lineNumber: number): string | null { + if (lineNumber > model.getLineCount()) { + return null; + } + return model.getLineContent(lineNumber); +} + +function isCommentLine(lineContent: string | null): boolean { + return lineContent !== null && lineContent.trim().startsWith('#'); +} + +function isElseKeyLine(lineContent: string | null): boolean { + if (!lineContent) return false; + const t = lineContent.trim(); + return t === 'else:' || /^else:\s*/.test(t); +} + +function replaceLineWithStepAndKeepLine( + model: monaco.editor.ITextModel, + lineNum: number, + insertText: string, + lineContent: string | null +): { range: monaco.Range; text: string } { + const normalizedText = removeTrailingNewlines(insertText); + const range = + lineNum < model.getLineCount() + ? new monaco.Range(lineNum, 1, lineNum + 1, 1) + : new monaco.Range(lineNum, 1, lineNum, model.getLineMaxColumn(lineNum)); + return { range, text: `${normalizedText}\n${lineContent ?? ''}\n` }; +} + +function handleInsertAfterComment( + model: monaco.editor.ITextModel, + insertAtLineNumber: number, + insertText: string, + addTrailingNewline: boolean +): { range: monaco.Range; text: string } { + const nextLineNumber = insertAtLineNumber + 1; + const nextLineContent = getLineContent(model, nextLineNumber); + + if (nextLineContent === null) { + const lineEndColumn = model.getLineMaxColumn(insertAtLineNumber); + const range = new monaco.Range( + insertAtLineNumber, + lineEndColumn, + insertAtLineNumber, + lineEndColumn + ); + return { range, text: `\n${insertText}` }; + } + + if (nextLineContent.trim() === '') { + const lineAfterNext = nextLineNumber + 1; + const lineAfterNextContent = getLineContent(model, lineAfterNext); + if (lineAfterNextContent === null || lineAfterNextContent.trim() === '') { + const emptyLineEndColumn = model.getLineMaxColumn(nextLineNumber); + const range = new monaco.Range(nextLineNumber, 1, nextLineNumber, emptyLineEndColumn); + return { range, text: insertText }; + } + const range = new monaco.Range(nextLineNumber, 1, lineAfterNext, 1); + return { range, text: insertText }; + } + + const normalizedText = removeTrailingNewlines(insertText); + const range = new monaco.Range(nextLineNumber, 1, nextLineNumber, 1); + return { range, text: addTrailingNewline ? `${normalizedText}\n` : normalizedText }; +} + +function handleInsertAfterItem( + model: monaco.editor.ITextModel, + insertAtLineNumber: number, + insertText: string, + addTrailingNewline: boolean +): { range: monaco.Range; text: string } { + const nextLineNumber = insertAtLineNumber + 1; + const range = new monaco.Range(nextLineNumber, 1, nextLineNumber, 1); + const nextLineContent = getLineContent(model, nextLineNumber); + + if (nextLineContent && nextLineContent.trim()) { + const normalizedText = removeTrailingNewlines(insertText); + return { range, text: addTrailingNewline ? `${normalizedText}\n` : normalizedText }; + } + + return { range, text: insertText }; +} + +export function createReplacementRange( + model: monaco.editor.ITextModel, + lineNumber: number +): monaco.Range { + const nextLineNumber = lineNumber + 1; + if (nextLineNumber <= model.getLineCount()) { + return new monaco.Range(lineNumber, 1, nextLineNumber, 1); + } + const lineEndColumn = model.getLineMaxColumn(lineNumber); + return new monaco.Range(lineNumber, 1, lineNumber, lineEndColumn); +} + +export function isEmptyItem(item: unknown): boolean { + if (!item) { + return true; + } + + if (isNode(item) && isScalar(item)) { + const value = item.value; + return value === null || value === undefined || value === ''; + } + + if (isNode(item) && isMap(item)) { + if (!('items' in item) || !item.items || item.items.length === 0) { + return true; + } + + const hasTypeField = item.items.some( + (pairItem) => isPair(pairItem) && isScalar(pairItem.key) && pairItem.key.value === 'type' + ); + return !hasTypeField; + } + + return false; +} + +export function findLastCommentLine( + model: monaco.editor.ITextModel, + sectionKeyRange: monaco.Range | null +): { lineNumber: number; indentLevel: number; commentCount: number } | null { + if (!sectionKeyRange) { + return null; + } + + const startLine = sectionKeyRange.endLineNumber + 1; + const maxLines = model.getLineCount(); + const sectionIndent = sectionKeyRange.startColumn - 1; + let lastCommentLine: number | null = null; + let commentCount = 0; + + for (let lineNum = startLine; lineNum <= maxLines; lineNum++) { + const lineContent = getLineContent(model, lineNum); + if (!lineContent) break; + + const trimmed = lineContent.trim(); + if (trimmed) { + const lineIndent = getIndentLevelFromLineNumber(model, lineNum); + if (lineIndent <= sectionIndent) { + break; + } + + if (trimmed.charAt(0) === '#') { + lastCommentLine = lineNum; + commentCount++; + } else { + return null; + } + } + } + + if (lastCommentLine !== null) { + const indentLevel = getIndentLevelFromLineNumber(model, lastCommentLine); + return { lineNumber: lastCommentLine, indentLevel, commentCount }; + } + + return null; +} + +export function getInsertRangeAndTextForTriggers( + model: monaco.editor.ITextModel, + replaceRange: monaco.Range | null, + insertAfterComment: boolean, + insertAtLineNumber: number, + insertText: string, + commentCount?: number, + isReplacingFlowArray?: boolean +): { range: monaco.Range; text: string } { + if (replaceRange) { + const text = isReplacingFlowArray ? `\n${insertText}` : insertText; + return { range: replaceRange, text }; + } + + if (insertAfterComment) { + if (insertAtLineNumber >= model.getLineCount()) { + const range = new monaco.Range(insertAtLineNumber, 1, insertAtLineNumber, 1); + return { range, text: insertText }; + } + + const currentLineContent = getLineContent(model, insertAtLineNumber); + if (isCommentLine(currentLineContent)) { + if (commentCount && commentCount > 1) { + const nextLineNumber = insertAtLineNumber + 1; + const nextLineContent = getLineContent(model, nextLineNumber); + if (nextLineContent !== null && nextLineContent.trim() === '') { + const lineAfterNext = nextLineNumber + 1; + const lineAfterNextContent = getLineContent(model, lineAfterNext); + if (lineAfterNextContent === null) { + const range = new monaco.Range(lineAfterNext, 1, lineAfterNext, 1); + return { range, text: insertText }; + } + if (lineAfterNextContent.trim() === '') { + const range = new monaco.Range(lineAfterNext, 1, lineAfterNext, 1); + return { range, text: insertText }; + } + } + } + return handleInsertAfterComment(model, insertAtLineNumber, insertText, false); + } else { + return handleInsertAfterItem(model, insertAtLineNumber, insertText, false); + } + } + + if (insertAtLineNumber > model.getLineCount()) { + if (insertAtLineNumber === model.getLineCount() + 1) { + const lastLineNumber = model.getLineCount(); + const lastLineEndColumn = model.getLineMaxColumn(lastLineNumber); + const range = new monaco.Range( + lastLineNumber, + lastLineEndColumn, + lastLineNumber, + lastLineEndColumn + ); + return { range, text: `\n${insertText}` }; + } + const range = new monaco.Range(insertAtLineNumber, 1, insertAtLineNumber, 1); + return { range, text: insertText }; + } + + const range = new monaco.Range(insertAtLineNumber, 1, insertAtLineNumber, 1); + const targetLine = getLineContent(model, insertAtLineNumber); + if (targetLine && targetLine.trim()) { + return { range, text: `${insertText}\n` }; + } + + return { range, text: insertText }; +} + +export function getInsertRangeAndTextForSteps( + model: monaco.editor.ITextModel, + replaceRange: monaco.Range | null, + insertAfterComment: boolean, + insertAtLineNumber: number, + insertText: string, + commentCount?: number, + isReplacingFlowArray?: boolean, + insertInElseBlock?: boolean, + elseKeyLines?: Set +): { range: monaco.Range; text: string } { + const isLineElseKey = (lineNum: number) => + elseKeyLines?.has(lineNum) ?? isElseKeyLine(getLineContent(model, lineNum)); + + if (replaceRange) { + const text = + isReplacingFlowArray && !insertText.startsWith('steps:\n') ? `\n${insertText}` : insertText; + return { range: replaceRange, text }; + } + + if (insertAfterComment) { + if (insertAtLineNumber > model.getLineCount()) { + const range = new monaco.Range(insertAtLineNumber, 1, insertAtLineNumber, 1); + return { range, text: insertText }; + } + + if (isLineElseKey(insertAtLineNumber)) { + return replaceLineWithStepAndKeepLine( + model, + insertAtLineNumber, + insertText, + getLineContent(model, insertAtLineNumber) + ); + } + + const nextLineNumber = insertAtLineNumber + 1; + if (isLineElseKey(nextLineNumber)) { + return replaceLineWithStepAndKeepLine( + model, + nextLineNumber, + insertText, + nextLineNumber <= model.getLineCount() ? getLineContent(model, nextLineNumber) : null + ); + } + + const currentLineContent = getLineContent(model, insertAtLineNumber); + if (isCommentLine(currentLineContent)) { + return handleInsertAfterComment(model, insertAtLineNumber, insertText, true); + } + if (!currentLineContent || !currentLineContent.trim()) { + if (insertAtLineNumber < model.getLineCount()) { + const range = new monaco.Range(insertAtLineNumber, 1, insertAtLineNumber + 1, 1); + return { range, text: insertText }; + } + const lineEndColumn = model.getLineMaxColumn(insertAtLineNumber); + const range = new monaco.Range(insertAtLineNumber, 1, insertAtLineNumber, lineEndColumn); + return { range, text: insertText }; + } + return handleInsertAfterItem(model, insertAtLineNumber, insertText, true); + } + + if (insertAtLineNumber > model.getLineCount()) { + const range = new monaco.Range(insertAtLineNumber, 1, insertAtLineNumber, 1); + return { range, text: insertText }; + } + + const targetLine = getLineContent(model, insertAtLineNumber); + + if (targetLine && targetLine.trim()) { + const normalizedText = removeTrailingNewlines(insertText); + const trimmed = targetLine.trim(); + const isComment = trimmed.startsWith('#'); + if (isLineElseKey(insertAtLineNumber)) { + return replaceLineWithStepAndKeepLine(model, insertAtLineNumber, insertText, targetLine); + } + if (!insertInElseBlock) { + const prevLineNumber = insertAtLineNumber - 1; + if (prevLineNumber >= 1 && isLineElseKey(prevLineNumber)) { + return replaceLineWithStepAndKeepLine( + model, + prevLineNumber, + insertText, + getLineContent(model, prevLineNumber) + ); + } + } + if (isComment) { + const lineEndColumn = model.getLineMaxColumn(insertAtLineNumber); + const range = new monaco.Range(insertAtLineNumber, 1, insertAtLineNumber, lineEndColumn); + return { range, text: `${normalizedText}\n${targetLine}\n` }; + } + const range = new monaco.Range(insertAtLineNumber, 1, insertAtLineNumber, 1); + return { range, text: `${normalizedText}\n` }; + } + + if (insertAtLineNumber < model.getLineCount()) { + const range = new monaco.Range(insertAtLineNumber, 1, insertAtLineNumber + 1, 1); + return { range, text: insertText }; + } + const lineEndColumn = model.getLineMaxColumn(insertAtLineNumber); + const range = new monaco.Range(insertAtLineNumber, 1, insertAtLineNumber, lineEndColumn); + return { range, text: insertText }; +} + +export function findFirstEmptyItem( + model: monaco.editor.ITextModel, + sectionPair: Pair | null +): { lineNumber: number; indentLevel: number } | null { + if (!sectionPair) { + return null; + } + + if (!sectionPair.value || !isSeq(sectionPair.value)) { + return null; + } + + const sequence = sectionPair.value; + if (!sequence.items || sequence.items.length === 0) { + return null; + } + + for (const item of sequence.items) { + if (isEmptyItem(item) && isNode(item) && item.range) { + const itemRange = getMonacoRangeFromYamlRange(model, item.range as Range); + if (itemRange) { + const indentLevel = getIndentLevelFromLineNumber(model, itemRange.startLineNumber); + return { lineNumber: itemRange.startLineNumber, indentLevel }; + } + } + } + + return null; +} + +export function getSectionKeyInfo( + model: monaco.editor.ITextModel, + sectionPair: Pair | null +): { range: monaco.Range | null; indentLevel: number } { + if (!sectionPair?.key) { + return { range: null, indentLevel: 2 }; + } + + const sectionKey = sectionPair.key; + const sectionKeyRange = + isNode(sectionKey) && sectionKey.range + ? getMonacoRangeFromYamlRange(model, sectionKey.range as Range) + : null; + + const indentLevel = sectionKeyRange + ? getIndentLevelFromLineNumber(model, sectionKeyRange.startLineNumber) + 2 + : 2; + + return { range: sectionKeyRange, indentLevel }; +} diff --git a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/ui/workflow_yaml_editor.tsx b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/ui/workflow_yaml_editor.tsx index 2910c832e03d8..26db5e824056a 100644 --- a/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/ui/workflow_yaml_editor.tsx +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/ui/workflow_yaml_editor.tsx @@ -470,9 +470,6 @@ export const WorkflowYAMLEditor = ({ if (isTriggerType(action.id)) { insertTriggerSnippet(model, yamlDocumentCurrent, action.id, editor); } else { - if (!yamlDocumentCurrent) { - return; - } insertStepSnippet(model, yamlDocumentCurrent, action.id, cursorPosition, editor); } closeActionsPopover();