From a587d292ba4997e050a0e5070437dbf7d9754554 Mon Sep 17 00:00:00 2001 From: Denys Mishunov Date: Fri, 1 Oct 2021 20:31:53 +0200 Subject: [PATCH 1/2] Init commit --- .../blob/file_template_mediator.js | 4 +- app/assets/javascripts/blob_edit/edit_blob.js | 3 +- .../source_editor_extension_base.js | 3 +- .../source_editor_file_template_ext.js | 12 +- .../javascripts/editor/source_editor.js | 255 ++++++++++++++++-- 5 files changed, 241 insertions(+), 36 deletions(-) diff --git a/app/assets/javascripts/blob/file_template_mediator.js b/app/assets/javascripts/blob/file_template_mediator.js index 136457c115d40f..36146bc1dfbd58 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 118cef59d5a01b..4b2ffb110c79fe 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 5fa01f03f7ed2a..28ffc79cfce5b3 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 397e090ed30331..721b9b119e010c 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/source_editor.js b/app/assets/javascripts/editor/source_editor.js index 81ddf8d77fa12f..6d23689990cc9b 100644 --- a/app/assets/javascripts/editor/source_editor.js +++ b/app/assets/javascripts/editor/source_editor.js @@ -12,9 +12,234 @@ 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 getHandler = { + get(target, prop, receiver) { + const methodExtensions = target.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(target, ...args, receiver); + }; + } + return target[prop]? Reflect.get(target, prop, receiver): Reflect.get(monacoInstance, prop); + }, + }; + const instProxy = new Proxy(this, 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 JSON.stringify(opt1) === JSON.stringify(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 +340,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 +393,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 +419,6 @@ export default class SourceEditor { }); SourceEditor.manageDefaultExtensions(instance, el, extensions); - this.instances.push(instance); return instance; } -- GitLab From cbe18c4d5f77ea32050e4f6e5167eb19fa92b1d0 Mon Sep 17 00:00:00 2001 From: Denys Mishunov Date: Mon, 4 Oct 2021 15:00:10 +0200 Subject: [PATCH 2/2] Migrating the extensions --- .../extensions/source_editor_webide_ext.js | 242 +++++++++--------- .../javascripts/editor/source_editor.js | 16 +- .../ide/components/repo_editor.vue | 35 ++- 3 files changed, 153 insertions(+), 140 deletions(-) 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 98e05489c1c332..62e6923e2d7dbb 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 6d23689990cc9b..47368dd5154831 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'; @@ -41,21 +42,24 @@ export class EditorExtension { export class EditorInstance { constructor(extensionsStore = new Map(), monacoInstance) { this.methods = new Map(); + const seInstance = this; const getHandler = { get(target, prop, receiver) { - const methodExtensions = target.methods.get(prop); // Value is always returned as Array + 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(target, ...args, receiver); + return extMethods[prop].call(seInstance, ...args, receiver); }; } - return target[prop]? Reflect.get(target, prop, receiver): Reflect.get(monacoInstance, prop); + return seInstance[prop] + ? Reflect.get(seInstance, prop, receiver) + : Reflect.get(target, prop, receiver); }, }; - const instProxy = new Proxy(this, getHandler); + const instProxy = new Proxy(monacoInstance, getHandler); this.use = this.useUnuse.bind(instProxy, extensionsStore, this.useExtension); this.unuse = this.useUnuse.bind(instProxy, extensionsStore, this.unuseExtension); @@ -106,7 +110,7 @@ export class EditorInstance { } static areOptionsTheSame(opt1, opt2) { - return JSON.stringify(opt1) === JSON.stringify(opt2); + return isEqual(opt1, opt2); } useUnuse(extensionsStore, fn, extensions = {}) { @@ -165,7 +169,7 @@ export class EditorInstance { registerExtensionMethods(name, extension) { const methods = extension.get('methods'); - if(methods) { + if (methods) { Object.keys(methods).forEach((method) => { EditorInstance.storeExtMethod(method, name, this.methods); }); diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue index 2bf99550bf2290..3865031f3f17ef 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({ -- GitLab