diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-11-16 18:13:28 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-11-16 18:13:28 +0300 |
commit | 3563f193744bf8bca9a1099fe6f6399c8883ec7e (patch) | |
tree | a14b71b59036f1c401bbfe4340f364fbf3a73ed9 /app | |
parent | dc9ff5fda1337883acd09fd4b98be2f6a41ad037 (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app')
5 files changed, 611 insertions, 24 deletions
diff --git a/app/assets/javascripts/editor/constants.js b/app/assets/javascripts/editor/constants.js index d44bfdfb966..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 { 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 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/source_editor_extension.js b/app/assets/javascripts/editor/source_editor_extension.js index 664bcabcf45..f6bc62a1c09 100644 --- a/app/assets/javascripts/editor/source_editor_extension.js +++ b/app/assets/javascripts/editor/source_editor_extension.js @@ -12,6 +12,6 @@ export default class EditorExtension { } get api() { - return this.obj.provides(); + 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); + } +} |