diff options
Diffstat (limited to 'app/assets/javascripts/editor')
7 files changed, 774 insertions, 47 deletions
diff --git a/app/assets/javascripts/editor/constants.js b/app/assets/javascripts/editor/constants.js index d40d19000fb..e855e304d27 100644 --- a/app/assets/javascripts/editor/constants.js +++ b/app/assets/javascripts/editor/constants.js @@ -1,17 +1,9 @@ import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; -import { __ } from '~/locale'; - -export const SOURCE_EDITOR_INSTANCE_ERROR_NO_EL = __( - '"el" parameter is required for createInstance()', -); +import { s__ } from '~/locale'; export const URI_PREFIX = 'gitlab'; export const CONTENT_UPDATE_DEBOUNCE = DEFAULT_DEBOUNCE_AND_THROTTLE_MS; -export const ERROR_INSTANCE_REQUIRED_FOR_EXTENSION = __( - 'Source Editor instance is required to set up an extension.', -); - export const EDITOR_READY_EVENT = 'editor-ready'; export const EDITOR_TYPE_CODE = 'vs.editor.ICodeEditor'; @@ -20,6 +12,32 @@ export const EDITOR_TYPE_DIFF = 'vs.editor.IDiffEditor'; export const EDITOR_CODE_INSTANCE_FN = 'createInstance'; export const EDITOR_DIFF_INSTANCE_FN = 'createDiffInstance'; +export const SOURCE_EDITOR_INSTANCE_ERROR_NO_EL = s__( + 'SourceEditor|"el" parameter is required for createInstance()', +); +export const ERROR_INSTANCE_REQUIRED_FOR_EXTENSION = s__( + 'SourceEditor|Source Editor instance is required to set up an extension.', +); +export const EDITOR_EXTENSION_DEFINITION_ERROR = s__( + 'SourceEditor|Extension definition should be either a class or a function', +); +export const EDITOR_EXTENSION_NO_DEFINITION_ERROR = s__( + 'SourceEditor|`definition` property is expected on the extension.', +); +export const EDITOR_EXTENSION_DEFINITION_TYPE_ERROR = s__( + 'SourceEditor|Extension definition should be either class, function, or an Array of definitions.', +); +export const EDITOR_EXTENSION_NOT_SPECIFIED_FOR_UNUSE_ERROR = s__( + 'SourceEditor|No extension for unuse has been specified.', +); +export const EDITOR_EXTENSION_NOT_REGISTERED_ERROR = s__('SourceEditor|%{name} is not registered.'); +export const EDITOR_EXTENSION_NAMING_CONFLICT_ERROR = s__( + 'SourceEditor|Name conflict for "%{prop}()" method.', +); +export const EDITOR_EXTENSION_STORE_IS_MISSING_ERROR = s__( + 'SourceEditor|Extensions Store is required to check for an extension.', +); + // // EXTENSIONS' CONSTANTS // diff --git a/app/assets/javascripts/editor/extensions/example_source_editor_extension.js b/app/assets/javascripts/editor/extensions/example_source_editor_extension.js new file mode 100644 index 00000000000..119a2aea9eb --- /dev/null +++ b/app/assets/javascripts/editor/extensions/example_source_editor_extension.js @@ -0,0 +1,116 @@ +// THIS IS AN EXAMPLE +// +// This file contains a basic documented example of the Source Editor extensions' +// API for your convenience. You can copy/paste it into your own file +// and adjust as you see fit +// + +export class MyFancyExtension { + /** + * THE LIFE-CYCLE CALLBACKS + */ + + /** + * Is called before the extension gets used by an instance, + * Use `onSetup` to setup Monaco directly: + * actions, keystrokes, update options, etc. + * Is called only once before the extension gets registered + * + * @param { Object } [setupOptions] The setupOptions object + * @param { Object } [instance] The Source Editor instance + */ + // eslint-disable-next-line class-methods-use-this,no-unused-vars + onSetup(setupOptions, instance) {} + + /** + * The first thing called after the extension is + * registered and used by an instance. + * Is called every time the extension is applied + * + * @param { Object } [instance] The Source Editor instance + */ + // eslint-disable-next-line class-methods-use-this,no-unused-vars + onUse(instance) {} + + /** + * Is called before un-using an extension. Can be used for time-critical + * actions like cleanup, reverting visual changes, and other user-facing + * updates. + * + * @param { Object } [instance] The Source Editor instance + */ + // eslint-disable-next-line class-methods-use-this,no-unused-vars + onBeforeUnuse(instance) {} + + /** + * Is called right after an extension is removed from an instance (un-used) + * Can be used for non time-critical tasks like cleanup on the Monaco level + * (removing actions, keystrokes, etc.). + * onUnuse() will be executed during the browser's idle period + * (https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback) + * + * @param { Object } [instance] The Source Editor instance + */ + // eslint-disable-next-line class-methods-use-this,no-unused-vars + onUnuse(instance) {} + + /** + * The public API of the extension: these are the methods that will be exposed + * to the end user + * @returns {Object} + */ + provides() { + return { + basic: () => { + // The most basic method not depending on anything + // Use: instance.basic(); + // eslint-disable-next-line @gitlab/require-i18n-strings + return 'Foo Bar'; + }, + basicWithProp: () => { + // The methods with access to the props of the extension. + // The props can be either hardcoded (for example in `onSetup`), or + // can be dynamically passed as part of `setupOptions` object when + // using the extension. + // Use: instance.use({ definition: MyFancyExtension, setupOptions: { foo: 'bar' }}); + return this.foo; + }, + basicWithPropsAsList: (prop1, prop2) => { + // Just a simple method with local props + // The props are passed as usually. + // Use: instance.basicWithPropsAsList(prop1, prop2); + // eslint-disable-next-line @gitlab/require-i18n-strings + return `The prop1 is ${prop1}; the prop2 is ${prop2}`; + }, + basicWithInstance: (instance) => { + // The method accessing the instance methods: either own or provided + // by previously-registered extensions + // `instance` is always supplied to all methods in provides() as THE LAST + // argument. + // You don't need to explicitly pass instance to this method: + // Use: instance.basicWithInstance(); + // eslint-disable-next-line @gitlab/require-i18n-strings + return `We have access to the whole Instance! ${instance.alpha()}`; + }, + advancedWithInstanceAndProps: ({ author, book } = {}, firstname, lastname, instance) => { + // Advanced method where + // { author, book } — are the props passed as an object + // prop1, prop2 — are the props passed as simple list + // instance — is automatically supplied, no need to pass it to + // the method explicitly + // Use: instance.advancedWithInstanceAndProps( + // { + // author: 'Franz Kafka', + // book: 'The Transformation' + // }, + // 'Franz', + // 'Kafka' + // ); + return ` +The author is ${author}; the book is ${book} +The author's name is ${firstname}; the last name is ${lastname} +We have access to the whole Instance! For example, 'instance.alpha()': ${instance.alpha()}`; + }, + }; + } +} 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 5fa01f03f7e..03c68fed3b1 100644 --- a/app/assets/javascripts/editor/extensions/source_editor_extension_base.js +++ b/app/assets/javascripts/editor/extensions/source_editor_extension_base.js @@ -36,12 +36,24 @@ export class SourceEditorExtension { }); } - static highlightLines(instance) { - const { hash } = window.location; - if (!hash) { - return; - } - const [start, end] = hash.replace(hashRegexp, '').split('-'); + static removeHighlights(instance) { + Object.assign(instance, { + lineDecorations: instance.deltaDecorations(instance.lineDecorations || [], []), + }); + } + + /** + * Returns a function that can only be invoked once between + * each browser screen repaint. + * @param {Object} instance - The Source Editor instance + * @param {Array} bounds - The [start, end] array with start + * and end coordinates for highlighting + */ + static highlightLines(instance, bounds = null) { + const [start, end] = + bounds && Array.isArray(bounds) + ? bounds + : window.location.hash?.replace(hashRegexp, '').split('-'); let startLine = start ? parseInt(start, 10) : null; let endLine = end ? parseInt(end, 10) : startLine; if (endLine < startLine) { @@ -51,15 +63,12 @@ export class SourceEditorExtension { window.requestAnimationFrame(() => { instance.revealLineInCenter(startLine); Object.assign(instance, { - lineDecorations: instance.deltaDecorations( - [], - [ - { - range: new Range(startLine, 1, endLine, 1), - options: { isWholeLine: true, className: 'active-line-text' }, - }, - ], - ), + lineDecorations: instance.deltaDecorations(instance.lineDecorations || [], [ + { + range: new Range(startLine, 1, endLine, 1), + options: { isWholeLine: true, className: 'active-line-text' }, + }, + ]), }); }); } diff --git a/app/assets/javascripts/editor/extensions/source_editor_yaml_ext.js b/app/assets/javascripts/editor/extensions/source_editor_yaml_ext.js new file mode 100644 index 00000000000..212e09c8724 --- /dev/null +++ b/app/assets/javascripts/editor/extensions/source_editor_yaml_ext.js @@ -0,0 +1,293 @@ +import { toPath } from 'lodash'; +import { parseDocument, Document, visit, isScalar, isCollection, isMap } from 'yaml'; +import { findPair } from 'yaml/util'; +import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base'; + +export class YamlEditorExtension extends SourceEditorExtension { + /** + * Extends the source editor with capabilities for yaml files. + * + * @param { Instance } instance Source Editor Instance + * @param { boolean } enableComments Convert model nodes with the comment + * pattern to comments? + * @param { string } highlightPath Add a line highlight to the + * node specified by this e.g. `"foo.bar[0]"` + * @param { * } model Any JS Object that will be stringified and used as the + * editor's value. Equivalent to using `setDataModel()` + * @param options SourceEditorExtension Options + */ + constructor({ + instance, + enableComments = false, + highlightPath = null, + model = null, + ...options + } = {}) { + super({ + instance, + options: { + ...options, + enableComments, + highlightPath, + }, + }); + + if (model) { + YamlEditorExtension.initFromModel(instance, model); + } + + instance.onDidChangeModelContent(() => instance.onUpdate()); + } + + /** + * @private + */ + static initFromModel(instance, model) { + const doc = new Document(model); + if (instance.options.enableComments) { + YamlEditorExtension.transformComments(doc); + } + instance.setValue(doc.toString()); + } + + /** + * @private + * This wraps long comments to a maximum line length of 80 chars. + * + * The `yaml` package does not currently wrap comments. This function + * is a local workaround and should be deprecated if + * https://github.com/eemeli/yaml/issues/322 + * is resolved. + */ + static wrapCommentString(string, level = 0) { + if (!string) { + return null; + } + if (level < 0 || Number.isNaN(parseInt(level, 10))) { + throw Error(`Invalid value "${level}" for variable \`level\``); + } + const maxLineWidth = 80; + const indentWidth = 2; + const commentMarkerWidth = '# '.length; + const maxLength = maxLineWidth - commentMarkerWidth - level * indentWidth; + const lines = [[]]; + string.split(' ').forEach((word) => { + const currentLine = lines.length - 1; + if ([...lines[currentLine], word].join(' ').length <= maxLength) { + lines[currentLine].push(word); + } else { + lines.push([word]); + } + }); + return lines.map((line) => ` ${line.join(' ')}`).join('\n'); + } + + /** + * @private + * + * This utilizes `yaml`'s `visit` function to transform nodes with a + * comment key pattern to actual comments. + * + * In Objects, a key of '#' will be converted to a comment at the top of a + * property. Any key following the pattern `#|<some key>` will be placed + * right before `<some key>`. + * + * In Arrays, any string that starts with # (including the space), will + * be converted to a comment at the position it was in. + * + * @param { Document } doc + * @returns { Document } + */ + static transformComments(doc) { + const getLevel = (path) => { + const { length } = path.filter((x) => isCollection(x)); + return length ? length - 1 : 0; + }; + + visit(doc, { + Pair(_, pair, path) { + const key = pair.key.value; + // If the key is = '#', we add the value as a comment to the parent + // We can then remove the node. + if (key === '#') { + Object.assign(path[path.length - 1], { + commentBefore: YamlEditorExtension.wrapCommentString(pair.value.value, getLevel(path)), + }); + return visit.REMOVE; + } + // If the key starts with `#|`, we want to add a comment to the + // corresponding property. We can then remove the node. + if (key.startsWith('#|')) { + const targetProperty = key.split('|')[1]; + const target = findPair(path[path.length - 1].items, targetProperty); + if (target) { + target.key.commentBefore = YamlEditorExtension.wrapCommentString( + pair.value.value, + getLevel(path), + ); + } + return visit.REMOVE; + } + return undefined; // If the node is not a comment, do nothing with it + }, + // Sequence is basically an array + Seq(_, node, path) { + let comment = null; + const items = node.items.flatMap((child) => { + if (comment) { + Object.assign(child, { commentBefore: comment }); + comment = null; + } + if ( + isScalar(child) && + child.value && + child.value.startsWith && + child.value.startsWith('#') + ) { + const commentValue = child.value.replace(/^#\s?/, ''); + comment = YamlEditorExtension.wrapCommentString(commentValue, getLevel(path)); + return []; + } + return child; + }); + Object.assign(node, { items }); + // Adding a comment in case the last one is a comment + if (comment) { + Object.assign(node, { comment }); + } + }, + }); + return doc; + } + + /** + * Get the editor's value parsed as a `Document` as defined by the `yaml` + * package + * @returns {Document} + */ + getDoc() { + return parseDocument(this.getValue()); + } + + /** + * Accepts a `Document` as defined by the `yaml` package and + * sets the Editor's value to a stringified version of it. + * @param { Document } doc + */ + setDoc(doc) { + if (this.options.enableComments) { + YamlEditorExtension.transformComments(doc); + } + + if (!this.getValue()) { + this.setValue(doc.toString()); + } else { + this.updateValue(doc.toString()); + } + } + + /** + * Returns the parsed value of the Editor's content as JS. + * @returns {*} + */ + getDataModel() { + return this.getDoc().toJS(); + } + + /** + * Accepts any JS Object and sets the Editor's value to a stringified version + * of that value. + * + * @param value + */ + setDataModel(value) { + this.setDoc(new Document(value)); + } + + /** + * Method to be executed when the Editor's <TextModel> was updated + */ + onUpdate() { + if (this.options.highlightPath) { + this.highlight(this.options.highlightPath); + } + } + + /** + * Set the editors content to the input without recreating the content model. + * + * @param blob + */ + updateValue(blob) { + // Using applyEdits() instead of setValue() ensures that tokens such as + // highlighted lines aren't deleted/recreated which causes a flicker. + const model = this.getModel(); + model.applyEdits([ + { + // A nice improvement would be to replace getFullModelRange() with + // a range of the actual diff, avoiding re-formatting the document, + // but that's something for a later iteration. + range: model.getFullModelRange(), + text: blob, + }, + ]); + } + + /** + * Add a line highlight style to the node specified by the path. + * + * @param {string|null|false} path A path to a node of the Editor's value, + * e.g. `"foo.bar[0]"`. If the value is falsy, this will remove all + * highlights. + */ + highlight(path) { + if (this.options.highlightPath === path) return; + if (!path) { + SourceEditorExtension.removeHighlights(this); + } else { + const res = this.locate(path); + SourceEditorExtension.highlightLines(this, res); + } + this.options.highlightPath = path || null; + } + + /** + * Return the line numbers of a certain node identified by `path` within + * the yaml. + * + * @param {string} path A path to a node, eg. `foo.bar[0]` + * @returns {number[]} Array following the schema `[firstLine, lastLine]` + * (both inclusive) + * + * @throws {Error} Will throw if the path is not found inside the document + */ + locate(path) { + if (!path) throw Error(`No path provided.`); + const blob = this.getValue(); + const doc = parseDocument(blob); + const pathArray = toPath(path); + + if (!doc.getIn(pathArray)) { + throw Error(`The node ${path} could not be found inside the document.`); + } + + const parentNode = doc.getIn(pathArray.slice(0, pathArray.length - 1)); + let startChar; + let endChar; + if (isMap(parentNode)) { + const node = parentNode.items.find( + (item) => item.key.value === pathArray[pathArray.length - 1], + ); + [startChar] = node.key.range; + [, , endChar] = node.value.range; + } else { + const node = doc.getIn(pathArray); + [startChar, , endChar] = node.range; + } + const startSlice = blob.slice(0, startChar); + const endSlice = blob.slice(0, endChar); + const startLine = (startSlice.match(/\n/g) || []).length + 1; + const endLine = (endSlice.match(/\n/g) || []).length; + return [startLine, endLine]; + } +} diff --git a/app/assets/javascripts/editor/schema/ci.json b/app/assets/javascripts/editor/schema/ci.json index 0052bc00406..f0db3e5594b 100644 --- a/app/assets/javascripts/editor/schema/ci.json +++ b/app/assets/javascripts/editor/schema/ci.json @@ -63,9 +63,9 @@ "items": { "type": "object", "properties": { - "if": { - "type": "string" - }, + "if": { "$ref": "#/definitions/if" }, + "changes": { "$ref": "#/definitions/changes" }, + "exists": { "$ref": "#/definitions/exists" }, "variables": { "$ref": "#/definitions/variables" }, "when": { "type": "string", @@ -497,24 +497,9 @@ "type": "object", "additionalProperties": false, "properties": { - "if": { - "type": "string", - "description": "Expression to evaluate whether additional attributes should be provided to the job" - }, - "changes": { - "type": "array", - "description": "Additional attributes will be provided to job if any of the provided paths matches a modified file", - "items": { - "type": "string" - } - }, - "exists": { - "type": "array", - "description": "Additional attributes will be provided to job if any of the provided paths matches an existing file in the repository", - "items": { - "type": "string" - } - }, + "if": { "$ref": "#/definitions/if" }, + "changes": { "$ref": "#/definitions/changes" }, + "exists": { "$ref": "#/definitions/exists" }, "variables": { "$ref": "#/definitions/variables" }, "when": { "$ref": "#/definitions/when" }, "start_in": { "$ref": "#/definitions/start_in" }, @@ -541,6 +526,24 @@ ] } }, + "if": { + "type": "string", + "description": "Expression to evaluate whether additional attributes should be provided to the job" + }, + "changes": { + "type": "array", + "description": "Additional attributes will be provided to job if any of the provided paths matches a modified file", + "items": { + "type": "string" + } + }, + "exists": { + "type": "array", + "description": "Additional attributes will be provided to job if any of the provided paths matches an existing file in the repository", + "items": { + "type": "string" + } + }, "variables": { "type": "object", "description": "Defines environment variables for specific jobs. Job level property overrides global variables. If a job sets `variables: {}`, all global variables are turned off.", @@ -555,7 +558,7 @@ }, "start_in": { "type": "string", - "description": "Used in conjunction with 'when: delayed' to set how long to delay before starting a job.", + "description": "Used in conjunction with 'when: delayed' to set how long to delay before starting a job. e.g. '5', 5 seconds, 30 minutes, 1 week, etc. Read more: https://docs.gitlab.com/ee/ci/jobs/job_control.html#run-a-job-after-a-delay", "minLength": 1 }, "allow_failure": { @@ -939,7 +942,7 @@ "stage": { "type": "string", "description": "Define what stage the job will run in.", - "default": "test" + "minLength": 1 }, "only": { "$ref": "#/definitions/filter", diff --git a/app/assets/javascripts/editor/source_editor_extension.js b/app/assets/javascripts/editor/source_editor_extension.js new file mode 100644 index 00000000000..f6bc62a1c09 --- /dev/null +++ b/app/assets/javascripts/editor/source_editor_extension.js @@ -0,0 +1,17 @@ +import { EDITOR_EXTENSION_DEFINITION_ERROR } from './constants'; + +export default class EditorExtension { + constructor({ definition, setupOptions } = {}) { + if (typeof definition !== 'function') { + throw new Error(EDITOR_EXTENSION_DEFINITION_ERROR); + } + this.name = definition.name; // both class- and fn-based extensions have a name + this.setupOptions = setupOptions; + // eslint-disable-next-line new-cap + this.obj = new definition(); + } + + get api() { + return this.obj.provides?.(); + } +} diff --git a/app/assets/javascripts/editor/source_editor_instance.js b/app/assets/javascripts/editor/source_editor_instance.js new file mode 100644 index 00000000000..e0ca4ea518b --- /dev/null +++ b/app/assets/javascripts/editor/source_editor_instance.js @@ -0,0 +1,271 @@ +/** + * @module source_editor_instance + */ + +/** + * A Source Editor Extension definition + * @typedef {Object} SourceEditorExtensionDefinition + * @property {Object} definition + * @property {Object} setupOptions + */ + +/** + * A Source Editor Extension + * @typedef {Object} SourceEditorExtension + * @property {Object} obj + * @property {string} name + * @property {Object} api + */ + +import { isEqual } from 'lodash'; +import { editor as monacoEditor } from 'monaco-editor'; +import { getBlobLanguage } from '~/editor/utils'; +import { logError } from '~/lib/logger'; +import { sprintf } from '~/locale'; +import EditorExtension from './source_editor_extension'; +import { + EDITOR_EXTENSION_DEFINITION_TYPE_ERROR, + EDITOR_EXTENSION_NAMING_CONFLICT_ERROR, + EDITOR_EXTENSION_NO_DEFINITION_ERROR, + EDITOR_EXTENSION_NOT_REGISTERED_ERROR, + EDITOR_EXTENSION_NOT_SPECIFIED_FOR_UNUSE_ERROR, + EDITOR_EXTENSION_STORE_IS_MISSING_ERROR, +} from './constants'; + +const utils = { + removeExtFromMethod: (method, extensionName, container) => { + if (!container) { + return; + } + if (Object.prototype.hasOwnProperty.call(container, method)) { + // eslint-disable-next-line no-param-reassign + delete container[method]; + } + }, + + getStoredExtension: (extensionsStore, name) => { + if (!extensionsStore) { + logError(EDITOR_EXTENSION_STORE_IS_MISSING_ERROR); + return undefined; + } + return extensionsStore.get(name); + }, +}; + +/** Class representing a Source Editor Instance */ +export default class EditorInstance { + /** + * Create a Source Editor Instance + * @param {Object} rootInstance - Monaco instance to build on top of + * @param {Map} extensionsStore - The global registry for the extension instances + * @returns {Object} - A Proxy returning props/methods from either registered extensions, or Source Editor instance, or underlying Monaco instance + */ + constructor(rootInstance = {}, extensionsStore = new Map()) { + /** The methods provided by extensions. */ + this.methods = {}; + + const seInstance = this; + const getHandler = { + get(target, prop, receiver) { + const methodExtension = + Object.prototype.hasOwnProperty.call(seInstance.methods, prop) && + seInstance.methods[prop]; + if (methodExtension) { + const extension = extensionsStore.get(methodExtension); + + return (...args) => { + return extension.api[prop].call(seInstance, ...args, receiver); + }; + } + return Reflect.get(seInstance[prop] ? seInstance : target, prop, receiver); + }, + set(target, prop, value) { + Object.assign(seInstance, { + [prop]: value, + }); + return true; + }, + }; + const instProxy = new Proxy(rootInstance, getHandler); + + /** + * Main entry point to apply an extension to the instance + * @param {SourceEditorExtensionDefinition} + */ + this.use = EditorInstance.useUnuse.bind(instProxy, extensionsStore, this.useExtension); + + /** + * Main entry point to un-use an extension and remove it from the instance + * @param {SourceEditorExtension} + */ + this.unuse = EditorInstance.useUnuse.bind(instProxy, extensionsStore, this.unuseExtension); + + return instProxy; + } + + /** + * A private dispatcher function for both `use` and `unuse` + * @param {Map} extensionsStore - The global registry for the extension instances + * @param {Function} fn - A function to route to. Either `this.useExtension` or `this.unuseExtension` + * @param {SourceEditorExtensionDefinition[]} extensions - The extensions to use/unuse. + * @returns {Function} + */ + static useUnuse(extensionsStore, fn, extensions) { + if (Array.isArray(extensions)) { + /** + * We cut short if the Array is empty and let the destination function to throw + * Otherwise, we run the destination function on every entry of the Array + */ + return extensions.length + ? extensions.map(fn.bind(this, extensionsStore)) + : fn.call(this, extensionsStore); + } + return fn.call(this, extensionsStore, extensions); + } + + // + // REGISTERING NEW EXTENSION + // + + /** + * Run all registrations when using an extension + * @param {Map} extensionsStore - The global registry for the extension instances + * @param {SourceEditorExtensionDefinition} extension - The extension definition to use. + * @returns {EditorExtension|*} + */ + useExtension(extensionsStore, extension = {}) { + const { definition } = extension; + if (!definition) { + throw new Error(EDITOR_EXTENSION_NO_DEFINITION_ERROR); + } + if (typeof definition !== 'function') { + throw new Error(EDITOR_EXTENSION_DEFINITION_TYPE_ERROR); + } + + // Existing Extension Path + const existingExt = utils.getStoredExtension(extensionsStore, definition.name); + if (existingExt) { + if (isEqual(extension.setupOptions, existingExt.setupOptions)) { + return existingExt; + } + this.unuseExtension(extensionsStore, existingExt); + } + + // New Extension Path + const extensionInstance = new EditorExtension(extension); + const { setupOptions, obj: extensionObj } = extensionInstance; + if (extensionObj.onSetup) { + extensionObj.onSetup(setupOptions, this); + } + if (extensionsStore) { + this.registerExtension(extensionInstance, extensionsStore); + } + this.registerExtensionMethods(extensionInstance); + return extensionInstance; + } + + /** + * Register extension in the global extensions store + * @param {SourceEditorExtension} extension - Instance of Source Editor extension + * @param {Map} extensionsStore - The global registry for the extension instances + */ + registerExtension(extension, extensionsStore) { + const { name } = extension; + const hasExtensionRegistered = + extensionsStore.has(name) && + isEqual(extension.setupOptions, extensionsStore.get(name).setupOptions); + if (hasExtensionRegistered) { + return; + } + extensionsStore.set(name, extension); + const { obj: extensionObj } = extension; + if (extensionObj.onUse) { + extensionObj.onUse(this); + } + } + + /** + * Register extension methods in the registry on the instance + * @param {SourceEditorExtension} extension - Instance of Source Editor extension + */ + registerExtensionMethods(extension) { + const { api, name } = extension; + + if (!api) { + return; + } + + Object.keys(api).forEach((prop) => { + if (this[prop]) { + logError(sprintf(EDITOR_EXTENSION_NAMING_CONFLICT_ERROR, { prop })); + } else { + this.methods[prop] = name; + } + }, this); + } + + // + // UNREGISTERING AN EXTENSION + // + + /** + * Unregister extension with the cleanup + * @param {Map} extensionsStore - The global registry for the extension instances + * @param {SourceEditorExtension} extension - Instance of Source Editor extension to un-use + */ + unuseExtension(extensionsStore, extension) { + if (!extension) { + throw new Error(EDITOR_EXTENSION_NOT_SPECIFIED_FOR_UNUSE_ERROR); + } + const { name } = extension; + const existingExt = utils.getStoredExtension(extensionsStore, name); + if (!existingExt) { + throw new Error(sprintf(EDITOR_EXTENSION_NOT_REGISTERED_ERROR, { name })); + } + const { obj: extensionObj } = existingExt; + if (extensionObj.onBeforeUnuse) { + extensionObj.onBeforeUnuse(this); + } + this.unregisterExtensionMethods(existingExt); + if (extensionObj.onUnuse) { + extensionObj.onUnuse(this); + } + } + + /** + * Remove all methods associated with this extension from the registry on the instance + * @param {SourceEditorExtension} extension - Instance of Source Editor extension to un-use + */ + unregisterExtensionMethods(extension) { + const { api, name } = extension; + if (!api) { + return; + } + Object.keys(api).forEach((method) => { + utils.removeExtFromMethod(method, name, this.methods); + }); + } + + /** + * PUBLIC API OF AN INSTANCE + */ + + /** + * Updates model language based on the path + * @param {String} path - blob path + */ + updateModelLanguage(path) { + const lang = getBlobLanguage(path); + const model = this.getModel(); + // return monacoEditor.setModelLanguage(model, lang); + monacoEditor.setModelLanguage(model, lang); + } + + /** + * Get the methods returned by extensions. + * @returns {Array} + */ + get extensionsAPI() { + return Object.keys(this.methods); + } +} |