diff --git a/app/assets/javascripts/blob/file_template_mediator.js b/app/assets/javascripts/blob/file_template_mediator.js index 136457c115d40f6c4b0373f644c94b8d4f165ae9..36146bc1dfbd58289a18149c86056090582dd421 100644 --- a/app/assets/javascripts/blob/file_template_mediator.js +++ b/app/assets/javascripts/blob/file_template_mediator.js @@ -247,7 +247,9 @@ export default class FileTemplateMediator { } setFilename(name) { - this.$filenameInput.val(name).trigger('change'); + const input = this.$filenameInput.get(0); + input.value = name; + input.dispatchEvent(new Event('change')); } getSelected() { diff --git a/app/assets/javascripts/blob_edit/edit_blob.js b/app/assets/javascripts/blob_edit/edit_blob.js index 118cef59d5a01b114af25d705485da129fd04cbb..4b2ffb110c79fe87b86eac7176618aab859de632 100644 --- a/app/assets/javascripts/blob_edit/edit_blob.js +++ b/app/assets/javascripts/blob_edit/edit_blob.js @@ -1,4 +1,5 @@ import $ from 'jquery'; +import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base'; import { FileTemplateExtension } from '~/editor/extensions/source_editor_file_template_ext'; import SourceEditor from '~/editor/source_editor'; import { getBlobLanguage } from '~/editor/utils'; @@ -60,7 +61,7 @@ export default class EditBlob { blobPath: fileNameEl.value, blobContent: editorEl.innerText, }); - this.editor.use(new FileTemplateExtension({ instance: this.editor })); + this.editor.use([{ definition: SourceEditorExtension }, { definition: FileTemplateExtension }]); fileNameEl.addEventListener('change', () => { this.editor.updateModelLanguage(fileNameEl.value); diff --git a/app/assets/javascripts/editor/extensions/source_editor_extension_base.js b/app/assets/javascripts/editor/extensions/source_editor_extension_base.js index 5fa01f03f7ed2a6c20f948348bf9a47b021c99a1..28ffc79cfce5b3096a8c5bdbd64fd75d1ddcd4fc 100644 --- a/app/assets/javascripts/editor/extensions/source_editor_extension_base.js +++ b/app/assets/javascripts/editor/extensions/source_editor_extension_base.js @@ -17,9 +17,8 @@ const createAnchor = (href) => { }; export class SourceEditorExtension { - constructor({ instance, ...options } = {}) { + onSetup(options, instance) { if (instance) { - Object.assign(instance, options); SourceEditorExtension.highlightLines(instance); if (instance.getEditorType && instance.getEditorType() === EDITOR_TYPE_CODE) { SourceEditorExtension.setupLineLinking(instance); diff --git a/app/assets/javascripts/editor/extensions/source_editor_file_template_ext.js b/app/assets/javascripts/editor/extensions/source_editor_file_template_ext.js index 397e090ed303312c51c55e3e484d3c7191da11ec..721b9b119e010c8359b4569d1305ad221429e26c 100644 --- a/app/assets/javascripts/editor/extensions/source_editor_file_template_ext.js +++ b/app/assets/javascripts/editor/extensions/source_editor_file_template_ext.js @@ -1,8 +1,12 @@ import { Position } from 'monaco-editor'; -import { SourceEditorExtension } from './source_editor_extension_base'; -export class FileTemplateExtension extends SourceEditorExtension { - navigateFileStart() { - this.setPosition(new Position(1, 1)); +export class FileTemplateExtension { + // eslint-disable-next-line class-methods-use-this + methods() { + return { + navigateFileStart: (instance) => { + instance.setPosition(new Position(1, 1)); + } + } } } diff --git a/app/assets/javascripts/editor/extensions/source_editor_webide_ext.js b/app/assets/javascripts/editor/extensions/source_editor_webide_ext.js index 98e05489c1c332ba6e37531db9a6b6218c049b50..62e6923e2d7dbb35e9e4286209def27c5339be6e 100644 --- a/app/assets/javascripts/editor/extensions/source_editor_webide_ext.js +++ b/app/assets/javascripts/editor/extensions/source_editor_webide_ext.js @@ -1,7 +1,6 @@ import { debounce } from 'lodash'; import { KeyCode, KeyMod, Range } from 'monaco-editor'; import { EDITOR_TYPE_DIFF } from '~/editor/constants'; -import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base'; import Disposable from '~/ide/lib/common/disposable'; import { editorOptions } from '~/ide/lib/editor_options'; import keymap from '~/ide/lib/keymap.json'; @@ -12,27 +11,62 @@ const isDiffEditorType = (instance) => { export const UPDATE_DIMENSIONS_DELAY = 200; -export class EditorWebIdeExtension extends SourceEditorExtension { - constructor({ instance, modelManager, ...options } = {}) { - super({ - instance, - ...options, - modelManager, - disposable: new Disposable(), - debouncedUpdate: debounce(() => { - instance.updateDimensions(); - }, UPDATE_DIMENSIONS_DELAY), +const addActions = (instance) => { + const { store } = instance; + const getKeyCode = (key) => { + const monacoKeyMod = key.indexOf('KEY_') === 0; + + return monacoKeyMod ? KeyCode[key] : KeyMod[key]; + }; + + keymap.forEach((command) => { + const { bindings, id, label, action } = command; + + const keybindings = bindings.map((binding) => { + const keys = binding.split('+'); + + // eslint-disable-next-line no-bitwise + return keys.length > 1 ? getKeyCode(keys[0]) | getKeyCode(keys[1]) : getKeyCode(keys[0]); }); - window.addEventListener('resize', instance.debouncedUpdate, false); + instance.addAction({ + id, + label, + keybindings, + run() { + store.dispatch(action.name, action.params); + return null; + }, + }); + }); +} +const renderSideBySide = (domElement) => { + return domElement.offsetWidth >= 700; +} + +export class EditorWebIdeExtension { + onSetup(setupOptions = {}, instance) { + this.disposable = new Disposable(); + this.modelManager = setupOptions.modelManager; + this.store = setupOptions.store; + this.file = setupOptions.file; + this.options = setupOptions.editorOptions; + this.debouncedUpdate = debounce(() => { + instance.updateDimensions(); + }, UPDATE_DIMENSIONS_DELAY); + addActions(instance); + } + + onUse(instance) { + window.addEventListener('resize', this.debouncedUpdate, false); instance.onDidDispose(() => { - window.removeEventListener('resize', instance.debouncedUpdate); + window.removeEventListener('resize', this.debouncedUpdate); // catch any potential errors with disposing the error // this is mainly for tests caused by elements not existing try { - instance.disposable.dispose(); + this.disposable.dispose(); } catch (e) { if (process.env.NODE_ENV !== 'test') { // eslint-disable-next-line no-console @@ -40,125 +74,93 @@ export class EditorWebIdeExtension extends SourceEditorExtension { } } }); - - EditorWebIdeExtension.addActions(instance); - } - - static addActions(instance) { - const { store } = instance; - const getKeyCode = (key) => { - const monacoKeyMod = key.indexOf('KEY_') === 0; - - return monacoKeyMod ? KeyCode[key] : KeyMod[key]; - }; - - keymap.forEach((command) => { - const { bindings, id, label, action } = command; - - const keybindings = bindings.map((binding) => { - const keys = binding.split('+'); - - // eslint-disable-next-line no-bitwise - return keys.length > 1 ? getKeyCode(keys[0]) | getKeyCode(keys[1]) : getKeyCode(keys[0]); - }); - - instance.addAction({ - id, - label, - keybindings, - run() { - store.dispatch(action.name, action.params); - return null; - }, - }); - }); } - createModel(file, head = null) { - return this.modelManager.addModel(file, head); - } - - attachModel(model) { - if (isDiffEditorType(this)) { - this.setModel({ - original: model.getOriginalModel(), - modified: model.getModel(), - }); - - return; - } - - this.setModel(model.getModel()); - - this.updateOptions( - editorOptions.reduce((acc, obj) => { - Object.keys(obj).forEach((key) => { - Object.assign(acc, { - [key]: obj[key](model), + methods() { + return { + createModel: (file, head = null) => { + return this.modelManager.addModel(file, head); + }, + + attachModel: (model, instance) => { + if (isDiffEditorType(instance)) { + instance.setModel({ + original: model.getOriginalModel(), + modified: model.getModel(), }); - }); - return acc; - }, {}), - ); - } - attachMergeRequestModel(model) { - this.setModel({ - original: model.getBaseModel(), - modified: model.getModel(), - }); - } + return; + } - updateDimensions() { - this.layout(); - this.updateDiffView(); - } + instance.setModel(model.getModel()); + + instance.updateOptions( + editorOptions.reduce((acc, obj) => { + Object.keys(obj).forEach((key) => { + Object.assign(acc, { + [key]: obj[key](model), + }); + }); + return acc; + }, {}), + ); + }, + + attachMergeRequestModel: (model, instance) => { + instance.setModel({ + original: model.getBaseModel(), + modified: model.getModel(), + }); + }, - setPos({ lineNumber, column }) { - this.revealPositionInCenter({ - lineNumber, - column, - }); - this.setPosition({ - lineNumber, - column, - }); - } + updateDimensions: (instance) => { + instance.layout(); + instance.updateDiffView(); + }, - onPositionChange(cb) { - if (!this.onDidChangeCursorPosition) { - return; - } + setPos: ({ lineNumber, column }, instance) => { + instance.revealPositionInCenter({ + lineNumber, + column, + }); + instance.setPosition({ + lineNumber, + column, + }); + }, - this.disposable.add(this.onDidChangeCursorPosition((e) => cb(this, e))); - } + onPositionChange: (cb, instance) => { + if (!instance.onDidChangeCursorPosition) { + return; + } - updateDiffView() { - if (!isDiffEditorType(this)) { - return; - } + this.disposable.add(instance.onDidChangeCursorPosition((e) => cb(instance, e))); + }, - this.updateOptions({ - renderSideBySide: EditorWebIdeExtension.renderSideBySide(this.getDomNode()), - }); - } + updateDiffView: (instance) => { + if (!isDiffEditorType(instance)) { + return; + } - replaceSelectedText(text) { - let selection = this.getSelection(); - const range = new Range( - selection.startLineNumber, - selection.startColumn, - selection.endLineNumber, - selection.endColumn, - ); + instance.updateOptions({ + renderSideBySide: renderSideBySide(instance.getDomNode()), + }); + }, - this.executeEdits('', [{ range, text }]); + replaceSelectedText: (text, instance) => { + let selection = instance.getSelection(); + const range = new Range( + selection.startLineNumber, + selection.startColumn, + selection.endLineNumber, + selection.endColumn, + ); - selection = this.getSelection(); - this.setPosition({ lineNumber: selection.endLineNumber, column: selection.endColumn }); - } + instance.executeEdits('', [{ range, text }]); - static renderSideBySide(domElement) { - return domElement.offsetWidth >= 700; + selection = instance.getSelection(); + instance.setPosition({ lineNumber: selection.endLineNumber, column: selection.endColumn }); + } + } } } diff --git a/app/assets/javascripts/editor/source_editor.js b/app/assets/javascripts/editor/source_editor.js index 81ddf8d77fa12fdd5c6696249c08348eb7339e0f..47368dd5154831b8939852958efead267969bf17 100644 --- a/app/assets/javascripts/editor/source_editor.js +++ b/app/assets/javascripts/editor/source_editor.js @@ -1,3 +1,4 @@ +import { isEqual } from 'lodash'; import { editor as monacoEditor, Uri } from 'monaco-editor'; import { defaultEditorOptions } from '~/ide/lib/editor_options'; import languages from '~/ide/lib/languages'; @@ -12,9 +13,237 @@ import { } from './constants'; import { clearDomElement, setupEditorTheme, getBlobLanguage } from './utils'; +export class EditorExtension { + constructor(extension) { + const { definition, setupOptions } = extension; + if (typeof definition !== 'function') { + throw new Error('Extension definition should be either class or function'); + } + this.name = definition.name; + this.setupOptions = setupOptions; + // eslint-disable-next-line new-cap + this.obj = new definition(); + } + + getExtensionObject() { + return this.obj; + } + + get(property) { + const extensionObj = this.getExtensionObject(); + const prop = extensionObj[property]; + if (typeof prop === 'function') { + return prop.call(extensionObj); + } + return prop; + } +} + +export class EditorInstance { + constructor(extensionsStore = new Map(), monacoInstance) { + this.methods = new Map(); + const seInstance = this; + const getHandler = { + get(target, prop, receiver) { + const methodExtensions = seInstance.methods.get(prop); // Value is always returned as Array + if (methodExtensions && methodExtensions.length) { + const extension = extensionsStore.get(methodExtensions[0]); + const extMethods = extension.get('methods'); + + return (...args) => { + return extMethods[prop].call(seInstance, ...args, receiver); + }; + } + return seInstance[prop] + ? Reflect.get(seInstance, prop, receiver) + : Reflect.get(target, prop, receiver); + }, + }; + const instProxy = new Proxy(monacoInstance, getHandler); + + this.use = this.useUnuse.bind(instProxy, extensionsStore, this.useExtension); + this.unuse = this.useUnuse.bind(instProxy, extensionsStore, this.unuseExtension); + + return instProxy; + } + + static storeExtMethod(method, extensionName, container) { + if (!container) { + return; + } + const existingEntry = container.get(method); + if (existingEntry && !existingEntry.includes(extensionName)) { + container.set(method, [extensionName, ...existingEntry]); + } else { + container.set(method, [extensionName]); + } + } + + static removeExtFromMethod(method, extensionName, container) { + if (!container) { + return; + } + const existingEntry = container.get(method); + if (existingEntry) { + const entryIndex = existingEntry.findIndex((ext) => ext === extensionName); + if (entryIndex >= 0) { + existingEntry.splice(entryIndex, 1); + if (!existingEntry.length) { + container.delete(method); + } else { + container.set(method, existingEntry); + } + } + } + } + + static getStoredExtension(extensionsStore, name) { + if (!extensionsStore) { + console.log('extensionsStore is required to check for an extension'); + return undefined; + } + return extensionsStore.has(name) ? extensionsStore.get(name) : undefined; + } + + static getExtensionNameFromDefinition(def) { + return def?.name; + } + + static areOptionsTheSame(opt1, opt2) { + return isEqual(opt1, opt2); + } + + useUnuse(extensionsStore, fn, extensions = {}) { + if (Array.isArray(extensions)) { + const exts = new Array(extensions.length); + extensions.forEach((ext, i) => { + exts[i] = fn.call(this, extensionsStore, ext); + }); + return exts; + } + return fn.call(this, extensionsStore, extensions); + } + + // + // REGISTERING NEW EXTENSION + // + useExtension(extensionsStore, extensionDefinition = {}) { + const { definition } = extensionDefinition; + if (!definition) { + throw new Error('`definition` property is expected on the extension'); + } + if (typeof definition !== 'function') { + throw new Error('Extension definition should be either class or function'); + } + + // Existing Extension Path + const nameFromDefinition = EditorInstance.getExtensionNameFromDefinition(definition); + const existingExt = EditorInstance.getStoredExtension(extensionsStore, nameFromDefinition); + if (existingExt) { + if ( + EditorInstance.areOptionsTheSame(extensionDefinition.setupOptions, existingExt.setupOptions) + ) { + return existingExt; + } + this.unuseExtension(extensionsStore, existingExt); + } + + // New Extension Path + const extension = new EditorExtension(extensionDefinition); + const { name } = extension; + this.extensionOnSetup(extension.getExtensionObject(), extension.setupOptions); + if (extensionsStore) { + this.registerExtension(name, extension, extensionsStore); + } + this.registerExtensionMethods(name, extension); + return extension; + } + + registerExtension(name, extension, extensionsStore) { + if (extensionsStore.has(name)) { + return; + } + extensionsStore.set(name, extension); + this.extensionOnUse(extension.getExtensionObject()); + } + + registerExtensionMethods(name, extension) { + const methods = extension.get('methods'); + if (methods) { + Object.keys(methods).forEach((method) => { + EditorInstance.storeExtMethod(method, name, this.methods); + }); + } + } + + // + // UNREGISTERING AN EXTENSION + // + unuseExtension(extensionsStore, extension) { + if (!extension) { + throw new Error('No extension for unuse has been specified'); + } + const { name } = extension; + const existingExt = EditorInstance.getStoredExtension(extensionsStore, name); + if (!existingExt) { + throw new Error(`${name} is not registered`); + } + this.unregisterExtension(name, existingExt, extensionsStore); + this.unregisterExtensionMethods(name, existingExt); + } + + unregisterExtension(name, extension, extensionsStore) { + const extObj = extension.getExtensionObject(); + this.extensionOnBeforeUnuse(extObj); + extensionsStore.delete(name); + this.extensionOnUnuse(extObj); + } + + unregisterExtensionMethods(name, extension) { + const methods = extension.get('methods'); + Object.keys(methods).forEach((method) => { + EditorInstance.removeExtFromMethod(method, name, this.methods); + }); + } + + // + // EXTENSION LIFE-CYCLE CALLBACKS + // + extensionOnSetup(extensionObj, options) { + if (extensionObj.onSetup) { + extensionObj.onSetup({ ...options }, this); + } + } + + extensionOnUse(extensionObj) { + if (extensionObj.onUse) { + extensionObj.onUse(this); + } + } + + extensionOnBeforeUnuse(extensionObj) { + if (extensionObj.onBeforeUnuse) { + extensionObj.onBeforeUnuse(this); + } + } + + extensionOnUnuse(extensionObj) { + if (extensionObj.onUnuse) { + extensionObj.onUnuse(this); + } + } + + updateModelLanguage(path) { + const lang = getBlobLanguage(path); + const model = this.getModel(); + return monacoEditor.setModelLanguage(model, lang); + } +} + export default class SourceEditor { constructor(options = {}) { this.instances = []; + this.extensions = new Map(); this.options = { extraEditorClassName: 'gl-source-editor', ...defaultEditorOptions, @@ -115,32 +344,6 @@ export default class SourceEditor { return diffModel; } - static convertMonacoToELInstance = (inst) => { - const sourceEditorInstanceAPI = { - updateModelLanguage: (path) => { - return SourceEditor.instanceUpdateLanguage(inst, path); - }, - use: (exts = []) => { - return SourceEditor.instanceApplyExtension(inst, exts); - }, - }; - const handler = { - get(target, prop, receiver) { - if (Reflect.has(sourceEditorInstanceAPI, prop)) { - return sourceEditorInstanceAPI[prop]; - } - return Reflect.get(target, prop, receiver); - }, - }; - return new Proxy(inst, handler); - }; - - static instanceUpdateLanguage(inst, path) { - const lang = getBlobLanguage(path); - const model = inst.getModel(); - return monacoEditor.setModelLanguage(model, lang); - } - static instanceApplyExtension(inst, exts = []) { const extensions = [].concat(exts); extensions.forEach((extension) => { @@ -194,7 +397,8 @@ export default class SourceEditor { SourceEditor.prepareInstance(el); const createEditorFn = isDiff ? 'createDiffEditor' : 'create'; - const instance = SourceEditor.convertMonacoToELInstance( + const instance = new EditorInstance( + this.extensions, monacoEditor[createEditorFn].call(this, el, { ...this.options, ...instanceOptions, @@ -219,7 +423,6 @@ export default class SourceEditor { }); SourceEditor.manageDefaultExtensions(instance, el, extensions); - this.instances.push(instance); return instance; } diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue index 2bf99550bf22908f534fdc0c0861c7acfd437833..3865031f3f17efee1727f83633ffeaaf810c5689 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_CODE_INSTANCE_FN, EDITOR_DIFF_INSTANCE_FN, } from '~/editor/constants'; +import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base'; import { EditorWebIdeExtension } from '~/editor/extensions/source_editor_webide_ext'; import SourceEditor from '~/editor/source_editor'; import createFlash from '~/flash'; @@ -64,6 +65,7 @@ export default { modelManager: new ModelManager(), isEditorLoading: true, unwatchCiYaml: null, + markdownExt: null, }; }, computed: { @@ -303,15 +305,20 @@ export default { ...this.editorOptions, }); - this.editor.use( - new EditorWebIdeExtension({ - instance: this.editor, - modelManager: this.modelManager, - store: this.$store, - file: this.file, - options: this.editorOptions, - }), - ); + this.editor.use([ + { + definition: SourceEditorExtension, + }, + { + definition: EditorWebIdeExtension, + setupOptions: { + modelManager: this.modelManager, + store: this.$store, + file: this.file, + options: this.editorOptions, + }, + }, + ]); if ( this.fileType === MARKDOWN_FILE_TYPE && @@ -320,12 +327,12 @@ export default { ) { import('~/editor/extensions/source_editor_markdown_ext') .then(({ EditorMarkdownExtension: MarkdownExtension } = {}) => { - this.editor.use( - new MarkdownExtension({ - instance: this.editor, + this.markdownExt = this.editor.use({ + definition: MarkdownExtension, + setupOption: { previewMarkdownPath: this.previewMarkdownPath, - }), - ); + }, + }); }) .catch((e) => createFlash({