Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2021-11-18 16:16:36 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2021-11-18 16:16:36 +0300
commit311b0269b4eb9839fa63f80c8d7a58f32b8138a0 (patch)
tree07e7870bca8aed6d61fdcc810731c50d2c40af47 /app/assets/javascripts/editor
parent27909cef6c4170ed9205afa7426b8d3de47cbb0c (diff)
Add latest changes from gitlab-org/gitlab@14-5-stable-eev14.5.0-rc42
Diffstat (limited to 'app/assets/javascripts/editor')
-rw-r--r--app/assets/javascripts/editor/constants.js36
-rw-r--r--app/assets/javascripts/editor/extensions/example_source_editor_extension.js116
-rw-r--r--app/assets/javascripts/editor/extensions/source_editor_extension_base.js39
-rw-r--r--app/assets/javascripts/editor/extensions/source_editor_yaml_ext.js293
-rw-r--r--app/assets/javascripts/editor/schema/ci.json49
-rw-r--r--app/assets/javascripts/editor/source_editor_extension.js17
-rw-r--r--app/assets/javascripts/editor/source_editor_instance.js271
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);
+ }
+}