Commit 5c9d969b authored by Kushal Pandya's avatar Kushal Pandya

Merge branch '292943-se-instance-module' into 'master'

Introduced the Source Editor Instance module

See merge request gitlab-org/gitlab!74566
parents b4c0fa17 e993bcdb
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { s__ } from '~/locale';
export const SOURCE_EDITOR_INSTANCE_ERROR_NO_EL = s__(
'SourceEditor|"el" parameter is required for createInstance()',
);
export const URI_PREFIX = 'gitlab';
export const CONTENT_UPDATE_DEBOUNCE = DEFAULT_DEBOUNCE_AND_THROTTLE_MS;
export const ERROR_INSTANCE_REQUIRED_FOR_EXTENSION = s__(
'SourceEditor|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,9 +12,31 @@ 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
......
......@@ -12,6 +12,6 @@ export default class EditorExtension {
}
get api() {
return this.obj.provides();
return this.obj.provides?.();
}
}
/**
* @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);
}
}
......@@ -32697,12 +32697,30 @@ msgstr ""
msgid "SourceEditor|\"el\" parameter is required for createInstance()"
msgstr ""
msgid "SourceEditor|%{name} is not registered."
msgstr ""
msgid "SourceEditor|Extension definition should be either a class or a function"
msgstr ""
msgid "SourceEditor|Extension definition should be either class, function, or an Array of definitions."
msgstr ""
msgid "SourceEditor|Extensions Store is required to check for an extension."
msgstr ""
msgid "SourceEditor|Name conflict for \"%{prop}()\" method."
msgstr ""
msgid "SourceEditor|No extension for unuse has been specified."
msgstr ""
msgid "SourceEditor|Source Editor instance is required to set up an extension."
msgstr ""
msgid "SourceEditor|`definition` property is expected on the extension."
msgstr ""
msgid "Sourcegraph"
msgstr ""
......
export class MyClassExtension {
// eslint-disable-next-line class-methods-use-this
provides() {
return {
shared: () => 'extension',
classExtMethod: () => 'class own method',
};
}
}
export function MyFnExtension() {
return {
fnExtMethod: () => 'fn own method',
provides: () => {
return {
fnExtMethod: () => 'class own method',
};
},
};
}
export const MyConstExt = () => {
return {
provides: () => {
return {
constExtMethod: () => 'const own method',
};
},
};
};
export const conflictingExtensions = {
WithInstanceExt: () => {
return {
provides: () => {
return {
use: () => 'A conflict with instance',
ownMethod: () => 'Non-conflicting method',
};
},
};
},
WithAnotherExt: () => {
return {
provides: () => {
return {
shared: () => 'A conflict with extension',
ownMethod: () => 'Non-conflicting method',
};
},
};
},
};
import EditorExtension from '~/editor/source_editor_extension';
import { EDITOR_EXTENSION_DEFINITION_ERROR } from '~/editor/constants';
class MyClassExtension {
// eslint-disable-next-line class-methods-use-this
provides() {
return {
shared: () => 'extension',
classExtMethod: () => 'class own method',
};
}
}
function MyFnExtension() {
return {
fnExtMethod: () => 'fn own method',
provides: () => {
return {
shared: () => 'extension',
};
},
};
}
const MyConstExt = () => {
return {
provides: () => {
return {
shared: () => 'extension',
constExtMethod: () => 'const own method',
};
},
};
};
import * as helpers from './helpers';
describe('Editor Extension', () => {
const dummyObj = { foo: 'bar' };
......@@ -52,16 +21,16 @@ describe('Editor Extension', () => {
);
it.each`
definition | setupOptions | expectedName
${MyClassExtension} | ${undefined} | ${'MyClassExtension'}
${MyClassExtension} | ${{}} | ${'MyClassExtension'}
${MyClassExtension} | ${dummyObj} | ${'MyClassExtension'}
${MyFnExtension} | ${undefined} | ${'MyFnExtension'}
${MyFnExtension} | ${{}} | ${'MyFnExtension'}
${MyFnExtension} | ${dummyObj} | ${'MyFnExtension'}
${MyConstExt} | ${undefined} | ${'MyConstExt'}
${MyConstExt} | ${{}} | ${'MyConstExt'}
${MyConstExt} | ${dummyObj} | ${'MyConstExt'}
definition | setupOptions | expectedName
${helpers.MyClassExtension} | ${undefined} | ${'MyClassExtension'}
${helpers.MyClassExtension} | ${{}} | ${'MyClassExtension'}
${helpers.MyClassExtension} | ${dummyObj} | ${'MyClassExtension'}
${helpers.MyFnExtension} | ${undefined} | ${'MyFnExtension'}
${helpers.MyFnExtension} | ${{}} | ${'MyFnExtension'}
${helpers.MyFnExtension} | ${dummyObj} | ${'MyFnExtension'}
${helpers.MyConstExt} | ${undefined} | ${'MyConstExt'}
${helpers.MyConstExt} | ${{}} | ${'MyConstExt'}
${helpers.MyConstExt} | ${dummyObj} | ${'MyConstExt'}
`(
'correctly creates extension for definition = $definition and setupOptions = $setupOptions',
({ definition, setupOptions, expectedName }) => {
......@@ -81,10 +50,10 @@ describe('Editor Extension', () => {
describe('api', () => {
it.each`
definition | expectedKeys
${MyClassExtension} | ${['shared', 'classExtMethod']}
${MyFnExtension} | ${['shared']}
${MyConstExt} | ${['shared', 'constExtMethod']}
definition | expectedKeys
${helpers.MyClassExtension} | ${['shared', 'classExtMethod']}
${helpers.MyFnExtension} | ${['fnExtMethod']}
${helpers.MyConstExt} | ${['constExtMethod']}
`('correctly returns API for $definition', ({ definition, expectedKeys }) => {
const extension = new EditorExtension({ definition });
const expectedApi = Object.fromEntries(
......
This diff is collapsed.
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment