Commit 07d4e611 authored by Himanshu Kapoor's avatar Himanshu Kapoor

Prepare Editor Model to accept options

Add support in Web IDE Editor Model class to allow setting certain
options that can be modified later. The options include both existing
options already supported by Monaco, and some custom options that are
updated to the model value right before dispose.

Existing options now configurable:
- insertSpaces
- indentSize
- tabSize

Custom options added:
- trimTrailingWhitespace
- endOfLine
- insertFinalNewline
parent 4d2882b1
...@@ -14,7 +14,6 @@ import Editor from '../lib/editor'; ...@@ -14,7 +14,6 @@ import Editor from '../lib/editor';
import FileTemplatesBar from './file_templates/bar.vue'; import FileTemplatesBar from './file_templates/bar.vue';
import { __ } from '~/locale'; import { __ } from '~/locale';
import { extractMarkdownImagesFromEntries } from '../stores/utils'; import { extractMarkdownImagesFromEntries } from '../stores/utils';
import { addFinalNewline } from '../utils';
export default { export default {
components: { components: {
...@@ -32,7 +31,6 @@ export default { ...@@ -32,7 +31,6 @@ export default {
return { return {
content: '', content: '',
images: {}, images: {},
addFinalNewline: true,
}; };
}, },
computed: { computed: {
...@@ -253,10 +251,8 @@ export default { ...@@ -253,10 +251,8 @@ export default {
const monacoModel = model.getModel(); const monacoModel = model.getModel();
const content = monacoModel.getValue(); const content = monacoModel.getValue();
this.changeFileContent({ this.changeFileContent({ path: file.path, content });
path: file.path, this.setFileEOL({ eol: this.model.eol });
content: this.addFinalNewline ? addFinalNewline(content, monacoModel.getEOL()) : content,
});
}); });
// Handle Cursor Position // Handle Cursor Position
......
import { editor as monacoEditor, Uri } from 'monaco-editor'; import { editor as monacoEditor, Uri } from 'monaco-editor';
import Disposable from './disposable'; import Disposable from './disposable';
import eventHub from '../../eventhub'; import eventHub from '../../eventhub';
import { trimTrailingWhitespace, insertFinalNewline } from '../../utils';
import { defaultModelOptions } from '../editor_options';
export default class Model { export default class Model {
constructor(file, head = null) { constructor(file, head = null) {
...@@ -8,6 +10,7 @@ export default class Model { ...@@ -8,6 +10,7 @@ export default class Model {
this.file = file; this.file = file;
this.head = head; this.head = head;
this.content = file.content !== '' || file.deleted ? file.content : file.raw; this.content = file.content !== '' || file.deleted ? file.content : file.raw;
this.options = { ...defaultModelOptions };
this.disposable.add( this.disposable.add(
(this.originalModel = monacoEditor.createModel( (this.originalModel = monacoEditor.createModel(
...@@ -94,8 +97,32 @@ export default class Model { ...@@ -94,8 +97,32 @@ export default class Model {
this.getModel().setValue(content); this.getModel().setValue(content);
} }
updateOptions(obj = {}) {
Object.assign(this.options, obj);
this.model.updateOptions(obj);
this.applyCustomOptions();
}
applyCustomOptions() {
this.updateNewContent(
Object.entries(this.options).reduce((content, [key, value]) => {
switch (key) {
case 'endOfLine':
this.model.pushEOL(value);
return this.model.getValue();
case 'insertFinalNewline':
return value ? insertFinalNewline(content) : content;
case 'trimTrailingWhitespace':
return value ? trimTrailingWhitespace(content) : content;
default:
return content;
}
}, this.model.getValue()),
);
}
dispose() { dispose() {
this.disposable.dispose(); if (!this.model.isDisposed()) this.applyCustomOptions();
this.events.forEach(cb => { this.events.forEach(cb => {
if (typeof cb === 'function') cb(); if (typeof cb === 'function') cb();
...@@ -106,5 +133,7 @@ export default class Model { ...@@ -106,5 +133,7 @@ export default class Model {
eventHub.$off(`editor.update.model.dispose.${this.file.key}`, this.dispose); eventHub.$off(`editor.update.model.dispose.${this.file.key}`, this.dispose);
eventHub.$off(`editor.update.model.content.${this.file.key}`, this.updateContent); eventHub.$off(`editor.update.model.content.${this.file.key}`, this.updateContent);
eventHub.$off(`editor.update.model.new.content.${this.file.key}`, this.updateNewContent); eventHub.$off(`editor.update.model.new.content.${this.file.key}`, this.updateNewContent);
this.disposable.dispose();
} }
} }
...@@ -50,10 +50,15 @@ export default class DirtyDiffController { ...@@ -50,10 +50,15 @@ export default class DirtyDiffController {
} }
computeDiff(model) { computeDiff(model) {
const originalModel = model.getOriginalModel();
const newModel = model.getModel();
if (originalModel.isDisposed() || newModel.isDisposed()) return;
this.dirtyDiffWorker.postMessage({ this.dirtyDiffWorker.postMessage({
path: model.path, path: model.path,
originalContent: model.getOriginalModel().getValue(), originalContent: originalModel.getValue(),
newContent: model.getModel().getValue(), newContent: newModel.getValue(),
}); });
} }
......
import { diffLines } from 'diff'; import { diffLines } from 'diff';
import { defaultDiffOptions } from '../editor_options';
// See: https://gitlab.com/gitlab-org/frontend/rfcs/-/issues/20
// eslint-disable-next-line import/prefer-default-export // eslint-disable-next-line import/prefer-default-export
export const computeDiff = (originalContent, newContent) => { export const computeDiff = (originalContent, newContent) => {
const changes = diffLines(originalContent, newContent); // prevent EOL changes from highlighting the entire file
const changes = diffLines(
originalContent.replace(/\r\n/g, '\n'),
newContent.replace(/\r\n/g, '\n'),
defaultDiffOptions,
);
let lineNumber = 1; let lineNumber = 1;
return changes.reduce((acc, change) => { return changes.reduce((acc, change) => {
......
...@@ -5,7 +5,7 @@ import DecorationsController from './decorations/controller'; ...@@ -5,7 +5,7 @@ import DecorationsController from './decorations/controller';
import DirtyDiffController from './diff/controller'; import DirtyDiffController from './diff/controller';
import Disposable from './common/disposable'; import Disposable from './common/disposable';
import ModelManager from './common/model_manager'; import ModelManager from './common/model_manager';
import editorOptions, { defaultEditorOptions } from './editor_options'; import { editorOptions, defaultEditorOptions, defaultDiffEditorOptions } from './editor_options';
import { themes } from './themes'; import { themes } from './themes';
import languages from './languages'; import languages from './languages';
import keymap from './keymap.json'; import keymap from './keymap.json';
...@@ -73,8 +73,7 @@ export default class Editor { ...@@ -73,8 +73,7 @@ export default class Editor {
this.disposable.add( this.disposable.add(
(this.instance = monacoEditor.createDiffEditor(domElement, { (this.instance = monacoEditor.createDiffEditor(domElement, {
...this.options, ...this.options,
quickSuggestions: false, ...defaultDiffEditorOptions,
occurrencesHighlight: false,
renderSideBySide: Editor.renderSideBySide(domElement), renderSideBySide: Editor.renderSideBySide(domElement),
readOnly, readOnly,
renderLineHighlight: readOnly ? 'all' : 'none', renderLineHighlight: readOnly ? 'all' : 'none',
......
...@@ -9,7 +9,23 @@ export const defaultEditorOptions = { ...@@ -9,7 +9,23 @@ export const defaultEditorOptions = {
wordWrap: 'on', wordWrap: 'on',
}; };
export default [ export const defaultDiffOptions = {
ignoreWhitespace: false,
};
export const defaultDiffEditorOptions = {
quickSuggestions: false,
occurrencesHighlight: false,
ignoreTrimWhitespace: false,
};
export const defaultModelOptions = {
endOfLine: 0,
insertFinalNewline: true,
trimTrailingWhitespace: false,
};
export const editorOptions = [
{ {
readOnly: model => Boolean(model.file.file_lock), readOnly: model => Boolean(model.file.file_lock),
quickSuggestions: model => !(model.language === 'markdown'), quickSuggestions: model => !(model.language === 'markdown'),
......
...@@ -77,7 +77,11 @@ export function registerLanguages(def, ...defs) { ...@@ -77,7 +77,11 @@ export function registerLanguages(def, ...defs) {
export const otherSide = side => (side === SIDE_RIGHT ? SIDE_LEFT : SIDE_RIGHT); export const otherSide = side => (side === SIDE_RIGHT ? SIDE_LEFT : SIDE_RIGHT);
export function addFinalNewline(content, eol = '\n') { export function trimTrailingWhitespace(content) {
return content.replace(/[^\S\r\n]+$/gm, '');
}
export function insertFinalNewline(content, eol = '\n') {
return content.slice(-eol.length) !== eol ? `${content}${eol}` : content; return content.slice(-eol.length) !== eol ? `${content}${eol}` : content;
} }
......
...@@ -283,25 +283,13 @@ describe('RepoEditor', () => { ...@@ -283,25 +283,13 @@ describe('RepoEditor', () => {
expect(vm.model.events.size).toBe(2); expect(vm.model.events.size).toBe(2);
}); });
it.each` it('updates state with the value of the model', () => {
insertFinalNewline | input | eol | output vm.model.setValue('testing 1234');
${true} | ${'testing 123\n'} | ${'\n'} | ${'testing 123\n'}
${true} | ${'testing 123'} | ${'\n'} | ${'testing 123\n'} vm.setupEditor();
${false} | ${'testing 123'} | ${'\n'} | ${'testing 123'}
${true} | ${'testing 123'} | ${'\r\n'} | ${'testing 123\r\n'} expect(vm.file.content).toBe('testing 1234');
${false} | ${'testing 123'} | ${'\r\n'} | ${'testing 123'} });
`(
'updates state with "$output" if `this.insertFinalNewline` is $insertFinalNewline',
({ insertFinalNewline, input, eol, output }) => {
jest.spyOn(vm.model.getModel(), 'getEOL').mockReturnValue(eol);
vm.addFinalNewline = insertFinalNewline;
vm.model.setValue(input);
expect(vm.file.content).toBe(output);
},
);
it('sets head model as staged file', () => { it('sets head model as staged file', () => {
jest.spyOn(vm.editor, 'createModel'); jest.spyOn(vm.editor, 'createModel');
......
...@@ -133,5 +133,77 @@ describe('Multi-file editor library model', () => { ...@@ -133,5 +133,77 @@ describe('Multi-file editor library model', () => {
expect(disposeSpy).toHaveBeenCalled(); expect(disposeSpy).toHaveBeenCalled();
}); });
it('applies custom options and triggers onChange callback', () => {
const changeSpy = jest.fn();
jest.spyOn(model, 'applyCustomOptions');
model.onChange(changeSpy);
model.dispose();
expect(model.applyCustomOptions).toHaveBeenCalled();
expect(changeSpy).toHaveBeenCalled();
});
});
describe('updateOptions', () => {
it('sets the options on the options object', () => {
model.updateOptions({ insertSpaces: true, someOption: 'some value' });
expect(model.options).toEqual({
endOfLine: 0,
insertFinalNewline: true,
insertSpaces: true,
someOption: 'some value',
trimTrailingWhitespace: false,
});
});
it.each`
option | value
${'insertSpaces'} | ${true}
${'insertSpaces'} | ${false}
${'indentSize'} | ${4}
${'tabSize'} | ${3}
`("correctly sets option: $option=$value to Monaco's TextModel", ({ option, value }) => {
model.updateOptions({ [option]: value });
expect(model.getModel().getOptions()).toMatchObject({ [option]: value });
});
it('applies custom options immediately', () => {
jest.spyOn(model, 'applyCustomOptions');
model.updateOptions({ trimTrailingWhitespace: true, someOption: 'some value' });
expect(model.applyCustomOptions).toHaveBeenCalled();
});
});
describe('applyCustomOptions', () => {
it.each`
option | value | contentBefore | contentAfter
${'endOfLine'} | ${0} | ${'hello\nworld\n'} | ${'hello\nworld\n'}
${'endOfLine'} | ${0} | ${'hello\r\nworld\r\n'} | ${'hello\nworld\n'}
${'endOfLine'} | ${1} | ${'hello\nworld\n'} | ${'hello\r\nworld\r\n'}
${'endOfLine'} | ${1} | ${'hello\r\nworld\r\n'} | ${'hello\r\nworld\r\n'}
${'insertFinalNewline'} | ${true} | ${'hello\nworld'} | ${'hello\nworld\n'}
${'insertFinalNewline'} | ${true} | ${'hello\nworld\n'} | ${'hello\nworld\n'}
${'insertFinalNewline'} | ${false} | ${'hello\nworld'} | ${'hello\nworld'}
${'trimTrailingWhitespace'} | ${true} | ${'hello \t\nworld \t\n'} | ${'hello\nworld\n'}
${'trimTrailingWhitespace'} | ${true} | ${'hello \t\r\nworld \t\r\n'} | ${'hello\nworld\n'}
${'trimTrailingWhitespace'} | ${false} | ${'hello \t\r\nworld \t\r\n'} | ${'hello \t\nworld \t\n'}
`(
'correctly applies custom option $option=$value to content',
({ option, value, contentBefore, contentAfter }) => {
model.options[option] = value;
model.updateNewContent(contentBefore);
model.applyCustomOptions();
expect(model.getModel().getValue()).toEqual(contentAfter);
},
);
}); });
}); });
...@@ -73,5 +73,13 @@ describe('Multi-file editor library diff calculator', () => { ...@@ -73,5 +73,13 @@ describe('Multi-file editor library diff calculator', () => {
expect(diff.endLineNumber).toBe(1); expect(diff.endLineNumber).toBe(1);
}); });
it('disregards changes for EOL type changes', () => {
const text1 = 'line1\nline2\nline3\n';
const text2 = 'line1\r\nline2\r\nline3\r\n';
expect(computeDiff(text1, text2)).toEqual([]);
expect(computeDiff(text2, text1)).toEqual([]);
});
}); });
}); });
import editorOptions from '~/ide/lib/editor_options';
describe('Multi-file editor library editor options', () => {
it('returns an array', () => {
expect(editorOptions).toEqual(expect.any(Array));
});
it('contains readOnly option', () => {
expect(editorOptions[0].readOnly).toBeDefined();
});
});
...@@ -72,6 +72,7 @@ describe('Multi-file editor library', () => { ...@@ -72,6 +72,7 @@ describe('Multi-file editor library', () => {
expect(monacoEditor.createDiffEditor).toHaveBeenCalledWith(holder, { expect(monacoEditor.createDiffEditor).toHaveBeenCalledWith(holder, {
...defaultEditorOptions, ...defaultEditorOptions,
ignoreTrimWhitespace: false,
quickSuggestions: false, quickSuggestions: false,
occurrencesHighlight: false, occurrencesHighlight: false,
renderSideBySide: false, renderSideBySide: false,
......
...@@ -2,7 +2,8 @@ import { ...@@ -2,7 +2,8 @@ import {
isTextFile, isTextFile,
registerLanguages, registerLanguages,
trimPathComponents, trimPathComponents,
addFinalNewline, insertFinalNewline,
trimTrailingWhitespace,
getPathParents, getPathParents,
} from '~/ide/utils'; } from '~/ide/utils';
import { languages } from 'monaco-editor'; import { languages } from 'monaco-editor';
...@@ -155,6 +156,20 @@ describe('WebIDE utils', () => { ...@@ -155,6 +156,20 @@ describe('WebIDE utils', () => {
}); });
}); });
describe('trimTrailingWhitespace', () => {
it.each`
input | output
${'text \n more text \n'} | ${'text\n more text\n'}
${'text \n more text \n\n \n'} | ${'text\n more text\n\n\n'}
${'text \t\t \n more text \n\t\ttext\n \n\t\t'} | ${'text\n more text\n\t\ttext\n\n'}
${'text \r\n more text \r\n'} | ${'text\r\n more text\r\n'}
${'text \r\n more text \r\n\r\n \r\n'} | ${'text\r\n more text\r\n\r\n\r\n'}
${'text \t\t \r\n more text \r\n\t\ttext\r\n \r\n\t\t'} | ${'text\r\n more text\r\n\t\ttext\r\n\r\n'}
`("trims trailing whitespace in each line of file's contents: $input", ({ input, output }) => {
expect(trimTrailingWhitespace(input)).toBe(output);
});
});
describe('addFinalNewline', () => { describe('addFinalNewline', () => {
it.each` it.each`
input | output input | output
...@@ -163,7 +178,7 @@ describe('WebIDE utils', () => { ...@@ -163,7 +178,7 @@ describe('WebIDE utils', () => {
${'some text\n\n'} | ${'some text\n\n'} ${'some text\n\n'} | ${'some text\n\n'}
${'some\n text'} | ${'some\n text\n'} ${'some\n text'} | ${'some\n text\n'}
`('adds a newline if it doesnt already exist for input: $input', ({ input, output }) => { `('adds a newline if it doesnt already exist for input: $input', ({ input, output }) => {
expect(addFinalNewline(input)).toEqual(output); expect(insertFinalNewline(input)).toBe(output);
}); });
it.each` it.each`
...@@ -174,7 +189,7 @@ describe('WebIDE utils', () => { ...@@ -174,7 +189,7 @@ describe('WebIDE utils', () => {
${'some text\r\n\r\n'} | ${'some text\r\n\r\n'} ${'some text\r\n\r\n'} | ${'some text\r\n\r\n'}
${'some\r\n text'} | ${'some\r\n text\r\n'} ${'some\r\n text'} | ${'some\r\n text\r\n'}
`('works with CRLF newline style; input: $input', ({ input, output }) => { `('works with CRLF newline style; input: $input', ({ input, output }) => {
expect(addFinalNewline(input, '\r\n')).toEqual(output); expect(insertFinalNewline(input, '\r\n')).toBe(output);
}); });
}); });
......
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