Commit da813abf authored by Denys Mishunov's avatar Denys Mishunov

Merge branch 'yaml-source-editor-extenstion' into 'master'

Add Yaml Source Editor Extension

See merge request gitlab-org/gitlab!72764
parents 0d51189a fe4f8022
......@@ -36,12 +36,24 @@ export class SourceEditorExtension {
});
}
static highlightLines(instance) {
const { hash } = window.location;
if (!hash) {
return;
static removeHighlights(instance) {
Object.assign(instance, {
lineDecorations: instance.deltaDecorations(instance.lineDecorations || [], []),
});
}
const [start, end] = hash.replace(hashRegexp, '').split('-');
/**
* 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(
[],
[
lineDecorations: instance.deltaDecorations(instance.lineDecorations || [], [
{
range: new Range(startLine, 1, endLine, 1),
options: { isWholeLine: true, className: 'active-line-text' },
},
],
),
]),
});
});
}
......
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];
}
}
......@@ -148,7 +148,10 @@ describe('The basis for an Source Editor extension', () => {
revealLineInCenter: revealSpy,
deltaDecorations: decorationsSpy,
};
const defaultDecorationOptions = { isWholeLine: true, className: 'active-line-text' };
const defaultDecorationOptions = {
isWholeLine: true,
className: 'active-line-text',
};
useFakeRequestAnimationFrame();
......@@ -157,18 +160,22 @@ describe('The basis for an Source Editor extension', () => {
});
it.each`
desc | hash | shouldReveal | expectedRange
${'properly decorates a single line'} | ${'#L10'} | ${true} | ${[10, 1, 10, 1]}
${'properly decorates multiple lines'} | ${'#L7-42'} | ${true} | ${[7, 1, 42, 1]}
${'correctly highlights if lines are reversed'} | ${'#L42-7'} | ${true} | ${[7, 1, 42, 1]}
${'highlights one line if start/end are the same'} | ${'#L7-7'} | ${true} | ${[7, 1, 7, 1]}
${'does not highlight if there is no hash'} | ${''} | ${false} | ${null}
${'does not highlight if the hash is undefined'} | ${undefined} | ${false} | ${null}
${'does not highlight if hash is incomplete 1'} | ${'#L'} | ${false} | ${null}
${'does not highlight if hash is incomplete 2'} | ${'#L-'} | ${false} | ${null}
`('$desc', ({ hash, shouldReveal, expectedRange } = {}) => {
desc | hash | bounds | shouldReveal | expectedRange
${'properly decorates a single line'} | ${'#L10'} | ${undefined} | ${true} | ${[10, 1, 10, 1]}
${'properly decorates multiple lines'} | ${'#L7-42'} | ${undefined} | ${true} | ${[7, 1, 42, 1]}
${'correctly highlights if lines are reversed'} | ${'#L42-7'} | ${undefined} | ${true} | ${[7, 1, 42, 1]}
${'highlights one line if start/end are the same'} | ${'#L7-7'} | ${undefined} | ${true} | ${[7, 1, 7, 1]}
${'does not highlight if there is no hash'} | ${''} | ${undefined} | ${false} | ${null}
${'does not highlight if the hash is undefined'} | ${undefined} | ${undefined} | ${false} | ${null}
${'does not highlight if hash is incomplete 1'} | ${'#L'} | ${undefined} | ${false} | ${null}
${'does not highlight if hash is incomplete 2'} | ${'#L-'} | ${undefined} | ${false} | ${null}
${'highlights lines if bounds are passed'} | ${undefined} | ${[17, 42]} | ${true} | ${[17, 1, 42, 1]}
${'highlights one line if bounds has a single value'} | ${undefined} | ${[17]} | ${true} | ${[17, 1, 17, 1]}
${'does not highlight if bounds is invalid'} | ${undefined} | ${[Number.NaN]} | ${false} | ${null}
${'uses bounds if both hash and bounds exist'} | ${'#L7-42'} | ${[3, 5]} | ${true} | ${[3, 1, 5, 1]}
`('$desc', ({ hash, bounds, shouldReveal, expectedRange } = {}) => {
window.location.hash = hash;
SourceEditorExtension.highlightLines(instance);
SourceEditorExtension.highlightLines(instance, bounds);
if (!shouldReveal) {
expect(revealSpy).not.toHaveBeenCalled();
expect(decorationsSpy).not.toHaveBeenCalled();
......@@ -193,6 +200,43 @@ describe('The basis for an Source Editor extension', () => {
SourceEditorExtension.highlightLines(instance);
expect(instance.lineDecorations).toBe('foo');
});
it('replaces existing line highlights', () => {
const oldLineDecorations = [
{
range: new Range(1, 1, 20, 1),
options: { isWholeLine: true, className: 'active-line-text' },
},
];
const newLineDecorations = [
{
range: new Range(7, 1, 10, 1),
options: { isWholeLine: true, className: 'active-line-text' },
},
];
instance.lineDecorations = oldLineDecorations;
SourceEditorExtension.highlightLines(instance, [7, 10]);
expect(decorationsSpy).toHaveBeenCalledWith(oldLineDecorations, newLineDecorations);
});
});
describe('removeHighlights', () => {
const decorationsSpy = jest.fn();
const lineDecorations = [
{
range: new Range(1, 1, 20, 1),
options: { isWholeLine: true, className: 'active-line-text' },
},
];
const instance = {
deltaDecorations: decorationsSpy,
lineDecorations,
};
it('removes all existing decorations', () => {
SourceEditorExtension.removeHighlights(instance);
expect(decorationsSpy).toHaveBeenCalledWith(lineDecorations, []);
});
});
describe('setupLineLinking', () => {
......
This diff is collapsed.
......@@ -12806,6 +12806,11 @@ yaml@^1.10.0:
resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b"
integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==
yaml@^2.0.0-8:
version "2.0.0-8"
resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.0.0-8.tgz#226365f0d804ba7fb8cc2b527a00a7a4a3d8ea5f"
integrity sha512-QaYgJZMfWD6fKN/EYMk6w1oLWPCr1xj9QaPSZW5qkDb3y8nGCXhy2Ono+AF4F+CSL/vGcqswcAT0BaS//pgD2A==
yargs-parser@^13.1.2:
version "13.1.2"
resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-13.1.2.tgz#130f09702ebaeef2650d54ce6e3e5706f7a4fb38"
......
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