diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue index 5f35dbdc5e7317f583bb9416b217af19067a6fff..3c9c0b1ade1faf5cfe0a6e0fa090b6dbf28288ea 100644 --- a/app/assets/javascripts/ide/components/repo_editor.vue +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -7,6 +7,7 @@ import { EDITOR_TYPE_CODE, EDITOR_CODE_INSTANCE_FN, EDITOR_DIFF_INSTANCE_FN, + EXTENSION_CI_SCHEMA_FILE_NAME_MATCH, } from '~/editor/constants'; import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base'; import { EditorWebIdeExtension } from '~/editor/extensions/source_editor_webide_ext'; @@ -26,6 +27,7 @@ import { performanceMarkAndMeasure } from '~/performance/utils'; import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue'; import { viewerInformationForPath } from '~/vue_shared/components/content_viewer/lib/viewer_utils'; import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { leftSidebarViews, viewerTypes, @@ -53,6 +55,7 @@ export default { DiffViewer, FileTemplatesBar, }, + mixins: [glFeatureFlagMixin()], props: { file: { type: Object, @@ -145,6 +148,12 @@ export default { showTabs() { return !this.shouldHideEditor && this.isEditModeActive && this.previewMode; }, + isCiConfigFile() { + return ( + this.file.path === EXTENSION_CI_SCHEMA_FILE_NAME_MATCH && + this.editor?.getEditorType() === EDITOR_TYPE_CODE + ); + }, }, watch: { 'file.name': { @@ -232,8 +241,6 @@ export default { return; } - this.registerSchemaForFile(); - Promise.all([this.fetchFileData(), this.fetchEditorconfigRules()]) .then(() => { this.createEditorInstance(); @@ -357,6 +364,8 @@ export default { this.model.updateOptions(this.rules); + this.registerSchemaForFile(); + this.model.onChange((model) => { const { file } = model; if (!file.active) return; @@ -446,8 +455,33 @@ export default { return Promise.resolve(); }, registerSchemaForFile() { - const schema = this.getJsonSchemaForPath(this.file.path); - registerSchema(schema); + const registerExternalSchema = () => { + const schema = this.getJsonSchemaForPath(this.file.path); + return registerSchema(schema); + }; + const registerLocalSchema = async () => { + if (!this.CiSchemaExtension) { + const { CiSchemaExtension } = await import( + '~/editor/extensions/source_editor_ci_schema_ext' + ).catch((e) => + createAlert({ + message: e, + }), + ); + this.CiSchemaExtension = CiSchemaExtension; + } + this.editor.use({ definition: this.CiSchemaExtension }); + this.editor.registerCiSchema(); + }; + + if (this.isCiConfigFile && this.glFeatures.schemaLinting) { + registerLocalSchema(); + } else { + if (this.CiSchemaExtension) { + this.editor.unuse(this.CiSchemaExtension); + } + registerExternalSchema(); + } }, updateEditor(data) { // Looks like our model wrapper `.dispose` causes the monaco editor to emit some position changes after diff --git a/spec/frontend/ide/components/repo_editor_spec.js b/spec/frontend/ide/components/repo_editor_spec.js index 9921d8cba18730a8025cc5db4b5688ec60d5f6ae..2f9fd957c6b6fb51e689de5d64ff4d9494bb1dee 100644 --- a/spec/frontend/ide/components/repo_editor_spec.js +++ b/spec/frontend/ide/components/repo_editor_spec.js @@ -8,9 +8,14 @@ import '~/behaviors/markdown/render_gfm'; import waitForPromises from 'helpers/wait_for_promises'; import { stubPerformanceWebAPI } from 'helpers/performance'; import { exampleConfigs, exampleFiles } from 'jest/ide/lib/editorconfig/mock_data'; -import { EDITOR_CODE_INSTANCE_FN, EDITOR_DIFF_INSTANCE_FN } from '~/editor/constants'; +import { + EDITOR_CODE_INSTANCE_FN, + EDITOR_DIFF_INSTANCE_FN, + EXTENSION_CI_SCHEMA_FILE_NAME_MATCH, +} from '~/editor/constants'; import { EditorMarkdownExtension } from '~/editor/extensions/source_editor_markdown_ext'; import { EditorMarkdownPreviewExtension } from '~/editor/extensions/source_editor_markdown_livepreview_ext'; +import { CiSchemaExtension } from '~/editor/extensions/source_editor_ci_schema_ext'; import SourceEditor from '~/editor/source_editor'; import RepoEditor from '~/ide/components/repo_editor.vue'; import { leftSidebarViews, FILE_VIEW_MODE_PREVIEW, viewerTypes } from '~/ide/constants'; @@ -22,6 +27,8 @@ import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer import SourceEditorInstance from '~/editor/source_editor_instance'; import { file } from '../helpers'; +jest.mock('~/editor/extensions/source_editor_ci_schema_ext'); + const PREVIEW_MARKDOWN_PATH = '/foo/bar/preview_markdown'; const CURRENT_PROJECT_ID = 'gitlab-org/gitlab'; @@ -46,6 +53,12 @@ const dummyFile = { tempFile: true, active: true, }, + ciConfig: { + ...file(EXTENSION_CI_SCHEMA_FILE_NAME_MATCH), + content: '', + tempFile: true, + active: true, + }, empty: { ...file('empty'), tempFile: false, @@ -101,6 +114,7 @@ describe('RepoEditor', () => { let createDiffInstanceSpy; let createModelSpy; let applyExtensionSpy; + let removeExtensionSpy; let extensionsStore; const waitForEditorSetup = () => @@ -108,7 +122,7 @@ describe('RepoEditor', () => { vm.$once('editorSetup', resolve); }); - const createComponent = async ({ state = {}, activeFile = dummyFile.text } = {}) => { + const createComponent = async ({ state = {}, activeFile = dummyFile.text, flags = {} } = {}) => { const store = prepareStore(state, activeFile); wrapper = shallowMount(RepoEditor, { store, @@ -118,6 +132,9 @@ describe('RepoEditor', () => { mocks: { ContentViewer, }, + provide: { + glFeatures: flags, + }, }); await waitForPromises(); vm = wrapper.vm; @@ -137,6 +154,7 @@ describe('RepoEditor', () => { createDiffInstanceSpy = jest.spyOn(SourceEditor.prototype, EDITOR_DIFF_INSTANCE_FN); createModelSpy = jest.spyOn(monacoEditor, 'createModel'); applyExtensionSpy = jest.spyOn(SourceEditorInstance.prototype, 'use'); + removeExtensionSpy = jest.spyOn(SourceEditorInstance.prototype, 'unuse'); jest.spyOn(service, 'getFileData').mockResolvedValue(); jest.spyOn(service, 'getRawFileData').mockResolvedValue(); }); @@ -177,6 +195,76 @@ describe('RepoEditor', () => { }); }); + describe('schema registration for .gitlab-ci.yml', () => { + const setup = async (activeFile, flagIsOn = true) => { + await createComponent({ + flags: { + schemaLinting: flagIsOn, + }, + }); + vm.editor.registerCiSchema = jest.fn(); + if (activeFile) { + wrapper.setProps({ file: activeFile }); + } + await waitForPromises(); + await nextTick(); + }; + it.each` + flagIsOn | activeFile | shouldUseExtension | desc + ${false} | ${dummyFile.markdown} | ${false} | ${`file is not CI config; should NOT`} + ${true} | ${dummyFile.markdown} | ${false} | ${`file is not CI config; should NOT`} + ${false} | ${dummyFile.ciConfig} | ${false} | ${`file is CI config; should NOT`} + ${true} | ${dummyFile.ciConfig} | ${true} | ${`file is CI config; should`} + `( + 'when the flag is "$flagIsOn", $desc use extension', + async ({ flagIsOn, activeFile, shouldUseExtension }) => { + await setup(activeFile, flagIsOn); + + if (shouldUseExtension) { + expect(applyExtensionSpy).toHaveBeenCalledWith({ + definition: CiSchemaExtension, + }); + } else { + expect(applyExtensionSpy).not.toHaveBeenCalledWith({ + definition: CiSchemaExtension, + }); + } + }, + ); + it('stores the fetched extension and does not double-fetch the schema', async () => { + await setup(); + expect(CiSchemaExtension).toHaveBeenCalledTimes(0); + + wrapper.setProps({ file: dummyFile.ciConfig }); + await waitForPromises(); + await nextTick(); + expect(CiSchemaExtension).toHaveBeenCalledTimes(1); + expect(vm.CiSchemaExtension).toEqual(CiSchemaExtension); + expect(vm.editor.registerCiSchema).toHaveBeenCalledTimes(1); + + wrapper.setProps({ file: dummyFile.markdown }); + await waitForPromises(); + await nextTick(); + expect(CiSchemaExtension).toHaveBeenCalledTimes(1); + expect(vm.editor.registerCiSchema).toHaveBeenCalledTimes(1); + + wrapper.setProps({ file: dummyFile.ciConfig }); + await waitForPromises(); + await nextTick(); + expect(CiSchemaExtension).toHaveBeenCalledTimes(1); + expect(vm.editor.registerCiSchema).toHaveBeenCalledTimes(2); + }); + it('unuses the existing CI extension if the new model is not CI config', async () => { + await setup(dummyFile.ciConfig); + + expect(removeExtensionSpy).not.toHaveBeenCalled(); + wrapper.setProps({ file: dummyFile.markdown }); + await waitForPromises(); + await nextTick(); + expect(removeExtensionSpy).toHaveBeenCalledWith(CiSchemaExtension); + }); + }); + describe('when file is markdown', () => { let mock; let activeFile;