Skip to content

Commit e838a14

Browse files
authored
Add 'Extract Task spec' CodeAction (redhat-developer#546)
* redhat-developer#511 add 'Extract Task spec' CodeAction Signed-off-by: Yevhen Vydolob <[email protected]> * Set 'ignoreFocusOut' to true Signed-off-by: Yevhen Vydolob <[email protected]> * Fix task yaml generator Signed-off-by: Yevhen Vydolob <[email protected]>
1 parent fe3d348 commit e838a14

File tree

7 files changed

+367
-42
lines changed

7 files changed

+367
-42
lines changed

src/model/pipeline/pipeline-model.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import { TknElement, TknArray, TknBaseRootElement, NodeTknElement, TknStringElement, TknValueElement, TknParam, TknKeyElement } from '../common';
77
import { TknElementType } from '../element-type';
88
import { YamlMap, YamlSequence, YamlNode, isSequence } from '../../yaml-support/yaml-locator';
9-
import { pipelineYaml, getYamlMappingValue, findNodeByKey } from '../../yaml-support/tkn-yaml';
9+
import { pipelineYaml, getYamlMappingValue, findNodeByKey, findNodeAndKeyByKeyValue } from '../../yaml-support/tkn-yaml';
1010
import { EmbeddedTask } from './task-model';
1111

1212
const ephemeralMap: YamlMap = {
@@ -229,9 +229,9 @@ export class PipelineTask extends NodeTknElement {
229229

230230
get taskSpec(): EmbeddedTask {
231231
if (!this._taskSpec) {
232-
const taskSpecNode = findNodeByKey<YamlSequence>('taskSpec', this.node as YamlMap)
232+
const taskSpecNode = findNodeAndKeyByKeyValue<YamlNode, YamlSequence>('taskSpec', this.node as YamlMap)
233233
if (taskSpecNode) {
234-
this._taskSpec = new EmbeddedTask(this, taskSpecNode);
234+
this._taskSpec = new EmbeddedTask(this, taskSpecNode[0], taskSpecNode[1]);
235235
}
236236
}
237237
return this._taskSpec;

src/model/pipeline/task-model.ts

+8-2
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
*-----------------------------------------------------------------------------------------------*/
55

66
import { findNodeByKey } from '../../yaml-support/tkn-yaml';
7-
import { YamlMap, YamlSequence } from '../../yaml-support/yaml-locator';
8-
import { NodeTknElement, TknArray, TknElement, TknStringElement } from '../common';
7+
import { YamlMap, YamlNode, YamlSequence } from '../../yaml-support/yaml-locator';
8+
import { NodeTknElement, TknArray, TknElement, TknKeyElement, TknStringElement } from '../common';
99
import { TknElementType } from '../element-type';
1010

1111
export class EmbeddedTask extends NodeTknElement {
@@ -23,6 +23,12 @@ export class EmbeddedTask extends NodeTknElement {
2323
// private _sidecars: TknArray<Sidecar>;
2424
// private _workspaces: TknArray<WorkspaceDeclaration>;
2525
private _results: TknArray<TaskResult>;
26+
keyNode: TknElement
27+
28+
constructor(parent: TknElement, keyNode: YamlNode, node: YamlSequence) {
29+
super(parent, node);
30+
this.keyNode = new TknKeyElement(parent, keyNode);
31+
}
2632

2733
get results(): TknArray<TaskResult> {
2834
if (!this._results) {

src/util/tekton-vfs.ts

+18
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,24 @@ export class TektonVFSProvider implements FileSystemProvider {
158158
};
159159
}
160160

161+
async saveTektonDocument(doc: VirtualDocument): Promise<void | string> {
162+
const tempPath = os.tmpdir();
163+
const fsPath = path.join(tempPath, doc.uri.fsPath);
164+
try {
165+
await fsx.ensureFile(fsPath);
166+
await fsx.writeFile(fsPath, doc.getText());
167+
168+
const result = await this.updateK8sResource(fsPath);
169+
if (result.error) {
170+
return getStderrString(result.error);
171+
}
172+
173+
} finally {
174+
await fsx.unlink(fsPath);
175+
}
176+
177+
}
178+
161179
}
162180

163181
export const tektonVfsProvider = new TektonVFSProvider();

src/yaml-support/tkn-code-actions.ts

+165-29
Original file line numberDiff line numberDiff line change
@@ -13,65 +13,70 @@ import * as jsYaml from 'js-yaml';
1313
import { Task } from '../tekton';
1414
import { telemetryLogError } from '../telemetry';
1515
import { ContextType } from '../context-type';
16+
import * as _ from 'lodash';
1617

1718
interface ProviderMetadata {
1819
getProviderMetadata(): vscode.CodeActionProviderMetadata;
1920
}
2021

21-
interface TaskInlineAction extends vscode.CodeAction{
22+
const INLINE_TASK = vscode.CodeActionKind.RefactorInline.append('TektonTask');
23+
const EXTRACT_TASK = vscode.CodeActionKind.RefactorExtract.append('TektonTask');
24+
25+
interface InlineTaskAction extends vscode.CodeAction {
2226
taskRefStartPosition?: vscode.Position;
2327
taskRefEndPosition?: vscode.Position;
2428
taskRefName?: string;
2529
taskKind?: string;
2630
documentUri?: vscode.Uri;
2731
}
2832

33+
function isTaskInlineAction(action: vscode.CodeAction): action is InlineTaskAction {
34+
return action.kind.contains(INLINE_TASK);
35+
}
36+
37+
interface ExtractTaskAction extends vscode.CodeAction {
38+
documentUri?: vscode.Uri;
39+
taskSpecText?: string;
40+
taskSpecStartPosition?: vscode.Position;
41+
taskSpecEndPosition?: vscode.Position;
42+
}
43+
2944
class PipelineCodeActionProvider implements vscode.CodeActionProvider {
3045
provideCodeActions(document: vscode.TextDocument, range: vscode.Range | vscode.Selection): vscode.ProviderResult<vscode.CodeAction[]> {
3146
const result = [];
3247
const tknDocs = yamlLocator.getTknDocuments(document);
3348
for (const tknDoc of tknDocs) {
3449
const selectedElement = this.findTask(tknDoc, range.start);
3550
if (selectedElement) {
36-
const taskRefName = selectedElement.taskRef?.name.value
37-
if (!taskRefName){
38-
continue;
51+
52+
const inlineAction = this.getInlineAction(selectedElement, document);
53+
if (inlineAction) {
54+
result.push(inlineAction);
55+
}
56+
57+
const extractAction = this.getExtractTaskAction(selectedElement, document);
58+
if (extractAction) {
59+
result.push(extractAction);
3960
}
40-
const action: TaskInlineAction = new vscode.CodeAction(`Inline '${taskRefName}' Task spec`, vscode.CodeActionKind.RefactorInline.append('TektonTask'));
41-
const startPos = document.positionAt(selectedElement.taskRef?.keyNode?.startPosition);
42-
const endPos = document.positionAt(selectedElement.taskRef?.endPosition);
43-
action.taskRefStartPosition = startPos;
44-
action.taskRefEndPosition = endPos;
45-
action.taskRefName = taskRefName;
46-
action.taskKind = selectedElement.taskRef?.kind.value;
47-
action.documentUri = document.uri;
48-
result.push(action);
4961
}
5062
}
5163

5264
return result;
5365
}
54-
resolveCodeAction?(codeAction: TaskInlineAction): Thenable<vscode.CodeAction> {
55-
return vscode.window.withProgress({location: vscode.ProgressLocation.Notification, cancellable: false, title: `Loading '${codeAction.taskRefName}' Task...` }, async (): Promise<vscode.CodeAction> => {
56-
const uri = tektonFSUri(codeAction.taskKind === TektonYamlType.ClusterTask ? ContextType.CLUSTERTASK : ContextType.TASK , codeAction.taskRefName, 'yaml');
57-
try {
58-
const taskDoc = await tektonVfsProvider.loadTektonDocument(uri, false);
59-
codeAction.edit = new vscode.WorkspaceEdit();
60-
codeAction.edit.replace(codeAction.documentUri,
61-
new vscode.Range(codeAction.taskRefStartPosition, codeAction.taskRefEndPosition),
62-
this.extractTaskDef(taskDoc, codeAction.taskRefStartPosition.character, codeAction.taskRefEndPosition.character));
63-
} catch (err){
64-
vscode.window.showErrorMessage('Cannot get Tekton Task definition: ' + err.toString());
65-
telemetryLogError('resolveCodeAction', `Cannot get '${codeAction.taskRefName}' Task definition`);
66-
}
67-
return codeAction;
68-
});
66+
resolveCodeAction?(codeAction: vscode.CodeAction): Thenable<vscode.CodeAction> {
67+
if (isTaskInlineAction(codeAction)){
68+
return this.resolveInlineAction(codeAction);
69+
}
70+
71+
if (codeAction.kind.contains(EXTRACT_TASK)) {
72+
return this.resolveExtractTaskAction(codeAction);
73+
}
6974

7075
}
7176

7277
getProviderMetadata(): vscode.CodeActionProviderMetadata {
7378
return {
74-
providedCodeActionKinds: [vscode.CodeActionKind.RefactorInline.append('TektonTask')],
79+
providedCodeActionKinds: [INLINE_TASK, EXTRACT_TASK],
7580
}
7681
}
7782

@@ -97,6 +102,137 @@ class PipelineCodeActionProvider implements vscode.CodeActionProvider {
97102

98103
}
99104

105+
private getInlineAction(selectedElement: PipelineTask, document: vscode.TextDocument): InlineTaskAction | undefined {
106+
const taskRefName = selectedElement.taskRef?.name.value
107+
if (!taskRefName){
108+
return;
109+
}
110+
const action: InlineTaskAction = new vscode.CodeAction(`Inline '${taskRefName}' Task spec`, INLINE_TASK);
111+
const startPos = document.positionAt(selectedElement.taskRef?.keyNode?.startPosition);
112+
const endPos = document.positionAt(selectedElement.taskRef?.endPosition);
113+
action.taskRefStartPosition = startPos;
114+
action.taskRefEndPosition = endPos;
115+
action.taskRefName = taskRefName;
116+
action.taskKind = selectedElement.taskRef?.kind.value;
117+
action.documentUri = document.uri;
118+
119+
return action;
120+
}
121+
122+
private async resolveInlineAction(codeAction: InlineTaskAction): Promise<InlineTaskAction> {
123+
return vscode.window.withProgress({location: vscode.ProgressLocation.Notification, cancellable: false, title: `Loading '${codeAction.taskRefName}' Task...` }, async (): Promise<vscode.CodeAction> => {
124+
const uri = tektonFSUri(codeAction.taskKind === TektonYamlType.ClusterTask ? ContextType.CLUSTERTASK : ContextType.TASK , codeAction.taskRefName, 'yaml');
125+
try {
126+
const taskDoc = await tektonVfsProvider.loadTektonDocument(uri, false);
127+
codeAction.edit = new vscode.WorkspaceEdit();
128+
codeAction.edit.replace(codeAction.documentUri,
129+
new vscode.Range(codeAction.taskRefStartPosition, codeAction.taskRefEndPosition),
130+
this.extractTaskDef(taskDoc, codeAction.taskRefStartPosition.character, codeAction.taskRefEndPosition.character));
131+
} catch (err){
132+
vscode.window.showErrorMessage('Cannot get Tekton Task definition: ' + err.toString());
133+
telemetryLogError('resolveCodeAction', `Cannot get '${codeAction.taskRefName}' Task definition`);
134+
}
135+
return codeAction;
136+
});
137+
}
138+
139+
private getExtractTaskAction(selectedElement: PipelineTask, document: vscode.TextDocument): ExtractTaskAction | undefined {
140+
const taskSpec = selectedElement.taskSpec;
141+
if (!taskSpec) {
142+
return;
143+
}
144+
const startPos = document.positionAt(taskSpec.keyNode?.startPosition);
145+
let taskSpecStartPos = document.positionAt(taskSpec.startPosition);
146+
// start replace from stat of the line
147+
taskSpecStartPos = document.lineAt(taskSpecStartPos.line).range.start;
148+
let endPos = document.positionAt(taskSpec.endPosition);
149+
150+
// if last line is contains only spaces then replace til previous line
151+
const lastLine = document.getText(new vscode.Range(endPos.line, 0, endPos.line, endPos.character));
152+
if (lastLine.trim().length === 0) {
153+
endPos = document.lineAt(endPos.line - 1).range.end;
154+
}
155+
156+
const action: ExtractTaskAction = new vscode.CodeAction(`Extract '${selectedElement.name.value}' Task spec`, EXTRACT_TASK);
157+
action.documentUri = document.uri;
158+
action.taskSpecStartPosition = startPos;
159+
action.taskSpecEndPosition = endPos;
160+
action.taskSpecText = document.getText(new vscode.Range(taskSpecStartPos, endPos));
161+
162+
return action;
163+
}
164+
165+
private async resolveExtractTaskAction(action: ExtractTaskAction): Promise<vscode.CodeAction> {
166+
const name = await vscode.window.showInputBox({ignoreFocusOut: true, prompt: 'Provide Task Name' });
167+
const type = await vscode.window.showQuickPick(['Task', 'ClusterTask'], {placeHolder: 'Select Task Type:', canPickMany: false, ignoreFocusOut: true});
168+
if (!type || !name) {
169+
return;
170+
}
171+
return vscode.window.withProgress({location: vscode.ProgressLocation.Notification, cancellable: false, title: 'Extracting Task...' }, async (): Promise<vscode.CodeAction> => {
172+
try {
173+
const virtDoc = this.getDocForExtractedTask(name, type, action.taskSpecText);
174+
const saveError = await tektonVfsProvider.saveTektonDocument(virtDoc);
175+
if (saveError) {
176+
console.error(saveError);
177+
throw new Error(saveError);
178+
}
179+
const newUri = tektonFSUri(type, name, 'yaml');
180+
await vscode.commands.executeCommand('vscode.open', newUri);
181+
const indentation = ' '.repeat(action.taskSpecStartPosition.character);
182+
action.edit = new vscode.WorkspaceEdit();
183+
action.edit.replace(action.documentUri, new vscode.Range(action.taskSpecStartPosition, action.taskSpecEndPosition),
184+
`taskRef:
185+
${indentation}name: ${name}
186+
${indentation}kind: ${type}`);
187+
} catch (err) {
188+
console.error(err);
189+
}
190+
191+
return action;
192+
});
193+
}
194+
195+
private getDocForExtractedTask(name: string, type: string, content: string): VirtualDocument {
196+
197+
const lines = content.split('\n');
198+
const firstLine = lines[0].trimLeft();
199+
const indentation = lines[0].length - firstLine.length;
200+
lines[0] = firstLine;
201+
for (let i = 1; i < lines.length; i++) {
202+
lines[i] = lines[i].slice(indentation);
203+
}
204+
content = lines.join('\n');
205+
206+
const taskPart = jsYaml.load(content);
207+
let metadataPart: {} = undefined;
208+
if (taskPart.metadata) {
209+
metadataPart = taskPart.metadata;
210+
delete taskPart['metadata'];
211+
}
212+
213+
let metadataPartStr: string = undefined;
214+
if (metadataPart && !_.isEmpty(metadataPart)) {
215+
metadataPartStr = jsYaml.dump(metadataPart, {indent: 2});
216+
metadataPartStr = metadataPartStr.trimRight().split('\n').map(it => ' ' + it).join('\n');
217+
}
218+
let specContent = jsYaml.dump(taskPart, {indent: 2, noArrayIndent: false});
219+
specContent = specContent.trimRight().split('\n').map(it => ' ' + it).join('\n');
220+
return {
221+
version: 1,
222+
uri: vscode.Uri.file(`file:///extracted/task/${name}.yaml`),
223+
getText: () => {
224+
return `apiVersion: tekton.dev/v1beta1
225+
kind: ${type}
226+
metadata:
227+
name: ${name}\n${metadataPartStr ? metadataPartStr : ''}
228+
spec:
229+
${specContent}
230+
`;
231+
}
232+
}
233+
234+
}
235+
100236
private extractTaskDef(taskDoc: VirtualDocument, startPos: number, endPos): string {
101237
const task: Task = jsYaml.safeLoad(taskDoc.getText()) as Task;
102238
if (!task){

test/text-document-mock.ts

+31-4
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export class TestTextDocument implements vscode.TextDocument {
1515
isDirty: boolean;
1616
isClosed: boolean;
1717

18-
private text;
18+
private text: string;
1919

2020
constructor(public uri: vscode.Uri, text: string) {
2121
this.text = text.replace(/\r\n/gm, '\n'); // normalize end of line
@@ -27,11 +27,34 @@ export class TestTextDocument implements vscode.TextDocument {
2727
eol = vscode.EndOfLine.LF;
2828
lineCount: number;
2929

30+
private get lines(): string[] {
31+
return this.text.split('\n');
32+
}
33+
3034
lineAt(position: number | vscode.Position): vscode.TextLine {
31-
throw new Error('Method not implemented.');
35+
const line = typeof position === 'number' ? position : position.line
36+
const text = this.lines[line];
37+
return {
38+
text,
39+
range: new vscode.Range(line, 0, line, text.length)
40+
} as vscode.TextLine;
3241
}
42+
3343
offsetAt(position: vscode.Position): number {
34-
throw new Error('Method not implemented.');
44+
const lines = this.text.split('\n');
45+
let currentOffSet = 0;
46+
for (let i = 0; i < lines.length; i++) {
47+
const l = lines[i];
48+
if (position.line === i) {
49+
if (l.length < position.character) {
50+
throw new Error(`Position ${JSON.stringify(position)} is out of range. Line [${i}] only has length ${l.length}.`);
51+
}
52+
return currentOffSet + position.character;
53+
} else {
54+
currentOffSet += l.length + 1;
55+
}
56+
}
57+
throw new Error(`Position ${JSON.stringify(position)} is out of range. Document only has ${lines.length} lines.`);
3558
}
3659

3760
positionAt(offset: number): vscode.Position {
@@ -48,7 +71,11 @@ export class TestTextDocument implements vscode.TextDocument {
4871
throw new Error('Cannot find position!');
4972
}
5073
getText(range?: vscode.Range): string {
51-
return this.text;
74+
if (!range)
75+
return this.text;
76+
const offset = this.offsetAt(range.start);
77+
const length = this.offsetAt(range.end) - offset;
78+
return this.text.substr(offset, length);
5279
}
5380
getWordRangeAtPosition(position: vscode.Position, regex?: RegExp): vscode.Range {
5481
throw new Error('Method not implemented.');

0 commit comments

Comments
 (0)