Commit 216aecfa authored by Phil Hughes's avatar Phil Hughes

EE port of multi-file-editor-dirty-diff-indicator

parent 3a7db45c
...@@ -3,19 +3,18 @@ ...@@ -3,19 +3,18 @@
import { mapGetters, mapActions } from 'vuex'; import { mapGetters, mapActions } from 'vuex';
import flash from '../../flash'; import flash from '../../flash';
import monacoLoader from '../monaco_loader'; import monacoLoader from '../monaco_loader';
import Editor from '../lib/editor';
export default { export default {
destroyed() { beforeDestroy() {
if (this.monacoInstance) { this.editor.dispose();
this.monacoInstance.destroy();
}
}, },
mounted() { mounted() {
if (this.monaco) { if (this.editor && monaco) {
this.initMonaco(); this.initMonaco();
} else { } else {
monacoLoader(['vs/editor/editor.main'], () => { monacoLoader(['vs/editor/editor.main'], () => {
this.monaco = monaco; this.editor = Editor.create(monaco);
this.initMonaco(); this.initMonaco();
}); });
...@@ -29,50 +28,25 @@ export default { ...@@ -29,50 +28,25 @@ export default {
initMonaco() { initMonaco() {
if (this.shouldHideEditor) return; if (this.shouldHideEditor) return;
if (this.monacoInstance) { this.editor.clearEditor();
this.monacoInstance.setModel(null);
}
this.getRawFileData(this.activeFile) this.getRawFileData(this.activeFile)
.then(() => { .then(() => {
if (!this.monacoInstance) { this.editor.createInstance(this.$refs.editor);
this.monacoInstance = this.monaco.editor.create(this.$el, {
model: null,
readOnly: false,
contextmenu: true,
scrollBeyondLastLine: false,
});
this.languages = this.monaco.languages.getLanguages();
this.addMonacoEvents();
}
this.setupEditor();
}) })
.then(() => this.setupEditor())
.catch(() => flash('Error setting up monaco. Please try again.')); .catch(() => flash('Error setting up monaco. Please try again.'));
}, },
setupEditor() { setupEditor() {
if (!this.activeFile) return; if (!this.activeFile) return;
const content = this.activeFile.content !== '' ? this.activeFile.content : this.activeFile.raw;
const foundLang = this.languages.find(lang => const model = this.editor.createModel(this.activeFile);
lang.extensions && lang.extensions.indexOf(this.activeFileExtension) === 0,
);
const newModel = this.monaco.editor.createModel(
content, foundLang ? foundLang.id : 'plaintext',
);
this.monacoInstance.setModel(newModel); this.editor.attachModel(model);
this.monacoInstance.updateOptions({ model.onChange((m) => {
readOnly: !!this.activeFile.file_lock,
});
},
addMonacoEvents() {
this.monacoInstance.onKeyUp(() => {
this.changeFileContent({ this.changeFileContent({
file: this.activeFile, file: this.activeFile,
content: this.monacoInstance.getValue(), content: m.getValue(),
}); });
}); });
}, },
...@@ -102,9 +76,14 @@ export default { ...@@ -102,9 +76,14 @@ export default {
class="blob-viewer-container blob-editor-container" class="blob-viewer-container blob-editor-container"
> >
<div <div
v-if="shouldHideEditor" v-show="shouldHideEditor"
v-html="activeFile.html" v-html="activeFile.html"
> >
</div> </div>
<div
v-show="!shouldHideEditor"
ref="editor"
>
</div>
</div> </div>
</template> </template>
export default class Disposable {
constructor() {
this.disposers = new Set();
}
add(...disposers) {
disposers.forEach(disposer => this.disposers.add(disposer));
}
dispose() {
this.disposers.forEach(disposer => disposer.dispose());
this.disposers.clear();
}
}
/* global monaco */
import Disposable from './disposable';
export default class Model {
constructor(monaco, file) {
this.monaco = monaco;
this.disposable = new Disposable();
this.file = file;
this.content = file.content !== '' ? file.content : file.raw;
this.disposable.add(
this.originalModel = this.monaco.editor.createModel(
this.file.raw,
undefined,
new this.monaco.Uri(null, null, `original/${this.file.path}`),
),
this.model = this.monaco.editor.createModel(
this.content,
undefined,
new this.monaco.Uri(null, null, this.file.path),
),
);
this.events = new Map();
}
get url() {
return this.model.uri.toString();
}
get path() {
return this.file.path;
}
getModel() {
return this.model;
}
getOriginalModel() {
return this.originalModel;
}
onChange(cb) {
this.events.set(
this.path,
this.disposable.add(
this.model.onDidChangeContent(e => cb(this.model, e)),
),
);
}
dispose() {
this.disposable.dispose();
this.events.clear();
}
}
import Disposable from './disposable';
import Model from './model';
export default class ModelManager {
constructor(monaco) {
this.monaco = monaco;
this.disposable = new Disposable();
this.models = new Map();
}
hasCachedModel(path) {
return this.models.has(path);
}
addModel(file) {
if (this.hasCachedModel(file.path)) {
return this.models.get(file.path);
}
const model = new Model(this.monaco, file);
this.models.set(model.path, model);
this.disposable.add(model);
return model;
}
dispose() {
// dispose of all the models
this.disposable.dispose();
this.models.clear();
}
}
export default class DecorationsController {
constructor(editor) {
this.editor = editor;
this.decorations = new Map();
this.editorDecorations = new Map();
}
getAllDecorationsForModel(model) {
if (!this.decorations.has(model.url)) return [];
const modelDecorations = this.decorations.get(model.url);
const decorations = [];
modelDecorations.forEach(val => decorations.push(...val));
return decorations;
}
addDecorations(model, decorationsKey, decorations) {
const decorationMap = this.decorations.get(model.url) || new Map();
decorationMap.set(decorationsKey, decorations);
this.decorations.set(model.url, decorationMap);
this.decorate(model);
}
decorate(model) {
const decorations = this.getAllDecorationsForModel(model);
const oldDecorations = this.editorDecorations.get(model.url) || [];
this.editorDecorations.set(
model.url,
this.editor.instance.deltaDecorations(oldDecorations, decorations),
);
}
dispose() {
this.decorations.clear();
this.editorDecorations.clear();
}
}
/* global monaco */
import { throttle } from 'underscore';
import DirtyDiffWorker from './diff_worker';
import Disposable from '../common/disposable';
export const getDiffChangeType = (change) => {
if (change.modified) {
return 'modified';
} else if (change.added) {
return 'added';
} else if (change.removed) {
return 'removed';
}
return '';
};
export const getDecorator = change => ({
range: new monaco.Range(
change.lineNumber,
1,
change.endLineNumber,
1,
),
options: {
isWholeLine: true,
linesDecorationsClassName: `dirty-diff dirty-diff-${getDiffChangeType(change)}`,
},
});
export default class DirtyDiffController {
constructor(modelManager, decorationsController) {
this.disposable = new Disposable();
this.editorSimpleWorker = null;
this.modelManager = modelManager;
this.decorationsController = decorationsController;
this.dirtyDiffWorker = new DirtyDiffWorker();
this.throttledComputeDiff = throttle(this.computeDiff, 250);
this.decorate = this.decorate.bind(this);
this.dirtyDiffWorker.addEventListener('message', this.decorate);
}
attachModel(model) {
model.onChange(() => this.throttledComputeDiff(model));
}
computeDiff(model) {
this.dirtyDiffWorker.postMessage({
path: model.path,
originalContent: model.getOriginalModel().getValue(),
newContent: model.getModel().getValue(),
});
}
reDecorate(model) {
this.decorationsController.decorate(model);
}
decorate({ data }) {
const decorations = data.changes.map(change => getDecorator(change));
this.decorationsController.addDecorations(data.path, 'dirtyDiff', decorations);
}
dispose() {
this.disposable.dispose();
this.dirtyDiffWorker.removeEventListener('message', this.decorate);
this.dirtyDiffWorker.terminate();
}
}
import { diffLines } from 'diff';
// eslint-disable-next-line import/prefer-default-export
export const computeDiff = (originalContent, newContent) => {
const changes = diffLines(originalContent, newContent);
let lineNumber = 1;
return changes.reduce((acc, change) => {
const findOnLine = acc.find(c => c.lineNumber === lineNumber);
if (findOnLine) {
Object.assign(findOnLine, change, {
modified: true,
endLineNumber: (lineNumber + change.count) - 1,
});
} else if ('added' in change || 'removed' in change) {
acc.push(Object.assign({}, change, {
lineNumber,
modified: undefined,
endLineNumber: (lineNumber + change.count) - 1,
}));
}
if (!change.removed) {
lineNumber += change.count;
}
return acc;
}, []);
};
import { computeDiff } from './diff';
self.addEventListener('message', (e) => {
const data = e.data;
self.postMessage({
path: data.path,
changes: computeDiff(data.originalContent, data.newContent),
});
});
import DecorationsController from './decorations/controller';
import DirtyDiffController from './diff/controller';
import Disposable from './common/disposable';
import ModelManager from './common/model_manager';
import editorOptions from './editor_options';
export default class Editor {
static create(monaco) {
this.editorInstance = new Editor(monaco);
return this.editorInstance;
}
constructor(monaco) {
this.monaco = monaco;
this.currentModel = null;
this.instance = null;
this.dirtyDiffController = null;
this.disposable = new Disposable();
this.disposable.add(
this.modelManager = new ModelManager(this.monaco),
this.decorationsController = new DecorationsController(this),
);
}
createInstance(domElement) {
if (!this.instance) {
this.disposable.add(
this.instance = this.monaco.editor.create(domElement, {
model: null,
readOnly: false,
contextmenu: true,
scrollBeyondLastLine: false,
}),
this.dirtyDiffController = new DirtyDiffController(
this.modelManager, this.decorationsController,
),
);
}
}
createModel(file) {
return this.modelManager.addModel(file);
}
attachModel(model) {
this.instance.setModel(model.getModel());
this.dirtyDiffController.attachModel(model);
this.currentModel = model;
this.instance.updateOptions(editorOptions.reduce((acc, obj) => {
Object.keys(obj).forEach((key) => {
Object.assign(acc, {
[key]: obj[key](model),
});
});
return acc;
}, {}));
this.dirtyDiffController.reDecorate(model);
}
clearEditor() {
if (this.instance) {
this.instance.setModel(null);
}
}
dispose() {
this.disposable.dispose();
// dispose main monaco instance
if (this.instance) {
this.instance = null;
}
}
}
export default [{
readOnly: model => !!model.file.file_lock,
}];
...@@ -16,6 +16,10 @@ export default { ...@@ -16,6 +16,10 @@ export default {
return Promise.resolve(file.content); return Promise.resolve(file.content);
} }
if (file.raw) {
return Promise.resolve(file.raw);
}
return Vue.http.get(file.rawPath, { params: { format: 'json' } }) return Vue.http.get(file.rawPath, { params: { format: 'json' } })
.then(res => res.text()); .then(res => res.text());
}, },
......
...@@ -70,6 +70,7 @@ ...@@ -70,6 +70,7 @@
.line-numbers { .line-numbers {
cursor: pointer; cursor: pointer;
min-width: initial;
&:hover { &:hover {
text-decoration: underline; text-decoration: underline;
...@@ -309,3 +310,36 @@ ...@@ -309,3 +310,36 @@
.multi-file-table-col-name { .multi-file-table-col-name {
width: 350px; width: 350px;
} }
.dirty-diff {
// !important need to override monaco inline style
width: 4px !important;
left: 0 !important;
&-modified {
background-color: $blue-500;
}
&-added {
background-color: $green-600;
}
&-removed {
height: 0 !important;
width: 0 !important;
bottom: -2px;
border-style: solid;
border-width: 5px;
border-color: transparent transparent transparent $red-500;
&::before {
content: '';
position: absolute;
left: 0;
top: 0;
width: 100px;
height: 1px;
background-color: rgba($red-500, .5);
}
}
}
...@@ -128,6 +128,10 @@ var config = { ...@@ -128,6 +128,10 @@ var config = {
loader: 'url-loader', loader: 'url-loader',
options: { limit: 2048 }, options: { limit: 2048 },
}, },
{
test: /\_worker\.js$/,
loader: 'worker-loader',
},
{ {
test: /\.(worker(\.min)?\.js|pdf|bmpr)$/, test: /\.(worker(\.min)?\.js|pdf|bmpr)$/,
exclude: /node_modules/, exclude: /node_modules/,
......
import Vue from 'vue'; import Vue from 'vue';
import store from '~/repo/stores'; import store from '~/repo/stores';
import repoEditor from '~/repo/components/repo_editor.vue'; import repoEditor from '~/repo/components/repo_editor.vue';
import monacoLoader from '~/repo/monaco_loader';
import { file, resetStore } from '../helpers'; import { file, resetStore } from '../helpers';
describe('RepoEditor', () => { describe('RepoEditor', () => {
let vm; let vm;
beforeEach(() => { beforeEach((done) => {
const f = file(); const f = file();
const RepoEditor = Vue.extend(repoEditor); const RepoEditor = Vue.extend(repoEditor);
...@@ -21,6 +22,10 @@ describe('RepoEditor', () => { ...@@ -21,6 +22,10 @@ describe('RepoEditor', () => {
vm.monaco = true; vm.monaco = true;
vm.$mount(); vm.$mount();
monacoLoader(['vs/editor/editor.main'], () => {
setTimeout(done, 0);
});
}); });
afterEach(() => { afterEach(() => {
...@@ -32,7 +37,6 @@ describe('RepoEditor', () => { ...@@ -32,7 +37,6 @@ describe('RepoEditor', () => {
it('renders an ide container', (done) => { it('renders an ide container', (done) => {
Vue.nextTick(() => { Vue.nextTick(() => {
expect(vm.shouldHideEditor).toBeFalsy(); expect(vm.shouldHideEditor).toBeFalsy();
expect(vm.$el.textContent.trim()).toBe('');
done(); done();
}); });
...@@ -50,51 +54,7 @@ describe('RepoEditor', () => { ...@@ -50,51 +54,7 @@ describe('RepoEditor', () => {
}); });
it('shows activeFile html', () => { it('shows activeFile html', () => {
expect(vm.$el.textContent.trim()).toBe('testing'); expect(vm.$el.textContent).toContain('testing');
});
});
describe('when open file is locked', () => {
beforeEach((done) => {
const f = file('test', '123', 'plaintext');
f.active = true;
f.tempFile = true;
const RepoEditor = Vue.extend(repoEditor);
vm = new RepoEditor({
store,
});
// Stubbing the getRawFileData Method to return a plain content
spyOn(vm, 'getRawFileData').and.callFake(() => Promise.resolve('testing'));
// Spying on setupEditor so we know when the async process executed
vm.oldSetupEditor = vm.setupEditor;
spyOn(vm, 'setupEditor').and.callFake(() => {
spyOn(vm.monacoInstance, 'updateOptions');
vm.oldSetupEditor();
Vue.nextTick(() => {
done();
});
});
vm.$store.state.openFiles.push(f);
vm.$store.getters.activeFile.html = 'testing';
vm.$store.getters.activeFile.file_lock = {
user: {
name: 'testuser',
updated_at: new Date(),
},
};
vm.$mount();
});
it('Monaco should be in read-only mode', () => {
expect(vm.monacoInstance.updateOptions).toHaveBeenCalledWith({
readOnly: true,
});
}); });
}); });
}); });
import Disposable from '~/repo/lib/common/disposable';
describe('Multi-file editor library disposable class', () => {
let instance;
let disposableClass;
beforeEach(() => {
instance = new Disposable();
disposableClass = {
dispose: jasmine.createSpy('dispose'),
};
});
afterEach(() => {
instance.dispose();
});
describe('add', () => {
it('adds disposable classes', () => {
instance.add(disposableClass);
expect(instance.disposers.size).toBe(1);
});
});
describe('dispose', () => {
beforeEach(() => {
instance.add(disposableClass);
});
it('calls dispose on all cached disposers', () => {
instance.dispose();
expect(disposableClass.dispose).toHaveBeenCalled();
});
it('clears cached disposers', () => {
instance.dispose();
expect(instance.disposers.size).toBe(0);
});
});
});
/* global monaco */
import monacoLoader from '~/repo/monaco_loader';
import ModelManager from '~/repo/lib/common/model_manager';
import { file } from '../../helpers';
describe('Multi-file editor library model manager', () => {
let instance;
beforeEach((done) => {
monacoLoader(['vs/editor/editor.main'], () => {
instance = new ModelManager(monaco);
done();
});
});
afterEach(() => {
instance.dispose();
});
describe('addModel', () => {
it('caches model', () => {
instance.addModel(file());
expect(instance.models.size).toBe(1);
});
it('caches model by file path', () => {
instance.addModel(file('path-name'));
expect(instance.models.keys().next().value).toBe('path-name');
});
it('adds model into disposable', () => {
spyOn(instance.disposable, 'add').and.callThrough();
instance.addModel(file());
expect(instance.disposable.add).toHaveBeenCalled();
});
it('returns cached model', () => {
spyOn(instance.models, 'get').and.callThrough();
instance.addModel(file());
instance.addModel(file());
expect(instance.models.get).toHaveBeenCalled();
});
});
describe('hasCachedModel', () => {
it('returns false when no models exist', () => {
expect(instance.hasCachedModel('path')).toBeFalsy();
});
it('returns true when model exists', () => {
instance.addModel(file('path-name'));
expect(instance.hasCachedModel('path-name')).toBeTruthy();
});
});
describe('dispose', () => {
it('clears cached models', () => {
instance.addModel(file());
instance.dispose();
expect(instance.models.size).toBe(0);
});
it('calls disposable dispose', () => {
spyOn(instance.disposable, 'dispose').and.callThrough();
instance.dispose();
expect(instance.disposable.dispose).toHaveBeenCalled();
});
});
});
/* global monaco */
import monacoLoader from '~/repo/monaco_loader';
import Model from '~/repo/lib/common/model';
import { file } from '../../helpers';
describe('Multi-file editor library model', () => {
let model;
beforeEach((done) => {
monacoLoader(['vs/editor/editor.main'], () => {
model = new Model(monaco, file('path'));
done();
});
});
afterEach(() => {
model.dispose();
});
it('creates original model & new model', () => {
expect(model.originalModel).not.toBeNull();
expect(model.model).not.toBeNull();
});
describe('path', () => {
it('returns file path', () => {
expect(model.path).toBe('path');
});
});
describe('getModel', () => {
it('returns model', () => {
expect(model.getModel()).toBe(model.model);
});
});
describe('getOriginalModel', () => {
it('returns original model', () => {
expect(model.getOriginalModel()).toBe(model.originalModel);
});
});
describe('onChange', () => {
it('caches event by path', () => {
model.onChange(() => {});
expect(model.events.size).toBe(1);
expect(model.events.keys().next().value).toBe('path');
});
it('calls callback on change', (done) => {
const spy = jasmine.createSpy();
model.onChange(spy);
model.getModel().setValue('123');
setTimeout(() => {
expect(spy).toHaveBeenCalledWith(model.getModel(), jasmine.anything());
done();
});
});
});
describe('dispose', () => {
it('calls disposable dispose', () => {
spyOn(model.disposable, 'dispose').and.callThrough();
model.dispose();
expect(model.disposable.dispose).toHaveBeenCalled();
});
it('clears events', () => {
model.onChange(() => {});
expect(model.events.size).toBe(1);
model.dispose();
expect(model.events.size).toBe(0);
});
});
});
/* global monaco */
import monacoLoader from '~/repo/monaco_loader';
import editor from '~/repo/lib/editor';
import DecorationsController from '~/repo/lib/decorations/controller';
import Model from '~/repo/lib/common/model';
import { file } from '../../helpers';
describe('Multi-file editor library decorations controller', () => {
let editorInstance;
let controller;
let model;
beforeEach((done) => {
monacoLoader(['vs/editor/editor.main'], () => {
editorInstance = editor.create(monaco);
editorInstance.createInstance(document.createElement('div'));
controller = new DecorationsController(editorInstance);
model = new Model(monaco, file('path'));
done();
});
});
afterEach(() => {
model.dispose();
editorInstance.dispose();
controller.dispose();
});
describe('getAllDecorationsForModel', () => {
it('returns empty array when no decorations exist for model', () => {
const decorations = controller.getAllDecorationsForModel(model);
expect(decorations).toEqual([]);
});
it('returns decorations by model URL', () => {
controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
const decorations = controller.getAllDecorationsForModel(model);
expect(decorations[0]).toEqual({ decoration: 'decorationValue' });
});
});
describe('addDecorations', () => {
it('caches decorations in a new map', () => {
controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
expect(controller.decorations.size).toBe(1);
});
it('does not create new cache model', () => {
controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
controller.addDecorations(model, 'key', [{ decoration: 'decorationValue2' }]);
expect(controller.decorations.size).toBe(1);
});
it('caches decorations by model URL', () => {
controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
expect(controller.decorations.size).toBe(1);
expect(controller.decorations.keys().next().value).toBe('path');
});
it('calls decorate method', () => {
spyOn(controller, 'decorate');
controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
expect(controller.decorate).toHaveBeenCalled();
});
});
describe('decorate', () => {
it('sets decorations on editor instance', () => {
spyOn(controller.editor.instance, 'deltaDecorations');
controller.decorate(model);
expect(controller.editor.instance.deltaDecorations).toHaveBeenCalledWith([], []);
});
it('caches decorations', () => {
spyOn(controller.editor.instance, 'deltaDecorations').and.returnValue([]);
controller.decorate(model);
expect(controller.editorDecorations.size).toBe(1);
});
it('caches decorations by model URL', () => {
spyOn(controller.editor.instance, 'deltaDecorations').and.returnValue([]);
controller.decorate(model);
expect(controller.editorDecorations.keys().next().value).toBe('path');
});
});
describe('dispose', () => {
it('clears cached decorations', () => {
controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
controller.dispose();
expect(controller.decorations.size).toBe(0);
});
it('clears cached editorDecorations', () => {
controller.addDecorations(model, 'key', [{ decoration: 'decorationValue' }]);
controller.dispose();
expect(controller.editorDecorations.size).toBe(0);
});
});
});
/* global monaco */
import monacoLoader from '~/repo/monaco_loader';
import editor from '~/repo/lib/editor';
import ModelManager from '~/repo/lib/common/model_manager';
import DecorationsController from '~/repo/lib/decorations/controller';
import DirtyDiffController, { getDiffChangeType, getDecorator } from '~/repo/lib/diff/controller';
import { computeDiff } from '~/repo/lib/diff/diff';
import { file } from '../../helpers';
describe('Multi-file editor library dirty diff controller', () => {
let editorInstance;
let controller;
let modelManager;
let decorationsController;
let model;
beforeEach((done) => {
monacoLoader(['vs/editor/editor.main'], () => {
editorInstance = editor.create(monaco);
editorInstance.createInstance(document.createElement('div'));
modelManager = new ModelManager(monaco);
decorationsController = new DecorationsController(editorInstance);
model = modelManager.addModel(file());
controller = new DirtyDiffController(modelManager, decorationsController);
done();
});
});
afterEach(() => {
controller.dispose();
model.dispose();
decorationsController.dispose();
editorInstance.dispose();
});
describe('getDiffChangeType', () => {
['added', 'removed', 'modified'].forEach((type) => {
it(`returns ${type}`, () => {
const change = {
[type]: true,
};
expect(getDiffChangeType(change)).toBe(type);
});
});
});
describe('getDecorator', () => {
['added', 'removed', 'modified'].forEach((type) => {
it(`returns with linesDecorationsClassName for ${type}`, () => {
const change = {
[type]: true,
};
expect(
getDecorator(change).options.linesDecorationsClassName,
).toBe(`dirty-diff dirty-diff-${type}`);
});
it('returns with line numbers', () => {
const change = {
lineNumber: 1,
endLineNumber: 2,
[type]: true,
};
const range = getDecorator(change).range;
expect(range.startLineNumber).toBe(1);
expect(range.endLineNumber).toBe(2);
expect(range.startColumn).toBe(1);
expect(range.endColumn).toBe(1);
});
});
});
describe('attachModel', () => {
it('adds change event callback', () => {
spyOn(model, 'onChange');
controller.attachModel(model);
expect(model.onChange).toHaveBeenCalled();
});
it('calls throttledComputeDiff on change', () => {
spyOn(controller, 'throttledComputeDiff');
controller.attachModel(model);
model.getModel().setValue('123');
expect(controller.throttledComputeDiff).toHaveBeenCalled();
});
});
describe('computeDiff', () => {
it('posts to worker', () => {
spyOn(controller.dirtyDiffWorker, 'postMessage');
controller.computeDiff(model);
expect(controller.dirtyDiffWorker.postMessage).toHaveBeenCalledWith({
path: model.path,
originalContent: '',
newContent: '',
});
});
});
describe('reDecorate', () => {
it('calls decorations controller decorate', () => {
spyOn(controller.decorationsController, 'decorate');
controller.reDecorate(model);
expect(controller.decorationsController.decorate).toHaveBeenCalledWith(model);
});
});
describe('decorate', () => {
it('adds decorations into decorations controller', () => {
spyOn(controller.decorationsController, 'addDecorations');
controller.decorate({ data: { changes: [], path: 'path' } });
expect(controller.decorationsController.addDecorations).toHaveBeenCalledWith('path', 'dirtyDiff', jasmine.anything());
});
it('adds decorations into editor', () => {
const spy = spyOn(controller.decorationsController.editor.instance, 'deltaDecorations');
controller.decorate({ data: { changes: computeDiff('123', '1234'), path: 'path' } });
expect(spy).toHaveBeenCalledWith([], [{
range: new monaco.Range(
1, 1, 1, 1,
),
options: {
isWholeLine: true,
linesDecorationsClassName: 'dirty-diff dirty-diff-modified',
},
}]);
});
});
describe('dispose', () => {
it('calls disposable dispose', () => {
spyOn(controller.disposable, 'dispose').and.callThrough();
controller.dispose();
expect(controller.disposable.dispose).toHaveBeenCalled();
});
it('terminates worker', () => {
spyOn(controller.dirtyDiffWorker, 'terminate').and.callThrough();
controller.dispose();
expect(controller.dirtyDiffWorker.terminate).toHaveBeenCalled();
});
it('removes worker event listener', () => {
spyOn(controller.dirtyDiffWorker, 'removeEventListener').and.callThrough();
controller.dispose();
expect(controller.dirtyDiffWorker.removeEventListener).toHaveBeenCalledWith('message', jasmine.anything());
});
});
});
import { computeDiff } from '~/repo/lib/diff/diff';
describe('Multi-file editor library diff calculator', () => {
describe('computeDiff', () => {
it('returns empty array if no changes', () => {
const diff = computeDiff('123', '123');
expect(diff).toEqual([]);
});
describe('modified', () => {
it('', () => {
const diff = computeDiff('123', '1234');
expect(diff[0].added).toBeTruthy();
expect(diff[0].modified).toBeTruthy();
expect(diff[0].removed).toBeUndefined();
});
it('', () => {
const diff = computeDiff('123\n123\n123', '123\n1234\n123');
expect(diff[0].added).toBeTruthy();
expect(diff[0].modified).toBeTruthy();
expect(diff[0].removed).toBeUndefined();
expect(diff[0].lineNumber).toBe(2);
});
});
describe('added', () => {
it('', () => {
const diff = computeDiff('123', '123\n123');
expect(diff[0].added).toBeTruthy();
expect(diff[0].modified).toBeUndefined();
expect(diff[0].removed).toBeUndefined();
});
it('', () => {
const diff = computeDiff('123\n123\n123', '123\n123\n1234\n123');
expect(diff[0].added).toBeTruthy();
expect(diff[0].modified).toBeUndefined();
expect(diff[0].removed).toBeUndefined();
expect(diff[0].lineNumber).toBe(3);
});
});
describe('removed', () => {
it('', () => {
const diff = computeDiff('123', '');
expect(diff[0].added).toBeUndefined();
expect(diff[0].modified).toBeUndefined();
expect(diff[0].removed).toBeTruthy();
});
it('', () => {
const diff = computeDiff('123\n123\n123', '123\n123');
expect(diff[0].added).toBeUndefined();
expect(diff[0].modified).toBeTruthy();
expect(diff[0].removed).toBeTruthy();
expect(diff[0].lineNumber).toBe(2);
});
});
it('includes line number of change', () => {
const diff = computeDiff('123', '');
expect(diff[0].lineNumber).toBe(1);
});
it('includes end line number of change', () => {
const diff = computeDiff('123', '');
expect(diff[0].endLineNumber).toBe(1);
});
});
});
import editorOptions from '~/repo/lib/editor_options';
describe('Multi-file editor library editor options', () => {
it('returns an array', () => {
expect(editorOptions).toEqual(jasmine.any(Array));
});
it('contains readOnly option', () => {
expect(editorOptions[0].readOnly).toBeDefined();
});
});
/* global monaco */
import monacoLoader from '~/repo/monaco_loader';
import editor from '~/repo/lib/editor';
import { file } from '../helpers';
describe('Multi-file editor library', () => {
let instance;
beforeEach((done) => {
monacoLoader(['vs/editor/editor.main'], () => {
instance = editor.create(monaco);
done();
});
});
afterEach(() => {
instance.dispose();
});
it('creates instance of editor', () => {
expect(editor.editorInstance).not.toBeNull();
});
describe('createInstance', () => {
let el;
beforeEach(() => {
el = document.createElement('div');
});
it('creates editor instance', () => {
spyOn(instance.monaco.editor, 'create').and.callThrough();
instance.createInstance(el);
expect(instance.monaco.editor.create).toHaveBeenCalled();
});
it('creates dirty diff controller', () => {
instance.createInstance(el);
expect(instance.dirtyDiffController).not.toBeNull();
});
});
describe('createModel', () => {
it('calls model manager addModel', () => {
spyOn(instance.modelManager, 'addModel');
instance.createModel('FILE');
expect(instance.modelManager.addModel).toHaveBeenCalledWith('FILE');
});
});
describe('attachModel', () => {
let model;
beforeEach(() => {
instance.createInstance(document.createElement('div'));
model = instance.createModel(file());
});
it('sets the current model on the instance', () => {
instance.attachModel(model);
expect(instance.currentModel).toBe(model);
});
it('attaches the model to the current instance', () => {
spyOn(instance.instance, 'setModel');
instance.attachModel(model);
expect(instance.instance.setModel).toHaveBeenCalledWith(model.getModel());
});
it('attaches the model to the dirty diff controller', () => {
spyOn(instance.dirtyDiffController, 'attachModel');
instance.attachModel(model);
expect(instance.dirtyDiffController.attachModel).toHaveBeenCalledWith(model);
});
it('re-decorates with the dirty diff controller', () => {
spyOn(instance.dirtyDiffController, 'reDecorate');
instance.attachModel(model);
expect(instance.dirtyDiffController.reDecorate).toHaveBeenCalledWith(model);
});
describe('updateOptions', () => {
it('defaults readOnly to false', () => {
spyOn(instance.instance, 'updateOptions');
instance.attachModel(model);
expect(instance.instance.updateOptions).toHaveBeenCalledWith({
readOnly: false,
});
});
it('puts editor into readOnly mode when file is locked', () => {
spyOn(instance.instance, 'updateOptions');
Object.assign(model.file, {
file_lock: {
name: 'test',
},
});
instance.attachModel(model);
expect(instance.instance.updateOptions).toHaveBeenCalledWith({
readOnly: true,
});
});
});
});
describe('clearEditor', () => {
it('resets the editor model', () => {
instance.createInstance(document.createElement('div'));
spyOn(instance.instance, 'setModel');
instance.clearEditor();
expect(instance.instance.setModel).toHaveBeenCalledWith(null);
});
});
describe('dispose', () => {
it('calls disposble dispose method', () => {
spyOn(instance.disposable, 'dispose').and.callThrough();
instance.dispose();
expect(instance.disposable.dispose).toHaveBeenCalled();
});
it('resets instance', () => {
instance.createInstance(document.createElement('div'));
expect(instance.instance).not.toBeNull();
instance.dispose();
expect(instance.instance).toBeNull();
});
});
});
...@@ -116,6 +116,15 @@ ajv@^4.7.0, ajv@^4.9.1: ...@@ -116,6 +116,15 @@ ajv@^4.7.0, ajv@^4.9.1:
co "^4.6.0" co "^4.6.0"
json-stable-stringify "^1.0.1" json-stable-stringify "^1.0.1"
ajv@^5.0.0:
version "5.4.0"
resolved "https://registry.yarnpkg.com/ajv/-/ajv-5.4.0.tgz#32d1cf08dbc80c432f426f12e10b2511f6b46474"
dependencies:
co "^4.6.0"
fast-deep-equal "^1.0.0"
fast-json-stable-stringify "^2.0.0"
json-schema-traverse "^0.3.0"
ajv@^5.1.5: ajv@^5.1.5:
version "5.2.2" version "5.2.2"
resolved "https://registry.yarnpkg.com/ajv/-/ajv-5.2.2.tgz#47c68d69e86f5d953103b0074a9430dc63da5e39" resolved "https://registry.yarnpkg.com/ajv/-/ajv-5.2.2.tgz#47c68d69e86f5d953103b0074a9430dc63da5e39"
...@@ -1895,6 +1904,10 @@ di@^0.0.1: ...@@ -1895,6 +1904,10 @@ di@^0.0.1:
version "0.0.1" version "0.0.1"
resolved "https://registry.yarnpkg.com/di/-/di-0.0.1.tgz#806649326ceaa7caa3306d75d985ea2748ba913c" resolved "https://registry.yarnpkg.com/di/-/di-0.0.1.tgz#806649326ceaa7caa3306d75d985ea2748ba913c"
diff@^3.4.0:
version "3.4.0"
resolved "https://registry.yarnpkg.com/diff/-/diff-3.4.0.tgz#b1d85507daf3964828de54b37d0d73ba67dda56c"
diffie-hellman@^5.0.0: diffie-hellman@^5.0.0:
version "5.0.2" version "5.0.2"
resolved "https://registry.yarnpkg.com/diffie-hellman/-/diffie-hellman-5.0.2.tgz#b5835739270cfe26acf632099fded2a07f209e5e" resolved "https://registry.yarnpkg.com/diffie-hellman/-/diffie-hellman-5.0.2.tgz#b5835739270cfe26acf632099fded2a07f209e5e"
...@@ -2527,6 +2540,10 @@ fast-deep-equal@^1.0.0: ...@@ -2527,6 +2540,10 @@ fast-deep-equal@^1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-1.0.0.tgz#96256a3bc975595eb36d82e9929d060d893439ff" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-1.0.0.tgz#96256a3bc975595eb36d82e9929d060d893439ff"
fast-json-stable-stringify@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz#d5142c0caee6b1189f87d3a76111064f86c8bbf2"
fast-levenshtein@~2.0.4: fast-levenshtein@~2.0.4:
version "2.0.6" version "2.0.6"
resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917"
...@@ -3843,7 +3860,7 @@ loader-utils@^0.2.15, loader-utils@^0.2.5: ...@@ -3843,7 +3860,7 @@ loader-utils@^0.2.15, loader-utils@^0.2.5:
json5 "^0.5.0" json5 "^0.5.0"
object-assign "^4.0.1" object-assign "^4.0.1"
loader-utils@^1.0.2, loader-utils@^1.1.0: loader-utils@^1.0.0, loader-utils@^1.0.2, loader-utils@^1.1.0:
version "1.1.0" version "1.1.0"
resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.1.0.tgz#c98aef488bcceda2ffb5e2de646d6a754429f5cd" resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.1.0.tgz#c98aef488bcceda2ffb5e2de646d6a754429f5cd"
dependencies: dependencies:
...@@ -5534,6 +5551,12 @@ sax@~1.2.1: ...@@ -5534,6 +5551,12 @@ sax@~1.2.1:
version "1.2.2" version "1.2.2"
resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.2.tgz#fd8631a23bc7826bef5d871bdb87378c95647828" resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.2.tgz#fd8631a23bc7826bef5d871bdb87378c95647828"
schema-utils@^0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-0.3.0.tgz#f5877222ce3e931edae039f17eb3716e7137f8cf"
dependencies:
ajv "^5.0.0"
select-hose@^2.0.0: select-hose@^2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca" resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca"
...@@ -6601,6 +6624,13 @@ wordwrap@~0.0.2: ...@@ -6601,6 +6624,13 @@ wordwrap@~0.0.2:
version "0.0.3" version "0.0.3"
resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.3.tgz#a3d5da6cd5c0bc0008d37234bbaf1bed63059107" resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.3.tgz#a3d5da6cd5c0bc0008d37234bbaf1bed63059107"
worker-loader@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/worker-loader/-/worker-loader-1.1.0.tgz#8cf21869a07add84d66f821d948d23c1eb98e809"
dependencies:
loader-utils "^1.0.0"
schema-utils "^0.3.0"
wrap-ansi@^2.0.0: wrap-ansi@^2.0.0:
version "2.1.0" version "2.1.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-2.1.0.tgz#d8fc3d284dd05794fe84973caecdd1cf824fdd85" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-2.1.0.tgz#d8fc3d284dd05794fe84973caecdd1cf824fdd85"
......
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