From 378615a56dfb2305ce6f56dc27c8e649b856b436 Mon Sep 17 00:00:00 2001 From: Yngrid Coello Date: Thu, 15 Jan 2026 17:59:50 +0100 Subject: [PATCH 1/7] Fixing insert_step_snippet --- .../lib/snippets/insert_step_snippet.test.ts | 441 +++++++++++++++++- .../lib/snippets/insert_step_snippet.ts | 424 ++++++++++++++--- .../lib/snippets/insert_trigger_snippet.ts | 263 +---------- .../lib/snippets/snippet_insertion_utils.ts | 361 ++++++++++++++ .../ui/workflow_yaml_editor.tsx | 3 - 5 files changed, 1183 insertions(+), 309 deletions(-) create mode 100644 src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/snippet_insertion_utils.ts 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..a669505e8872f 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 @@ -159,7 +159,7 @@ steps: null, [ { - range: new monaco.Range(11, 1, 11, 1), + range: new monaco.Range(12, 1, 12, 1), text: prependIndentToLines(snippetText, 6), }, ], @@ -205,4 +205,443 @@ 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 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) + ); + }); }); 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..c5f61000da45f 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,397 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import type { Document } 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 { type Document, type Pair, isMap, isPair, isScalar, isSeq, parseDocument } from 'yaml'; import { getStepNodeAtPosition, getStepNodesWithType } from '../../../../../common/lib/yaml'; import { getIndentLevelFromLineNumber } from '../get_indent_level'; import { prependIndentToLines } from '../prepend_indent_to_lines'; import { getMonacoRangeFromYamlNode } from '../utils'; +import { generateBuiltInStepSnippet } from './generate_builtin_step_snippet'; +import { generateConnectorSnippet } from './generate_connector_snippet'; +import { + createReplacementRange, + findFirstEmptyItem, + findLastCommentLine, + getInsertRangeAndTextForSteps, + getSectionKeyInfo, +} from './snippet_insertion_utils'; + +/** + * Finds the steps pair in the YAML document, even if it's empty or has empty items + * @returns The steps pair if found, null otherwise + */ +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; +} + + +/** + * Finds the step node to insert after based on cursor position or last step + */ +function findStepNodeToInsertAfter( + document: Document, + model: monaco.editor.ITextModel, + cursorPosition: monaco.Position | null | undefined, + stepNodes: ReturnType, + stepsKeyRange: monaco.Range | null +) { + if (cursorPosition && stepsKeyRange) { + const cursorLine = cursorPosition.lineNumber; + const cursorLineContent = model.getLineContent(cursorLine); + const cursorLineTrimmed = cursorLineContent.trim(); + const isOnStepsKeyLine = cursorLine === stepsKeyRange.startLineNumber; + const isOnCommentLine = cursorLineTrimmed.startsWith('#'); + const isOnEmptyLine = cursorLineTrimmed === ''; + + if (isOnStepsKeyLine || isOnCommentLine || isOnEmptyLine) { + return null; + } + + const stepAtCursor = getStepNodeAtPosition(document, model.getOffsetAt(cursorPosition)); + if (stepAtCursor) { + return stepAtCursor; + } + } + + return stepNodes.length > 0 ? stepNodes[stepNodes.length - 1] : null; +} + +/** + * Handles flow-style empty array replacement (steps: []) + */ +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, + }; +} + +/** + * Determines insertion point after a step node + */ +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 isAfterStep = cursorLine > stepRange.endLineNumber || + (cursorLine === stepRange.endLineNumber && cursorColumn > stepRange.endColumn); + if (isAfterStep && cursorLine <= stepRange.endLineNumber + 10) { + insertAtLineNumber = cursorLine; + } + } + + return { + insertAtLineNumber, + indentLevel: getIndentLevelFromLineNumber(model, stepRange.startLineNumber), + }; +} + +/** + * Determines insertion point when no step node is found + */ +function getDefaultInsertPoint( + model: monaco.editor.ITextModel, + stepsKeyRange: monaco.Range, + expectedIndent: number, + cursorPosition: monaco.Position | null | undefined, + stepNodes: ReturnType +): { + insertAtLineNumber: number; + indentLevel: number; + insertAfterComment: boolean; + commentCount?: number; +} { + if (cursorPosition) { + const cursorLine = cursorPosition.lineNumber; + if (cursorLine >= stepsKeyRange.startLineNumber) { + const cursorInsertPoint = getInsertPointFromCursor( + model, + cursorPosition, + stepsKeyRange, + stepNodes + ); + if (cursorInsertPoint) { + return cursorInsertPoint; + } + const cursorLineContent = model.getLineContent(cursorLine); + const cursorLineTrimmed = cursorLineContent.trim(); + const isCursorLineEmpty = cursorLineTrimmed === ''; + const indentLevel = isCursorLineEmpty + ? getIndentLevelFromLineNumber(model, stepsKeyRange.startLineNumber) + 2 + : getIndentLevelFromLineNumber(model, cursorLine); + return { + insertAtLineNumber: isCursorLineEmpty ? cursorLine : cursorLine + 1, + indentLevel, + insertAfterComment: false, + }; + } + } + + const lastCommentLine = findLastCommentLine(model, stepsKeyRange); + if (lastCommentLine) { + return { + insertAtLineNumber: lastCommentLine.lineNumber, + indentLevel: lastCommentLine.indentLevel, + commentCount: lastCommentLine.commentCount, + insertAfterComment: true, + }; + } + + return { + insertAtLineNumber: stepsKeyRange.endLineNumber + 1, + indentLevel: expectedIndent, + insertAfterComment: false, + }; +} + +/** + * Determines insertion point based on cursor position when in steps section + * @returns null if cursor is not in the steps section + */ +function getInsertPointFromCursor( + model: monaco.editor.ITextModel, + cursorPosition: monaco.Position, + stepsKeyRange: monaco.Range, + stepNodes: ReturnType +): { insertAtLineNumber: number; indentLevel: number; insertAfterComment: boolean; commentCount?: number } | null { + const cursorLine = cursorPosition.lineNumber; + + if (cursorLine < stepsKeyRange.startLineNumber) { + return null; + } -// 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 + const cursorLineContent = model.getLineContent(cursorLine); + const cursorLineTrimmed = cursorLineContent.trim(); + const isCursorLineEmpty = cursorLineTrimmed === ''; + const isCursorLineComment = cursorLineTrimmed.startsWith('#'); + const isOnStepsKeyLine = cursorLine === stepsKeyRange.startLineNumber; + if (isOnStepsKeyLine) { + return { + insertAtLineNumber: stepsKeyRange.endLineNumber + 1, + indentLevel: getIndentLevelFromLineNumber(model, stepsKeyRange.startLineNumber) + 2, + insertAfterComment: false, + }; + } + + if (isCursorLineEmpty) { + let indentLevel = getIndentLevelFromLineNumber(model, stepsKeyRange.startLineNumber) + 2; + if (cursorLine > stepsKeyRange.endLineNumber) { + const prevLineContent = model.getLineContent(cursorLine - 1).trim(); + if (prevLineContent && (prevLineContent.startsWith('-') || prevLineContent.startsWith('#'))) { + indentLevel = getIndentLevelFromLineNumber(model, cursorLine - 1); + } + } + return { + insertAtLineNumber: cursorLine, + indentLevel, + insertAfterComment: false, + }; + } + + if (isCursorLineComment) { + return { + insertAtLineNumber: cursorLine, + indentLevel: getIndentLevelFromLineNumber(model, cursorLine), + insertAfterComment: true, + }; + } + + if (stepNodes.length > 0) { + const lastStepNode = stepNodes[stepNodes.length - 1]; + const lastStepRange = getMonacoRangeFromYamlNode(model, lastStepNode); + if (lastStepRange && cursorLine >= lastStepRange.endLineNumber && cursorLine <= lastStepRange.endLineNumber + 10) { + return { + insertAtLineNumber: cursorLine, + indentLevel: getIndentLevelFromLineNumber(model, lastStepRange.startLineNumber), + insertAfterComment: false, + }; + } + } + + const lineIndent = getIndentLevelFromLineNumber(model, cursorLine); + const indentLevel = lineIndent > 0 ? lineIndent : getIndentLevelFromLineNumber(model, stepsKeyRange.startLineNumber) + 2; + + return { + insertAtLineNumber: cursorLine + 1, + indentLevel, + insertAfterComment: false, + }; +} + +/** + * Inserts a step snippet into the YAML editor at the appropriate location. + */ 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 stepNode = findStepNodeToInsertAfter(document, model, cursorPosition, stepNodes, stepsKeyRange); - // Create separate undo boundary for each snippet insertion - if (editor) { - editor.pushUndoStop(); + let { replaceRange, indentLevel, isReplacingFlowArray } = handleFlowArrayReplacement( + model, + stepsPair, + stepsKeyRange, + expectedIndent + ); + + 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( + model, + stepsKeyRange, + expectedIndent, + cursorPosition, + stepNodes + ); + 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(); + + 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 { range, text } = getInsertRangeAndTextForSteps( + model, + replaceRange, + insertAfterComment, + insertAtLineNumber, + finalInsertText, + commentCount, + isReplacingFlowArray ); + 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..32b7c0a173348 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 { type Document, parseDocument } from 'yaml'; +import { isSeq } from 'yaml'; import { monaco } from '@kbn/monaco'; import type { TriggerType } from '@kbn/workflows'; import { generateTriggerSnippet } from './generate_trigger_snippet'; 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'; +import { + createReplacementRange, + findFirstEmptyItem, + findLastCommentLine, + getInsertRangeAndTextForTriggers, + getSectionKeyInfo, +} from './snippet_insertion_utils'; // Algorithm: // 1. Check if triggers section exists (even if empty or has empty items) @@ -286,7 +60,7 @@ export function insertTriggerSnippet( if (triggersPair) { insertTriggersSection = false; - const { range: keyRange, indentLevel: expectedIndent } = getTriggersKeyInfo( + const { range: keyRange, indentLevel: expectedIndent } = getSectionKeyInfo( model, triggersPair ); @@ -321,7 +95,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 +124,7 @@ export function insertTriggerSnippet( ? triggerSnippet : prependIndentToLines(triggerSnippet, indentLevel); - const { range: insertRange, text: finalInsertText } = getInsertRangeAndText( + const { range, text } = getInsertRangeAndTextForTriggers( model, replaceRange, insertAfterComment, @@ -360,16 +134,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..b01b9b3bae65c --- /dev/null +++ b/src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/lib/snippets/snippet_insertion_utils.ts @@ -0,0 +1,361 @@ +/* + * 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 { monaco } from '@kbn/monaco'; +import { isMap, isNode, isPair, isScalar, isSeq, type Pair, type Range } from 'yaml'; +import { getIndentLevelFromLineNumber } from '../get_indent_level'; +import { getMonacoRangeFromYamlRange } from '../utils'; + +/** + * Removes trailing newlines from a string without using regex + */ +function removeTrailingNewlines(text: string): string { + let end = text.length; + while (end > 0 && text[end - 1] === '\n') { + end--; + } + return text.slice(0, end); +} + +/** + * Gets line content and checks if it's empty + */ +function getLineContent(model: monaco.editor.ITextModel, lineNumber: number): string | null { + if (lineNumber > model.getLineCount()) { + return null; + } + return model.getLineContent(lineNumber); +} + +/** + * Checks if a line is a comment + */ +function isCommentLine(lineContent: string | null): boolean { + return lineContent !== null && lineContent.trim().startsWith('#'); +} + +/** + * Handles insertion after a comment line + */ +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 }; +} + +/** + * Handles insertion after a non-comment item (step/trigger) + */ +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 }; +} + +/** + * Creates a replacement range for an empty item line + */ +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); +} + +/** + * 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/steps + */ +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; +} + +/** + * Finds the last comment line in a section (triggers or steps) + */ +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; +} + +/** + * Determines the insertion range and modifies the insert text based on the insertion context + */ +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 +): { range: monaco.Range; text: string } { + 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 }; + } + + const currentLineContent = getLineContent(model, insertAtLineNumber); + if (isCommentLine(currentLineContent)) { + return handleInsertAfterComment(model, insertAtLineNumber, insertText, true); + } else { + 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 isComment = targetLine.trim().startsWith('#'); + 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` }; + } + + const lineEndColumn = model.getLineMaxColumn(insertAtLineNumber); + const range = new monaco.Range(insertAtLineNumber, 1, insertAtLineNumber, lineEndColumn); + return { range, text: insertText }; +} + +/** + * Finds the first empty item in a sequence (triggers or steps) + */ +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; +} + +/** + * Gets the section key range and calculates the expected indent level for array items + * Works for both triggers and steps sections + */ +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 78d04beb42ed5..a1f7f8ef0aeec 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 @@ -464,9 +464,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(); From 7940570999286fbddaef4c36b7e3cb6c6563f3ee Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Thu, 15 Jan 2026 17:38:05 +0000 Subject: [PATCH 2/7] Changes from node scripts/eslint_all_files --no-cache --fix --- .../lib/snippets/insert_step_snippet.test.ts | 9 +- .../lib/snippets/insert_step_snippet.ts | 84 ++++++++++++------- .../lib/snippets/insert_trigger_snippet.ts | 15 ++-- .../lib/snippets/snippet_insertion_utils.ts | 28 +++++-- 4 files changed, 84 insertions(+), 52 deletions(-) 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 a669505e8872f..5bdd4293fa741 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 @@ -420,11 +420,14 @@ steps: // 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') { + 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'; + const expectedText = `${expectedTextWithoutNewline}\n ## Hello\n`; expect(callArgs[1][0].text).toBe(expectedText); expect(callArgs[2]).toBeInstanceOf(Function); }); @@ -638,7 +641,7 @@ steps: [ { range: expect.any(monaco.Range), - text: '\n' + prependIndentToLines(snippetText, 2), + text: `\n${prependIndentToLines(snippetText, 2)}`, }, ], 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 c5f61000da45f..1c100af4b31a2 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,13 +7,9 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ +import { type Document, isMap, isPair, isScalar, isSeq, type Pair, parseDocument } from 'yaml'; import { monaco } from '@kbn/monaco'; import { isBuiltInStepType } from '@kbn/workflows'; -import { type Document, type Pair, isMap, isPair, isScalar, isSeq, parseDocument } from 'yaml'; -import { getStepNodeAtPosition, getStepNodesWithType } from '../../../../../common/lib/yaml'; -import { getIndentLevelFromLineNumber } from '../get_indent_level'; -import { prependIndentToLines } from '../prepend_indent_to_lines'; -import { getMonacoRangeFromYamlNode } from '../utils'; import { generateBuiltInStepSnippet } from './generate_builtin_step_snippet'; import { generateConnectorSnippet } from './generate_connector_snippet'; import { @@ -23,6 +19,10 @@ import { getInsertRangeAndTextForSteps, getSectionKeyInfo, } from './snippet_insertion_utils'; +import { getStepNodeAtPosition, getStepNodesWithType } from '../../../../../common/lib/yaml'; +import { getIndentLevelFromLineNumber } from '../get_indent_level'; +import { prependIndentToLines } from '../prepend_indent_to_lines'; +import { getMonacoRangeFromYamlNode } from '../utils'; /** * Finds the steps pair in the YAML document, even if it's empty or has empty items @@ -45,7 +45,6 @@ function getStepsPair(yamlDocument: Document): Pair | null { return isPair(stepsPair) ? stepsPair : null; } - /** * Finds the step node to insert after based on cursor position or last step */ @@ -63,17 +62,17 @@ function findStepNodeToInsertAfter( const isOnStepsKeyLine = cursorLine === stepsKeyRange.startLineNumber; const isOnCommentLine = cursorLineTrimmed.startsWith('#'); const isOnEmptyLine = cursorLineTrimmed === ''; - + if (isOnStepsKeyLine || isOnCommentLine || isOnEmptyLine) { return null; } - + const stepAtCursor = getStepNodeAtPosition(document, model.getOffsetAt(cursorPosition)); if (stepAtCursor) { return stepAtCursor; } } - + return stepNodes.length > 0 ? stepNodes[stepNodes.length - 1] : null; } @@ -96,14 +95,15 @@ function handleFlowArrayReplacement( 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; + const replaceRange = + stepsKeyRange.startLineNumber === sequenceRange.startLineNumber + ? new monaco.Range( + stepsKeyRange.startLineNumber, + 1, + stepsKeyRange.startLineNumber, + model.getLineMaxColumn(stepsKeyRange.startLineNumber) + ) + : sequenceRange; return { replaceRange, @@ -129,7 +129,8 @@ function getInsertPointAfterStep( if (cursorPosition) { const { lineNumber: cursorLine, column: cursorColumn } = cursorPosition; - const isAfterStep = cursorLine > stepRange.endLineNumber || + const isAfterStep = + cursorLine > stepRange.endLineNumber || (cursorLine === stepRange.endLineNumber && cursorColumn > stepRange.endColumn); if (isAfterStep && cursorLine <= stepRange.endLineNumber + 10) { insertAtLineNumber = cursorLine; @@ -209,9 +210,14 @@ function getInsertPointFromCursor( cursorPosition: monaco.Position, stepsKeyRange: monaco.Range, stepNodes: ReturnType -): { insertAtLineNumber: number; indentLevel: number; insertAfterComment: boolean; commentCount?: number } | null { +): { + insertAtLineNumber: number; + indentLevel: number; + insertAfterComment: boolean; + commentCount?: number; +} | null { const cursorLine = cursorPosition.lineNumber; - + if (cursorLine < stepsKeyRange.startLineNumber) { return null; } @@ -256,7 +262,11 @@ function getInsertPointFromCursor( if (stepNodes.length > 0) { const lastStepNode = stepNodes[stepNodes.length - 1]; const lastStepRange = getMonacoRangeFromYamlNode(model, lastStepNode); - if (lastStepRange && cursorLine >= lastStepRange.endLineNumber && cursorLine <= lastStepRange.endLineNumber + 10) { + if ( + lastStepRange && + cursorLine >= lastStepRange.endLineNumber && + cursorLine <= lastStepRange.endLineNumber + 10 + ) { return { insertAtLineNumber: cursorLine, indentLevel: getIndentLevelFromLineNumber(model, lastStepRange.startLineNumber), @@ -264,10 +274,13 @@ function getInsertPointFromCursor( }; } } - + const lineIndent = getIndentLevelFromLineNumber(model, cursorLine); - const indentLevel = lineIndent > 0 ? lineIndent : getIndentLevelFromLineNumber(model, stepsKeyRange.startLineNumber) + 2; - + const indentLevel = + lineIndent > 0 + ? lineIndent + : getIndentLevelFromLineNumber(model, stepsKeyRange.startLineNumber) + 2; + return { insertAtLineNumber: cursorLine + 1, indentLevel, @@ -313,7 +326,7 @@ export function insertStepSnippet( ); model.pushEditOperations(null, [{ range, text }], () => null); - if (editor) { + if (editor) { editor.pushUndoStop(); } @@ -326,8 +339,14 @@ export function insertStepSnippet( } const stepsKeyRange = sectionInfo.range; const expectedIndent = sectionInfo.indentLevel; - - const stepNode = findStepNodeToInsertAfter(document, model, cursorPosition, stepNodes, stepsKeyRange); + + const stepNode = findStepNodeToInsertAfter( + document, + model, + cursorPosition, + stepNodes, + stepsKeyRange + ); let { replaceRange, indentLevel, isReplacingFlowArray } = handleFlowArrayReplacement( model, @@ -379,12 +398,13 @@ export function insertStepSnippet( const insertText = prependIndentToLines(snippetText, indentLevel); - const finalInsertText = replaceRange && isReplacingFlowArray && stepsKeyRange - ? (replaceRange.startLineNumber === stepsKeyRange.startLineNumber && - replaceRange.endLineNumber === stepsKeyRange.startLineNumber + const finalInsertText = + replaceRange && isReplacingFlowArray && stepsKeyRange + ? replaceRange.startLineNumber === stepsKeyRange.startLineNumber && + replaceRange.endLineNumber === stepsKeyRange.startLineNumber ? `steps:\n${insertText}` - : `\n${insertText}`) - : insertText; + : `\n${insertText}` + : insertText; const { range, text } = getInsertRangeAndTextForSteps( model, 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 32b7c0a173348..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 @@ -9,13 +9,9 @@ import { type Document, parseDocument } from 'yaml'; import { isSeq } from 'yaml'; -import { monaco } from '@kbn/monaco'; +import type { monaco } from '@kbn/monaco'; import type { TriggerType } from '@kbn/workflows'; import { generateTriggerSnippet } from './generate_trigger_snippet'; -import { getTriggerNodes, getTriggersPair } from '../../../../../common/lib/yaml'; -import { getIndentLevelFromLineNumber } from '../get_indent_level'; -import { prependIndentToLines } from '../prepend_indent_to_lines'; -import { getMonacoRangeFromYamlNode } from '../utils'; import { createReplacementRange, findFirstEmptyItem, @@ -23,6 +19,10 @@ import { 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 } from '../utils'; // Algorithm: // 1. Check if triggers section exists (even if empty or has empty items) @@ -60,10 +60,7 @@ export function insertTriggerSnippet( if (triggersPair) { insertTriggersSection = false; - const { range: keyRange, indentLevel: expectedIndent } = getSectionKeyInfo( - model, - triggersPair - ); + const { range: keyRange, indentLevel: expectedIndent } = getSectionKeyInfo(model, triggersPair); triggersKeyRange = keyRange; // Check if triggers is a flow-style empty array (triggers: []) 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 index b01b9b3bae65c..c70f7bc8cc8b6 100644 --- 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 @@ -7,8 +7,8 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { monaco } from '@kbn/monaco'; 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'; @@ -54,7 +54,12 @@ function handleInsertAfterComment( if (nextLineContent === null) { const lineEndColumn = model.getLineMaxColumn(insertAtLineNumber); - const range = new monaco.Range(insertAtLineNumber, lineEndColumn, insertAtLineNumber, lineEndColumn); + const range = new monaco.Range( + insertAtLineNumber, + lineEndColumn, + insertAtLineNumber, + lineEndColumn + ); return { range, text: `\n${insertText}` }; } @@ -99,7 +104,10 @@ function handleInsertAfterItem( /** * Creates a replacement range for an empty item line */ -export function createReplacementRange(model: monaco.editor.ITextModel, lineNumber: number): monaco.Range { +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); @@ -232,7 +240,12 @@ export function getInsertRangeAndTextForTriggers( if (insertAtLineNumber === model.getLineCount() + 1) { const lastLineNumber = model.getLineCount(); const lastLineEndColumn = model.getLineMaxColumn(lastLineNumber); - const range = new monaco.Range(lastLineNumber, lastLineEndColumn, lastLineNumber, lastLineEndColumn); + const range = new monaco.Range( + lastLineNumber, + lastLineEndColumn, + lastLineNumber, + lastLineEndColumn + ); return { range, text: `\n${insertText}` }; } const range = new monaco.Range(insertAtLineNumber, 1, insertAtLineNumber, 1); @@ -258,9 +271,8 @@ export function getInsertRangeAndTextForSteps( isReplacingFlowArray?: boolean ): { range: monaco.Range; text: string } { if (replaceRange) { - const text = isReplacingFlowArray && !insertText.startsWith('steps:\n') - ? `\n${insertText}` - : insertText; + const text = + isReplacingFlowArray && !insertText.startsWith('steps:\n') ? `\n${insertText}` : insertText; return { range: replaceRange, text }; } @@ -284,7 +296,7 @@ export function getInsertRangeAndTextForSteps( } const targetLine = getLineContent(model, insertAtLineNumber); - + if (targetLine && targetLine.trim()) { const normalizedText = removeTrailingNewlines(insertText); const isComment = targetLine.trim().startsWith('#'); From 8257cfa8f5af7251ec5a44c2bc5a5c5b96964155 Mon Sep 17 00:00:00 2001 From: Yngrid Coello Date: Fri, 16 Jan 2026 10:05:29 +0100 Subject: [PATCH 3/7] fixing lintin issue --- .../lib/snippets/insert_step_snippet.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) 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 c5f61000da45f..bada5bc000a4c 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 @@ -329,12 +329,9 @@ export function insertStepSnippet( const stepNode = findStepNodeToInsertAfter(document, model, cursorPosition, stepNodes, stepsKeyRange); - let { replaceRange, indentLevel, isReplacingFlowArray } = handleFlowArrayReplacement( - model, - stepsPair, - stepsKeyRange, - expectedIndent - ); + const replacement = handleFlowArrayReplacement(model, stepsPair, stepsKeyRange, expectedIndent); + const { isReplacingFlowArray } = replacement; + let { replaceRange, indentLevel } = replacement; if (!replaceRange) { const firstEmptyItem = findFirstEmptyItem(model, stepsPair); From da44570eeec5c060778e1a2deeec0061ee917c02 Mon Sep 17 00:00:00 2001 From: Yngrid Coello Date: Wed, 4 Feb 2026 14:06:23 +0100 Subject: [PATCH 4/7] handling CR failing use cases --- .../lib/yaml/get_step_nodes_with_type.ts | 9 + .../lib/yaml/get_steps_else_key_offsets.ts | 76 ++++ .../common/lib/yaml/index.ts | 8 +- .../lib/snippets/insert_step_snippet.test.ts | 174 +++++++ .../lib/snippets/insert_step_snippet.ts | 430 +++++++++++++----- .../lib/snippets/snippet_insertion_utils.ts | 114 +++-- 6 files changed, 660 insertions(+), 151 deletions(-) create mode 100644 src/platform/plugins/shared/workflows_management/common/lib/yaml/get_steps_else_key_offsets.ts 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 5bdd4293fa741..1b3e94dbf63e3 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 @@ -470,6 +470,69 @@ steps: ); }); + 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 @@ -647,4 +710,115 @@ steps: 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 89a40e2565a88..e58979deec9a6 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,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { type Document, isMap, isPair, isScalar, isSeq, type Pair, parseDocument } 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'; @@ -19,15 +19,122 @@ import { getInsertRangeAndTextForSteps, getSectionKeyInfo, } from './snippet_insertion_utils'; -import { getStepNodeAtPosition, getStepNodesWithType } from '../../../../../common/lib/yaml'; +import { + getStepNodeAtPosition, + getStepsAndElseKeyOffsets, + getStepNodesWithType, + isStepLikeMap, +} from '../../../../../common/lib/yaml'; import { getIndentLevelFromLineNumber } from '../get_indent_level'; import { prependIndentToLines } from '../prepend_indent_to_lines'; import { getMonacoRangeFromYamlNode } from '../utils'; -/** - * Finds the steps pair in the YAML document, even if it's empty or has empty items - * @returns The steps pair if found, null otherwise - */ +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) continue; + 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; @@ -45,40 +152,122 @@ function getStepsPair(yamlDocument: Document): Pair | null { return isPair(stepsPair) ? stepsPair : null; } -/** - * Finds the step node to insert after based on cursor position or last step - */ +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 + 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 isOnStepsKeyLine = cursorLine === stepsKeyRange.startLineNumber; const isOnCommentLine = cursorLineTrimmed.startsWith('#'); - const isOnEmptyLine = cursorLineTrimmed === ''; - - if (isOnStepsKeyLine || isOnCommentLine || isOnEmptyLine) { + 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; } -/** - * Handles flow-style empty array replacement (steps: []) - */ function handleFlowArrayReplacement( model: monaco.editor.ITextModel, stepsPair: Pair, @@ -112,9 +301,6 @@ function handleFlowArrayReplacement( }; } -/** - * Determines insertion point after a step node - */ function getInsertPointAfterStep( model: monaco.editor.ITextModel, stepNode: ReturnType[number], @@ -129,10 +315,16 @@ function getInsertPointAfterStep( 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 (isAfterStep && cursorLine <= stepRange.endLineNumber + 10) { + if (cursorLineEmpty && (cursorLineIndent >= stepIndent || cursorLine >= stepRange.startLineNumber)) { + insertAtLineNumber = cursorLine; + } else if (isAfterStep && (cursorLineEmpty || cursorLineIndent >= stepIndent)) { insertAtLineNumber = cursorLine; } } @@ -143,44 +335,45 @@ function getInsertPointAfterStep( }; } -/** - * Determines insertion point when no step node is found - */ function getDefaultInsertPoint( + document: Document, model: monaco.editor.ITextModel, + stepsPair: Pair, stepsKeyRange: monaco.Range, expectedIndent: number, cursorPosition: monaco.Position | null | undefined, - stepNodes: ReturnType + 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 + stepNodes, + stepsKeyLines, + elseKeyLines ); - if (cursorInsertPoint) { - return cursorInsertPoint; - } - const cursorLineContent = model.getLineContent(cursorLine); - const cursorLineTrimmed = cursorLineContent.trim(); - const isCursorLineEmpty = cursorLineTrimmed === ''; - const indentLevel = isCursorLineEmpty - ? getIndentLevelFromLineNumber(model, stepsKeyRange.startLineNumber) + 2 - : getIndentLevelFromLineNumber(model, cursorLine); - return { - insertAtLineNumber: isCursorLineEmpty ? cursorLine : cursorLine + 1, - indentLevel, - insertAfterComment: false, - }; + if (cursorInsertPoint) return cursorInsertPoint; + const insertAt = + model.getLineContent(cursorLine).trim() === '' ? cursorLine : cursorLine + 1; + return atLine(insertAt); } } @@ -194,22 +387,20 @@ function getDefaultInsertPoint( }; } - return { - insertAtLineNumber: stepsKeyRange.endLineNumber + 1, - indentLevel: expectedIndent, - insertAfterComment: false, - }; + const lastRootStepEndLine = getLastRootStepEndLine(model, stepsPair); + const insertAtLineNumber = + lastRootStepEndLine != null ? lastRootStepEndLine + 1 : stepsKeyRange.startLineNumber + 1; + return atLine(insertAtLineNumber); } -/** - * Determines insertion point based on cursor position when in steps section - * @returns null if cursor is not in the steps section - */ function getInsertPointFromCursor( + document: Document, model: monaco.editor.ITextModel, cursorPosition: monaco.Position, stepsKeyRange: monaco.Range, - stepNodes: ReturnType + stepNodes: ReturnType, + stepsKeyLines: Set, + elseKeyLines: Set ): { insertAtLineNumber: number; indentLevel: number; @@ -217,80 +408,44 @@ function getInsertPointFromCursor( 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; - } + if (cursorLine < stepsKeyRange.startLineNumber) return null; const cursorLineContent = model.getLineContent(cursorLine); const cursorLineTrimmed = cursorLineContent.trim(); const isCursorLineEmpty = cursorLineTrimmed === ''; const isCursorLineComment = cursorLineTrimmed.startsWith('#'); - const isOnStepsKeyLine = cursorLine === stepsKeyRange.startLineNumber; - - if (isOnStepsKeyLine) { - return { - insertAtLineNumber: stepsKeyRange.endLineNumber + 1, - indentLevel: getIndentLevelFromLineNumber(model, stepsKeyRange.startLineNumber) + 2, - insertAfterComment: false, - }; - } - - if (isCursorLineEmpty) { - let indentLevel = getIndentLevelFromLineNumber(model, stepsKeyRange.startLineNumber) + 2; - if (cursorLine > stepsKeyRange.endLineNumber) { - const prevLineContent = model.getLineContent(cursorLine - 1).trim(); - if (prevLineContent && (prevLineContent.startsWith('-') || prevLineContent.startsWith('#'))) { - indentLevel = getIndentLevelFromLineNumber(model, cursorLine - 1); - } - } - return { - insertAtLineNumber: cursorLine, - indentLevel, - insertAfterComment: false, - }; - } + const isOnAnyStepsKeyLine = stepsKeyLines.has(cursorLine); - if (isCursorLineComment) { - return { - insertAtLineNumber: cursorLine, - indentLevel: getIndentLevelFromLineNumber(model, cursorLine), - insertAfterComment: true, - }; - } + 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 && - cursorLine <= lastStepRange.endLineNumber + 10 - ) { - return { - insertAtLineNumber: cursorLine, - indentLevel: getIndentLevelFromLineNumber(model, lastStepRange.startLineNumber), - insertAfterComment: false, - }; + if (lastStepRange && cursorLine >= lastStepRange.endLineNumber) { + const cursorLineIndent = getIndentLevelFromLineNumber(model, cursorLine); + const lastStepIndent = getIndentLevelFromLineNumber(model, lastStepRange.startLineNumber); + if (isCursorLineEmpty || cursorLineIndent >= lastStepIndent) return at(cursorLine, false); } } - const lineIndent = getIndentLevelFromLineNumber(model, cursorLine); - const indentLevel = - lineIndent > 0 - ? lineIndent - : getIndentLevelFromLineNumber(model, stepsKeyRange.startLineNumber) + 2; - - return { - insertAtLineNumber: cursorLine + 1, - indentLevel, - insertAfterComment: false, - }; + return at(cursorLine + 1, false); } -/** - * Inserts a step snippet into the YAML editor at the appropriate location. - */ export function insertStepSnippet( model: monaco.editor.ITextModel, yamlDocument: Document | null, @@ -339,13 +494,16 @@ export function insertStepSnippet( } const stepsKeyRange = sectionInfo.range; const expectedIndent = sectionInfo.indentLevel; + const { stepsKeyLines, elseKeyLines } = getStepsAndElseKeyLines(document, model); const stepNode = findStepNodeToInsertAfter( document, model, cursorPosition, stepNodes, - stepsKeyRange + stepsKeyRange, + stepsKeyLines, + elseKeyLines ); const replacement = handleFlowArrayReplacement(model, stepsPair, stepsKeyRange, expectedIndent); @@ -374,11 +532,15 @@ export function insertStepSnippet( } } else { const defaultPoint = getDefaultInsertPoint( + document, model, + stepsPair, stepsKeyRange, expectedIndent, cursorPosition, - stepNodes + stepNodes, + stepsKeyLines, + elseKeyLines ); insertAtLineNumber = defaultPoint.insertAtLineNumber; indentLevel = defaultPoint.indentLevel; @@ -393,6 +555,23 @@ export function insertStepSnippet( 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 = @@ -403,6 +582,33 @@ export function insertStepSnippet( : `\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, @@ -410,7 +616,9 @@ export function insertStepSnippet( insertAtLineNumber, finalInsertText, commentCount, - isReplacingFlowArray + isReplacingFlowArray, + cursorOnElseLine, + elseKeyLines ); model.pushEditOperations(null, [{ range, text }], () => null); 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 index c70f7bc8cc8b6..8f975016fd331 100644 --- 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 @@ -12,9 +12,6 @@ import { monaco } from '@kbn/monaco'; import { getIndentLevelFromLineNumber } from '../get_indent_level'; import { getMonacoRangeFromYamlRange } from '../utils'; -/** - * Removes trailing newlines from a string without using regex - */ function removeTrailingNewlines(text: string): string { let end = text.length; while (end > 0 && text[end - 1] === '\n') { @@ -23,9 +20,6 @@ function removeTrailingNewlines(text: string): string { return text.slice(0, end); } -/** - * Gets line content and checks if it's empty - */ function getLineContent(model: monaco.editor.ITextModel, lineNumber: number): string | null { if (lineNumber > model.getLineCount()) { return null; @@ -33,16 +27,30 @@ function getLineContent(model: monaco.editor.ITextModel, lineNumber: number): st return model.getLineContent(lineNumber); } -/** - * Checks if a line is a comment - */ function isCommentLine(lineContent: string | null): boolean { return lineContent !== null && lineContent.trim().startsWith('#'); } -/** - * Handles insertion after a comment line - */ +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, @@ -80,9 +88,6 @@ function handleInsertAfterComment( return { range, text: addTrailingNewline ? `${normalizedText}\n` : normalizedText }; } -/** - * Handles insertion after a non-comment item (step/trigger) - */ function handleInsertAfterItem( model: monaco.editor.ITextModel, insertAtLineNumber: number, @@ -101,9 +106,6 @@ function handleInsertAfterItem( return { range, text: insertText }; } -/** - * Creates a replacement range for an empty item line - */ export function createReplacementRange( model: monaco.editor.ITextModel, lineNumber: number @@ -116,10 +118,6 @@ export function createReplacementRange( 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/steps - */ export function isEmptyItem(item: unknown): boolean { if (!item) { return true; @@ -144,9 +142,6 @@ export function isEmptyItem(item: unknown): boolean { return false; } -/** - * Finds the last comment line in a section (triggers or steps) - */ export function findLastCommentLine( model: monaco.editor.ITextModel, sectionKeyRange: monaco.Range | null @@ -189,9 +184,6 @@ export function findLastCommentLine( return null; } -/** - * Determines the insertion range and modifies the insert text based on the insertion context - */ export function getInsertRangeAndTextForTriggers( model: monaco.editor.ITextModel, replaceRange: monaco.Range | null, @@ -268,8 +260,13 @@ export function getInsertRangeAndTextForSteps( insertAtLineNumber: number, insertText: string, commentCount?: number, - isReplacingFlowArray?: boolean + 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; @@ -282,12 +279,39 @@ export function getInsertRangeAndTextForSteps( 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); - } else { - return handleInsertAfterItem(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()) { @@ -299,7 +323,22 @@ export function getInsertRangeAndTextForSteps( if (targetLine && targetLine.trim()) { const normalizedText = removeTrailingNewlines(insertText); - const isComment = targetLine.trim().startsWith('#'); + 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); @@ -309,14 +348,15 @@ export function getInsertRangeAndTextForSteps( 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 }; } -/** - * Finds the first empty item in a sequence (triggers or steps) - */ export function findFirstEmptyItem( model: monaco.editor.ITextModel, sectionPair: Pair | null @@ -347,10 +387,6 @@ export function findFirstEmptyItem( return null; } -/** - * Gets the section key range and calculates the expected indent level for array items - * Works for both triggers and steps sections - */ export function getSectionKeyInfo( model: monaco.editor.ITextModel, sectionPair: Pair | null From e8e894010f44f5e432f55544101fa256c6668241 Mon Sep 17 00:00:00 2001 From: Yngrid Coello Date: Wed, 4 Feb 2026 14:20:33 +0100 Subject: [PATCH 5/7] copilot review --- .../lib/snippets/insert_step_snippet.test.ts | 1 + .../lib/snippets/snippet_insertion_utils.ts | 11 +++++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) 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 1b3e94dbf63e3..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,6 +155,7 @@ 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, [ 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 index 8f975016fd331..87f64c0500345 100644 --- 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 @@ -213,8 +213,15 @@ export function getInsertRangeAndTextForTriggers( 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 }; + const lastLineNumber = model.getLineCount(); + const lastLineEndColumn = model.getLineMaxColumn(lastLineNumber); + const range = new monaco.Range( + lastLineNumber, + lastLineEndColumn, + lastLineNumber, + lastLineEndColumn + ); + return { range, text: `\n${insertText}` }; } if (lineAfterNextContent.trim() === '') { const range = new monaco.Range(lineAfterNext, 1, lineAfterNext, 1); From b550db6a23089fbc48716c9d91a63e2b34bc026d Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Wed, 4 Feb 2026 13:47:44 +0000 Subject: [PATCH 6/7] Changes from node scripts/eslint_all_files --no-cache --fix --- .../lib/snippets/insert_step_snippet.ts | 55 ++++++++++--------- 1 file changed, 28 insertions(+), 27 deletions(-) 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 e58979deec9a6..17fcb9d917512 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,7 +7,16 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { type Document, isMap, isPair, isScalar, isSeq, type Pair, parseDocument, visit } 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'; @@ -21,8 +30,8 @@ import { } from './snippet_insertion_utils'; import { getStepNodeAtPosition, - getStepsAndElseKeyOffsets, getStepNodesWithType, + getStepsAndElseKeyOffsets, isStepLikeMap, } from '../../../../../common/lib/yaml'; import { getIndentLevelFromLineNumber } from '../get_indent_level'; @@ -76,9 +85,7 @@ function findStepWithMaxEndOffsetAtOrBefore( for (const node of nodes) { const range = getMonacoRangeFromYamlNode(model, node); if (!range) continue; - const endOffset = model.getOffsetAt( - new monaco.Position(range.endLineNumber, range.endColumn) - ); + const endOffset = model.getOffsetAt(new monaco.Position(range.endLineNumber, range.endColumn)); if (endOffset <= cursorOffset && endOffset > bestEndOffset) { bestEndOffset = endOffset; best = node; @@ -92,7 +99,8 @@ function getStepsInInnermostBlockContainingCursor( model: monaco.editor.ITextModel, cursorOffset: number ): StepNodeLike[] { - const candidates: Array<{ seq: { items?: unknown[] }; rangeStart: number; rangeEnd: number }> = []; + const candidates: Array<{ seq: { items?: unknown[] }; rangeStart: number; rangeEnd: number }> = + []; visit(document, { Pair(_key, pair) { if (!pair.key || !isScalar(pair.key)) return; @@ -121,10 +129,7 @@ function getStepsInInnermostBlockContainingCursor( return steps; } -function getLastRootStepEndLine( - model: monaco.editor.ITextModel, - stepsPair: Pair -): number | null { +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]; @@ -166,7 +171,8 @@ function getLastThenStepBeforeElseLine( 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; + 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'); @@ -212,8 +218,7 @@ function findStepNodeToInsertAfter( 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' + (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' @@ -246,11 +251,7 @@ function findStepNodeToInsertAfter( ); }); if (nestedStepsInsideCurrentStep) { - const bestStep = findStepWithMaxEndOffsetAtOrBefore( - stepsInBlock, - model, - cursorOffset - ); + const bestStep = findStepWithMaxEndOffsetAtOrBefore(stepsInBlock, model, cursorOffset); return bestStep ?? stepsInBlock[stepsInBlock.length - 1]; } } @@ -322,7 +323,10 @@ function getInsertPointAfterStep( const isAfterStep = cursorLine > stepRange.endLineNumber || (cursorLine === stepRange.endLineNumber && cursorColumn > stepRange.endColumn); - if (cursorLineEmpty && (cursorLineIndent >= stepIndent || cursorLine >= stepRange.startLineNumber)) { + if ( + cursorLineEmpty && + (cursorLineIndent >= stepIndent || cursorLine >= stepRange.startLineNumber) + ) { insertAtLineNumber = cursorLine; } else if (isAfterStep && (cursorLineEmpty || cursorLineIndent >= stepIndent)) { insertAtLineNumber = cursorLine; @@ -371,8 +375,7 @@ function getDefaultInsertPoint( elseKeyLines ); if (cursorInsertPoint) return cursorInsertPoint; - const insertAt = - model.getLineContent(cursorLine).trim() === '' ? cursorLine : cursorLine + 1; + const insertAt = model.getLineContent(cursorLine).trim() === '' ? cursorLine : cursorLine + 1; return atLine(insertAt); } } @@ -409,11 +412,7 @@ function getInsertPointFromCursor( } | null { const cursorLine = cursorPosition.lineNumber; const rootLine = stepsKeyRange.startLineNumber; - const at = ( - insertAtLineNumber: number, - insertAfterComment: boolean, - commentCount?: number - ) => ({ + const at = (insertAtLineNumber: number, insertAfterComment: boolean, commentCount?: number) => ({ insertAtLineNumber, indentLevel: getStepIndentForInsertLine(document, model, insertAtLineNumber, rootLine), insertAfterComment, @@ -583,7 +582,9 @@ export function insertStepSnippet( : insertText; const cursorOnElseLine = - cursorPosition !== undefined && cursorPosition !== null && elseKeyLines.has(cursorPosition.lineNumber); + cursorPosition !== undefined && + cursorPosition !== null && + elseKeyLines.has(cursorPosition.lineNumber); if ( !replaceRange && From 860084cfcbc4816a69a1398f84280aaa4f54a235 Mon Sep 17 00:00:00 2001 From: Yngrid Coello Date: Wed, 4 Feb 2026 15:17:48 +0100 Subject: [PATCH 7/7] fixing build --- .../lib/snippets/insert_step_snippet.ts | 13 ++++++++----- .../lib/snippets/snippet_insertion_utils.ts | 11 ++--------- 2 files changed, 10 insertions(+), 14 deletions(-) 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 17fcb9d917512..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 @@ -84,11 +84,14 @@ function findStepWithMaxEndOffsetAtOrBefore( let bestEndOffset = -1; for (const node of nodes) { const range = getMonacoRangeFromYamlNode(model, node); - if (!range) continue; - const endOffset = model.getOffsetAt(new monaco.Position(range.endLineNumber, range.endColumn)); - if (endOffset <= cursorOffset && endOffset > bestEndOffset) { - bestEndOffset = endOffset; - best = 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; 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 index 87f64c0500345..8f975016fd331 100644 --- 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 @@ -213,15 +213,8 @@ export function getInsertRangeAndTextForTriggers( const lineAfterNext = nextLineNumber + 1; const lineAfterNextContent = getLineContent(model, lineAfterNext); if (lineAfterNextContent === null) { - 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(lineAfterNext, 1, lineAfterNext, 1); + return { range, text: insertText }; } if (lineAfterNextContent.trim() === '') { const range = new monaco.Range(lineAfterNext, 1, lineAfterNext, 1);