Commit ceed1934 authored by Denys Mishunov's avatar Denys Mishunov Committed by David O'Regan

Converted WebIDE into a proper EL extension

- Create new instance on view change
- Moved modelManager to RepoEditor
- Do not create new instance for every file
- Removed DirtyDiffController from the extension
- Removed DecorationsController
- Removed un-used-anymore currentModel
- Dispose editor only when editor exists
- Holistic handling of the model attachment
parent 81b2ae7f
......@@ -16,6 +16,9 @@ export const EDITOR_READY_EVENT = 'editor-ready';
export const EDITOR_TYPE_CODE = 'vs.editor.ICodeEditor';
export const EDITOR_TYPE_DIFF = 'vs.editor.IDiffEditor';
export const EDITOR_CODE_INSTANCE_FN = 'createInstance';
export const EDITOR_DIFF_INSTANCE_FN = 'createDiffInstance';
//
// EXTENSIONS' CONSTANTS
//
......
import { debounce } from 'lodash';
import { KeyCode, KeyMod, Range } from 'monaco-editor';
import { EDITOR_TYPE_DIFF } from '~/editor/constants';
import { EditorLiteExtension } from '~/editor/extensions/editor_lite_extension_base';
import Disposable from '~/ide/lib/common/disposable';
import { editorOptions } from '~/ide/lib/editor_options';
import keymap from '~/ide/lib/keymap.json';
const isDiffEditorType = (instance) => {
return instance.getEditorType() === EDITOR_TYPE_DIFF;
};
export const UPDATE_DIMENSIONS_DELAY = 200;
export class EditorWebIdeExtension extends EditorLiteExtension {
constructor({ instance, modelManager, ...options } = {}) {
super({
instance,
...options,
modelManager,
disposable: new Disposable(),
debouncedUpdate: debounce(() => {
instance.updateDimensions();
}, UPDATE_DIMENSIONS_DELAY),
});
window.addEventListener('resize', instance.debouncedUpdate, false);
instance.onDidDispose(() => {
window.removeEventListener('resize', instance.debouncedUpdate);
// catch any potential errors with disposing the error
// this is mainly for tests caused by elements not existing
try {
instance.disposable.dispose();
} catch (e) {
if (process.env.NODE_ENV !== 'test') {
// eslint-disable-next-line no-console
console.error(e);
}
}
});
EditorWebIdeExtension.addActions(instance);
}
static addActions(instance) {
const { store } = instance;
const getKeyCode = (key) => {
const monacoKeyMod = key.indexOf('KEY_') === 0;
return monacoKeyMod ? KeyCode[key] : KeyMod[key];
};
keymap.forEach((command) => {
const { bindings, id, label, action } = command;
const keybindings = bindings.map((binding) => {
const keys = binding.split('+');
// eslint-disable-next-line no-bitwise
return keys.length > 1 ? getKeyCode(keys[0]) | getKeyCode(keys[1]) : getKeyCode(keys[0]);
});
instance.addAction({
id,
label,
keybindings,
run() {
store.dispatch(action.name, action.params);
return null;
},
});
});
}
createModel(file, head = null) {
return this.modelManager.addModel(file, head);
}
attachModel(model) {
if (isDiffEditorType(this)) {
this.setModel({
original: model.getOriginalModel(),
modified: model.getModel(),
});
return;
}
this.setModel(model.getModel());
this.updateOptions(
editorOptions.reduce((acc, obj) => {
Object.keys(obj).forEach((key) => {
Object.assign(acc, {
[key]: obj[key](model),
});
});
return acc;
}, {}),
);
}
attachMergeRequestModel(model) {
this.setModel({
original: model.getBaseModel(),
modified: model.getModel(),
});
}
updateDimensions() {
this.layout();
this.updateDiffView();
}
setPos({ lineNumber, column }) {
this.revealPositionInCenter({
lineNumber,
column,
});
this.setPosition({
lineNumber,
column,
});
}
onPositionChange(cb) {
if (!this.onDidChangeCursorPosition) {
return;
}
this.disposable.add(this.onDidChangeCursorPosition((e) => cb(this, e)));
}
updateDiffView() {
if (!isDiffEditorType(this)) {
return;
}
this.updateOptions({
renderSideBySide: EditorWebIdeExtension.renderSideBySide(this.getDomNode()),
});
}
replaceSelectedText(text) {
let selection = this.getSelection();
const range = new Range(
selection.startLineNumber,
selection.startColumn,
selection.endLineNumber,
selection.endColumn,
);
this.executeEdits('', [{ range, text }]);
selection = this.getSelection();
this.setPosition({ lineNumber: selection.endLineNumber, column: selection.endColumn });
}
static renderSideBySide(domElement) {
return domElement.offsetWidth >= 700;
}
}
......@@ -49,7 +49,9 @@ export default {
</script>
<template>
<div class="d-flex align-items-center ide-file-templates qa-file-templates-bar">
<div
class="d-flex align-items-center ide-file-templates qa-file-templates-bar gl-relative gl-z-index-1"
>
<strong class="gl-mr-3"> {{ __('File templates') }} </strong>
<dropdown
:data="templateTypes"
......
<script>
import { mapState, mapGetters, mapActions } from 'vuex';
import {
EDITOR_TYPE_DIFF,
EDITOR_CODE_INSTANCE_FN,
EDITOR_DIFF_INSTANCE_FN,
} from '~/editor/constants';
import EditorLite from '~/editor/editor_lite';
import { EditorWebIdeExtension } from '~/editor/extensions/editor_lite_webide_ext';
import { deprecatedCreateFlash as flash } from '~/flash';
import ModelManager from '~/ide/lib/common/model_manager';
import { defaultDiffEditorOptions, defaultEditorOptions } from '~/ide/lib/editor_options';
import { __ } from '~/locale';
import {
WEBIDE_MARK_FILE_CLICKED,
......@@ -20,7 +29,6 @@ import {
FILE_VIEW_MODE_PREVIEW,
} from '../constants';
import eventHub from '../eventhub';
import Editor from '../lib/editor';
import { getRulesWithTraversal } from '../lib/editorconfig/parser';
import mapRulesToMonaco from '../lib/editorconfig/rules_mapper';
import { getFileEditorOrDefault } from '../stores/modules/editor/utils';
......@@ -46,6 +54,9 @@ export default {
content: '',
images: {},
rules: {},
globalEditor: null,
modelManager: new ModelManager(),
isEditorLoading: true,
};
},
computed: {
......@@ -132,6 +143,7 @@ export default {
// Compare key to allow for files opened in review mode to be cached differently
if (oldVal.key !== this.file.key) {
this.isEditorLoading = true;
this.initEditor();
if (this.currentActivityView !== leftSidebarViews.edit.name) {
......@@ -149,6 +161,7 @@ export default {
}
},
viewer() {
this.isEditorLoading = false;
if (!this.file.pending) {
this.createEditorInstance();
}
......@@ -181,11 +194,11 @@ export default {
},
},
beforeDestroy() {
this.editor.dispose();
this.globalEditor.dispose();
},
mounted() {
if (!this.editor) {
this.editor = Editor.create(this.$store, this.editorOptions);
if (!this.globalEditor) {
this.globalEditor = new EditorLite();
}
this.initEditor();
......@@ -211,8 +224,6 @@ export default {
return;
}
this.editor.clearEditor();
this.registerSchemaForFile();
Promise.all([this.fetchFileData(), this.fetchEditorconfigRules()])
......@@ -251,20 +262,45 @@ export default {
return;
}
this.editor.dispose();
const isDiff = this.viewer !== viewerTypes.edit;
const shouldDisposeEditor = isDiff !== (this.editor?.getEditorType() === EDITOR_TYPE_DIFF);
this.$nextTick(() => {
if (this.viewer === viewerTypes.edit) {
this.editor.createInstance(this.$refs.editor);
if (this.editor && !shouldDisposeEditor) {
this.setupEditor();
} else {
this.editor.createDiffInstance(this.$refs.editor);
if (this.editor && shouldDisposeEditor) {
this.editor.dispose();
}
const instanceOptions = isDiff ? defaultDiffEditorOptions : defaultEditorOptions;
const method = isDiff ? EDITOR_DIFF_INSTANCE_FN : EDITOR_CODE_INSTANCE_FN;
this.editor = this.globalEditor[method]({
el: this.$refs.editor,
blobPath: this.file.path,
blobGlobalId: this.file.key,
blobContent: this.content || this.file.content,
...instanceOptions,
...this.editorOptions,
});
this.editor.use(
new EditorWebIdeExtension({
instance: this.editor,
modelManager: this.modelManager,
store: this.$store,
file: this.file,
options: this.editorOptions,
}),
);
this.$nextTick(() => {
this.setupEditor();
});
}
},
setupEditor() {
if (!this.file || !this.editor.instance || this.file.loading) return;
if (!this.file || !this.editor || this.file.loading) return;
const head = this.getStagedFile(this.file.path);
......@@ -279,6 +315,8 @@ export default {
this.editor.attachModel(this.model);
}
this.isEditorLoading = false;
this.model.updateOptions(this.rules);
this.model.onChange((model) => {
......@@ -298,7 +336,7 @@ export default {
});
});
this.editor.setPosition({
this.editor.setPos({
lineNumber: this.fileEditor.editorRow,
column: this.fileEditor.editorColumn,
});
......@@ -308,6 +346,10 @@ export default {
fileLanguage: this.model.language,
});
this.$nextTick(() => {
this.editor.updateDimensions();
});
this.$emit('editorSetup');
if (performance.getEntriesByName(WEBIDE_MARK_FILE_CLICKED).length) {
eventHub.$emit(WEBIDE_MEASURE_FILE_AFTER_INTERACTION);
......@@ -344,7 +386,7 @@ export default {
});
},
onPaste(event) {
const editor = this.editor.instance;
const { editor } = this;
const reImage = /^image\/(png|jpg|jpeg|gif)$/;
const file = event.clipboardData.files[0];
......@@ -395,6 +437,7 @@ export default {
<a
href="javascript:void(0);"
role="button"
data-testid="edit-tab"
@click.prevent="updateEditor({ viewMode: $options.FILE_VIEW_MODE_EDITOR })"
>
{{ __('Edit') }}
......@@ -404,6 +447,7 @@ export default {
<a
href="javascript:void(0);"
role="button"
data-testid="preview-tab"
@click.prevent="updateEditor({ viewMode: $options.FILE_VIEW_MODE_PREVIEW })"
>{{ previewMode.previewTitle }}</a
>
......@@ -414,6 +458,7 @@ export default {
<div
v-show="showEditor"
ref="editor"
:key="`content-editor`"
:class="{
'is-readonly': isCommitModeActive,
'is-deleted': file.deleted,
......@@ -421,6 +466,8 @@ export default {
}"
class="multi-file-editor-holder"
data-qa-selector="editor_container"
data-testid="editor-container"
:data-editor-loading="isEditorLoading"
@focusout="triggerFilesChange"
></div>
<content-viewer
......
......@@ -3,6 +3,11 @@
@include gl-display-flex;
@include gl-justify-content-center;
@include gl-align-items-center;
@include gl-z-index-0;
> * {
filter: blur(5px);
}
&::before {
content: '';
......
---
title: Introduce WebIDE as an extension for Editor Lite
merge_request: 51527
author:
type: changed
import { shallowMount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import { Range } from 'monaco-editor';
import { editor as monacoEditor, Range } from 'monaco-editor';
import Vue from 'vue';
import Vuex from 'vuex';
import '~/behaviors/markdown/render_gfm';
import { createComponentWithStore } from 'helpers/vue_mount_component_helper';
import waitForPromises from 'helpers/wait_for_promises';
import waitUsingRealTimer from 'helpers/wait_using_real_timer';
import { exampleConfigs, exampleFiles } from 'jest/ide/lib/editorconfig/mock_data';
import { EDITOR_CODE_INSTANCE_FN, EDITOR_DIFF_INSTANCE_FN } from '~/editor/constants';
import EditorLite from '~/editor/editor_lite';
import { EditorWebIdeExtension } from '~/editor/extensions/editor_lite_webide_ext';
import RepoEditor from '~/ide/components/repo_editor.vue';
import {
leftSidebarViews,
......@@ -13,59 +17,50 @@ import {
FILE_VIEW_MODE_PREVIEW,
viewerTypes,
} from '~/ide/constants';
import Editor from '~/ide/lib/editor';
import ModelManager from '~/ide/lib/common/model_manager';
import service from '~/ide/services';
import { createStoreOptions } from '~/ide/stores';
import axios from '~/lib/utils/axios_utils';
import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue';
import { file } from '../helpers';
import { exampleConfigs, exampleFiles } from '../lib/editorconfig/mock_data';
describe('RepoEditor', () => {
let vm;
let store;
const waitForEditorSetup = () =>
new Promise((resolve) => {
vm.$once('editorSetup', resolve);
});
const createComponent = () => {
if (vm) {
throw new Error('vm already exists');
}
vm = createComponentWithStore(Vue.extend(RepoEditor), store, {
file: store.state.openFiles[0],
});
jest.spyOn(vm, 'getFileData').mockResolvedValue();
jest.spyOn(vm, 'getRawFileData').mockResolvedValue();
vm.$mount();
};
const createOpenFile = (path) => {
const origFile = store.state.openFiles[0];
const newFile = { ...origFile, path, key: path, name: 'myfile.txt', content: 'hello world' };
store.state.entries[path] = newFile;
store.state.openFiles = [newFile];
};
beforeEach(() => {
const f = {
const defaultFileProps = {
...file('file.txt'),
content: 'hello world',
active: true,
tempFile: true,
};
const createActiveFile = (props) => {
return {
...defaultFileProps,
...props,
};
};
const storeOptions = createStoreOptions();
store = new Vuex.Store(storeOptions);
f.active = true;
f.tempFile = true;
store.state.openFiles.push(f);
store.state.projects = {
const dummyFile = {
markdown: (() =>
createActiveFile({
projectId: 'namespace/project',
path: 'sample.md',
name: 'sample.md',
}))(),
binary: (() =>
createActiveFile({
name: 'file.dat',
content: '🐱', // non-ascii binary content,
}))(),
empty: (() =>
createActiveFile({
tempFile: false,
content: '',
raw: '',
}))(),
};
const prepareStore = (state, activeFile) => {
const localState = {
openFiles: [activeFile],
projects: {
'gitlab-org/gitlab': {
branches: {
master: {
......@@ -76,267 +71,245 @@ describe('RepoEditor', () => {
},
},
},
},
currentProjectId: 'gitlab-org/gitlab',
currentBranchId: 'master',
entries: {
[activeFile.path]: activeFile,
},
};
store.state.currentProjectId = 'gitlab-org/gitlab';
store.state.currentBranchId = 'master';
Vue.set(store.state.entries, f.path, f);
const storeOptions = createStoreOptions();
return new Vuex.Store({
...createStoreOptions(),
state: {
...storeOptions.state,
...localState,
...state,
},
});
};
afterEach(() => {
vm.$destroy();
vm = null;
describe('RepoEditor', () => {
let wrapper;
let vm;
let createInstanceSpy;
let createDiffInstanceSpy;
let createModelSpy;
Editor.editorInstance.dispose();
const waitForEditorSetup = () =>
new Promise((resolve) => {
vm.$once('editorSetup', resolve);
});
const findEditor = () => vm.$el.querySelector('.multi-file-editor-holder');
const changeViewMode = (viewMode) =>
store.dispatch('editor/updateFileEditor', { path: vm.file.path, data: { viewMode } });
describe('default', () => {
beforeEach(() => {
createComponent();
return waitForEditorSetup();
const createComponent = async ({ state = {}, activeFile = defaultFileProps } = {}) => {
const store = prepareStore(state, activeFile);
wrapper = shallowMount(RepoEditor, {
store,
propsData: {
file: store.state.openFiles[0],
},
mocks: {
ContentViewer,
},
});
await waitForPromises();
vm = wrapper.vm;
jest.spyOn(vm, 'getFileData').mockResolvedValue();
jest.spyOn(vm, 'getRawFileData').mockResolvedValue();
};
it('sets renderWhitespace to `all`', () => {
vm.$store.state.renderWhitespaceInCode = true;
const findEditor = () => wrapper.find('[data-testid="editor-container"]');
const findTabs = () => wrapper.findAll('.ide-mode-tabs .nav-links li');
const findPreviewTab = () => wrapper.find('[data-testid="preview-tab"]');
expect(vm.editorOptions.renderWhitespace).toEqual('all');
beforeEach(() => {
createInstanceSpy = jest.spyOn(EditorLite.prototype, EDITOR_CODE_INSTANCE_FN);
createDiffInstanceSpy = jest.spyOn(EditorLite.prototype, EDITOR_DIFF_INSTANCE_FN);
createModelSpy = jest.spyOn(monacoEditor, 'createModel');
jest.spyOn(service, 'getFileData').mockResolvedValue();
jest.spyOn(service, 'getRawFileData').mockResolvedValue();
});
it('sets renderWhitespace to `none`', () => {
vm.$store.state.renderWhitespaceInCode = false;
expect(vm.editorOptions.renderWhitespace).toEqual('none');
afterEach(() => {
jest.clearAllMocks();
// create a new model each time, otherwise tests conflict with each other
// because of same model being used in multiple tests
// eslint-disable-next-line no-undef
monaco.editor.getModels().forEach((model) => model.dispose());
wrapper.destroy();
wrapper = null;
});
it('renders an ide container', () => {
expect(vm.shouldHideEditor).toBeFalsy();
expect(vm.showEditor).toBe(true);
expect(findEditor()).not.toHaveCss({ display: 'none' });
describe('default', () => {
it.each`
boolVal | textVal
${true} | ${'all'}
${false} | ${'none'}
`('sets renderWhitespace to "$textVal"', async ({ boolVal, textVal } = {}) => {
await createComponent({
state: {
renderWhitespaceInCode: boolVal,
},
});
expect(vm.editorOptions.renderWhitespace).toEqual(textVal);
});
it('renders only an edit tab', (done) => {
Vue.nextTick(() => {
const tabs = vm.$el.querySelectorAll('.ide-mode-tabs .nav-links li');
it('renders an ide container', async () => {
await createComponent();
expect(findEditor().isVisible()).toBe(true);
});
expect(tabs.length).toBe(1);
expect(tabs[0].textContent.trim()).toBe('Edit');
it('renders only an edit tab', async () => {
await createComponent();
const tabs = findTabs();
done();
expect(tabs).toHaveLength(1);
expect(tabs.at(0).text()).toBe('Edit');
});
});
describe('when file is markdown', () => {
let mock;
let activeFile;
beforeEach(() => {
activeFile = dummyFile.markdown;
mock = new MockAdapter(axios);
mock.onPost(/(.*)\/preview_markdown/).reply(200, {
body: '<p>testing 123</p>',
body: `<p>${defaultFileProps.content}</p>`,
});
Vue.set(vm, 'file', {
...vm.file,
projectId: 'namespace/project',
path: 'sample.md',
name: 'sample.md',
content: 'testing 123',
});
vm.$store.state.entries[vm.file.path] = vm.file;
return vm.$nextTick();
});
afterEach(() => {
mock.restore();
});
it('renders an Edit and a Preview Tab', (done) => {
Vue.nextTick(() => {
const tabs = vm.$el.querySelectorAll('.ide-mode-tabs .nav-links li');
it('renders an Edit and a Preview Tab', async () => {
await createComponent({ activeFile });
const tabs = findTabs();
expect(tabs.length).toBe(2);
expect(tabs[0].textContent.trim()).toBe('Edit');
expect(tabs[1].textContent.trim()).toBe('Preview Markdown');
done();
});
});
it('renders markdown for tempFile', (done) => {
vm.file.tempFile = true;
vm.$nextTick()
.then(() => {
vm.$el.querySelectorAll('.ide-mode-tabs .nav-links a')[1].click();
})
.then(waitForPromises)
.then(() => {
expect(vm.$el.querySelector('.preview-container').innerHTML).toContain(
'<p>testing 123</p>',
);
})
.then(done)
.catch(done.fail);
expect(tabs).toHaveLength(2);
expect(tabs.at(0).text()).toBe('Edit');
expect(tabs.at(1).text()).toBe('Preview Markdown');
});
describe('when not in edit mode', () => {
beforeEach(async () => {
await vm.$nextTick();
vm.$store.state.currentActivityView = leftSidebarViews.review.name;
it('renders markdown for tempFile', async () => {
// by default files created in the spec are temp: no need for explicitly sending the param
await createComponent({ activeFile });
return vm.$nextTick();
findPreviewTab().trigger('click');
await waitForPromises();
expect(wrapper.find(ContentViewer).html()).toContain(defaultFileProps.content);
});
it('shows no tabs', () => {
expect(vm.$el.querySelectorAll('.ide-mode-tabs .nav-links a')).toHaveLength(0);
it('shows no tabs when not in Edit mode', async () => {
await createComponent({
state: {
currentActivityView: leftSidebarViews.review.name,
},
activeFile,
});
expect(findTabs()).toHaveLength(0);
});
});
describe('when open file is binary and not raw', () => {
beforeEach((done) => {
vm.file.name = 'file.dat';
vm.file.content = '🐱'; // non-ascii binary content
jest.spyOn(vm.editor, 'createInstance').mockImplementation();
jest.spyOn(vm.editor, 'createDiffInstance').mockImplementation();
vm.$nextTick(done);
describe('when file is binary and not raw', () => {
beforeEach(async () => {
const activeFile = dummyFile.binary;
await createComponent({ activeFile });
});
it('does not render the IDE', () => {
expect(vm.shouldHideEditor).toBeTruthy();
expect(findEditor().isVisible()).toBe(false);
});
it('does not call createInstance', async () => {
// Mirror the act's in the `createEditorInstance`
vm.createEditorInstance();
await vm.$nextTick();
expect(vm.editor.createInstance).not.toHaveBeenCalled();
expect(vm.editor.createDiffInstance).not.toHaveBeenCalled();
it('does not create an instance', () => {
expect(createInstanceSpy).not.toHaveBeenCalled();
expect(createDiffInstanceSpy).not.toHaveBeenCalled();
});
});
describe('createEditorInstance', () => {
it('calls createInstance when viewer is editor', (done) => {
jest.spyOn(vm.editor, 'createInstance').mockImplementation();
vm.createEditorInstance();
vm.$nextTick(() => {
expect(vm.editor.createInstance).toHaveBeenCalled();
done();
});
});
it('calls createDiffInstance when viewer is diff', (done) => {
vm.$store.state.viewer = 'diff';
jest.spyOn(vm.editor, 'createDiffInstance').mockImplementation();
vm.createEditorInstance();
vm.$nextTick(() => {
expect(vm.editor.createDiffInstance).toHaveBeenCalled();
done();
});
});
it('calls createDiffInstance when viewer is a merge request diff', (done) => {
vm.$store.state.viewer = 'mrdiff';
jest.spyOn(vm.editor, 'createDiffInstance').mockImplementation();
vm.createEditorInstance();
vm.$nextTick(() => {
expect(vm.editor.createDiffInstance).toHaveBeenCalled();
it.each`
viewer | diffInstance
${viewerTypes.edit} | ${undefined}
${viewerTypes.diff} | ${true}
${viewerTypes.mr} | ${true}
`(
'creates instance of correct type when viewer is $viewer',
async ({ viewer, diffInstance }) => {
await createComponent({
state: { viewer },
});
const isDiff = () => {
return diffInstance ? { isDiff: true } : {};
};
expect(createInstanceSpy).toHaveBeenCalledWith(expect.objectContaining(isDiff()));
expect(createDiffInstanceSpy).toHaveBeenCalledTimes((diffInstance && 1) || 0);
},
);
done();
it('installs the WebIDE extension', async () => {
const extensionSpy = jest.spyOn(EditorLite, 'instanceApplyExtension');
await createComponent();
expect(extensionSpy).toHaveBeenCalled();
Reflect.ownKeys(EditorWebIdeExtension.prototype)
.filter((fn) => fn !== 'constructor')
.forEach((fn) => {
expect(vm.editor[fn]).toBe(EditorWebIdeExtension.prototype[fn]);
});
});
});
describe('setupEditor', () => {
it('creates new model', () => {
jest.spyOn(vm.editor, 'createModel');
Editor.editorInstance.modelManager.dispose();
vm.setupEditor();
expect(vm.editor.createModel).toHaveBeenCalledWith(vm.file, null);
expect(vm.model).not.toBeNull();
});
it('attaches model to editor', () => {
jest.spyOn(vm.editor, 'attachModel');
Editor.editorInstance.modelManager.dispose();
vm.setupEditor();
expect(vm.editor.attachModel).toHaveBeenCalledWith(vm.model);
beforeEach(async () => {
await createComponent();
});
it('attaches model to merge request editor', () => {
vm.$store.state.viewer = 'mrdiff';
vm.file.mrChange = true;
jest.spyOn(vm.editor, 'attachMergeRequestModel').mockImplementation();
Editor.editorInstance.modelManager.dispose();
vm.setupEditor();
expect(vm.editor.attachMergeRequestModel).toHaveBeenCalledWith(vm.model);
it('creates new model on load', () => {
// We always create two models per file to be able to build a diff of changes
expect(createModelSpy).toHaveBeenCalledTimes(2);
// The model with the most recent changes is the last one
const [content] = createModelSpy.mock.calls[1];
expect(content).toBe(defaultFileProps.content);
});
it('does not attach model to merge request editor when not a MR change', () => {
vm.$store.state.viewer = 'mrdiff';
vm.file.mrChange = false;
jest.spyOn(vm.editor, 'attachMergeRequestModel').mockImplementation();
Editor.editorInstance.modelManager.dispose();
it('does not create a new model on subsequent calls to setupEditor and re-uses the already-existing model', () => {
const existingModel = vm.model;
createModelSpy.mockClear();
vm.setupEditor();
expect(vm.editor.attachMergeRequestModel).not.toHaveBeenCalledWith(vm.model);
expect(createModelSpy).not.toHaveBeenCalled();
expect(vm.model).toBe(existingModel);
});
it('adds callback methods', () => {
jest.spyOn(vm.editor, 'onPositionChange');
Editor.editorInstance.modelManager.dispose();
jest.spyOn(vm.model, 'onChange');
jest.spyOn(vm.model, 'updateOptions');
vm.setupEditor();
expect(vm.editor.onPositionChange).toHaveBeenCalled();
expect(vm.model.events.size).toBe(2);
expect(vm.editor.onPositionChange).toHaveBeenCalledTimes(1);
expect(vm.model.onChange).toHaveBeenCalledTimes(1);
expect(vm.model.updateOptions).toHaveBeenCalledWith(vm.rules);
});
it('updates state with the value of the model', () => {
vm.model.setValue('testing 1234\n');
const newContent = 'As Gregor Samsa\n awoke one morning\n';
vm.model.setValue(newContent);
vm.setupEditor();
expect(vm.file.content).toBe('testing 1234\n');
expect(vm.file.content).toBe(newContent);
});
it('sets head model as staged file', () => {
jest.spyOn(vm.editor, 'createModel');
Editor.editorInstance.modelManager.dispose();
vm.modelManager.dispose();
const addModelSpy = jest.spyOn(ModelManager.prototype, 'addModel');
vm.$store.state.stagedFiles.push({ ...vm.file, key: 'staged' });
vm.file.staged = true;
......@@ -344,235 +317,233 @@ describe('RepoEditor', () => {
vm.setupEditor();
expect(vm.editor.createModel).toHaveBeenCalledWith(vm.file, vm.$store.state.stagedFiles[0]);
expect(addModelSpy).toHaveBeenCalledWith(vm.file, vm.$store.state.stagedFiles[0]);
});
});
describe('editor updateDimensions', () => {
beforeEach(() => {
jest.spyOn(vm.editor, 'updateDimensions');
jest.spyOn(vm.editor, 'updateDiffView').mockImplementation();
let updateDimensionsSpy;
let updateDiffViewSpy;
beforeEach(async () => {
await createComponent();
updateDimensionsSpy = jest.spyOn(vm.editor, 'updateDimensions');
updateDiffViewSpy = jest.spyOn(vm.editor, 'updateDiffView').mockImplementation();
});
it('calls updateDimensions when panelResizing is false', (done) => {
it('calls updateDimensions only when panelResizing is false', async () => {
expect(updateDimensionsSpy).not.toHaveBeenCalled();
expect(updateDiffViewSpy).not.toHaveBeenCalled();
expect(vm.$store.state.panelResizing).toBe(false); // default value
vm.$store.state.panelResizing = true;
await vm.$nextTick();
expect(updateDimensionsSpy).not.toHaveBeenCalled();
expect(updateDiffViewSpy).not.toHaveBeenCalled();
vm.$nextTick()
.then(() => {
vm.$store.state.panelResizing = false;
})
.then(vm.$nextTick)
.then(() => {
expect(vm.editor.updateDimensions).toHaveBeenCalled();
expect(vm.editor.updateDiffView).toHaveBeenCalled();
})
.then(done)
.catch(done.fail);
});
await vm.$nextTick();
it('does not call updateDimensions when panelResizing is true', (done) => {
vm.$store.state.panelResizing = true;
expect(updateDimensionsSpy).toHaveBeenCalledTimes(1);
expect(updateDiffViewSpy).toHaveBeenCalledTimes(1);
vm.$nextTick(() => {
expect(vm.editor.updateDimensions).not.toHaveBeenCalled();
expect(vm.editor.updateDiffView).not.toHaveBeenCalled();
vm.$store.state.panelResizing = true;
await vm.$nextTick();
done();
});
expect(updateDimensionsSpy).toHaveBeenCalledTimes(1);
expect(updateDiffViewSpy).toHaveBeenCalledTimes(1);
});
it('calls updateDimensions when rightPane is opened', (done) => {
it('calls updateDimensions when rightPane is toggled', async () => {
expect(updateDimensionsSpy).not.toHaveBeenCalled();
expect(updateDiffViewSpy).not.toHaveBeenCalled();
expect(vm.$store.state.rightPane.isOpen).toBe(false); // default value
vm.$store.state.rightPane.isOpen = true;
await vm.$nextTick();
vm.$nextTick(() => {
expect(vm.editor.updateDimensions).toHaveBeenCalled();
expect(vm.editor.updateDiffView).toHaveBeenCalled();
expect(updateDimensionsSpy).toHaveBeenCalledTimes(1);
expect(updateDiffViewSpy).toHaveBeenCalledTimes(1);
done();
});
vm.$store.state.rightPane.isOpen = false;
await vm.$nextTick();
expect(updateDimensionsSpy).toHaveBeenCalledTimes(2);
expect(updateDiffViewSpy).toHaveBeenCalledTimes(2);
});
});
describe('show tabs', () => {
it('shows tabs in edit mode', () => {
expect(vm.$el.querySelector('.nav-links')).not.toBe(null);
describe('editor tabs', () => {
beforeEach(async () => {
await createComponent();
});
it('hides tabs in review mode', (done) => {
vm.$store.state.currentActivityView = leftSidebarViews.review.name;
vm.$nextTick(() => {
expect(vm.$el.querySelector('.nav-links')).toBe(null);
it.each`
mode | isVisible
${'edit'} | ${true}
${'review'} | ${false}
${'commit'} | ${false}
`('tabs in $mode are $isVisible', async ({ mode, isVisible } = {}) => {
vm.$store.state.currentActivityView = leftSidebarViews[mode].name;
done();
await vm.$nextTick();
expect(wrapper.find('.nav-links').exists()).toBe(isVisible);
});
});
it('hides tabs in commit mode', (done) => {
vm.$store.state.currentActivityView = leftSidebarViews.commit.name;
vm.$nextTick(() => {
expect(vm.$el.querySelector('.nav-links')).toBe(null);
done();
});
describe('files in preview mode', () => {
let updateDimensionsSpy;
const changeViewMode = (viewMode) =>
vm.$store.dispatch('editor/updateFileEditor', {
path: vm.file.path,
data: { viewMode },
});
beforeEach(async () => {
await createComponent({
activeFile: dummyFile.markdown,
});
describe('when files view mode is preview', () => {
beforeEach((done) => {
jest.spyOn(vm.editor, 'updateDimensions').mockImplementation();
changeViewMode(FILE_VIEW_MODE_PREVIEW);
vm.file.name = 'myfile.md';
vm.file.content = 'hello world';
updateDimensionsSpy = jest.spyOn(vm.editor, 'updateDimensions');
vm.$nextTick(done);
changeViewMode(FILE_VIEW_MODE_PREVIEW);
await vm.$nextTick();
});
it('should hide editor', () => {
it('do not show the editor', () => {
expect(vm.showEditor).toBe(false);
expect(findEditor()).toHaveCss({ display: 'none' });
expect(findEditor().isVisible()).toBe(false);
});
describe('when file view mode changes to editor', () => {
it('should update dimensions', () => {
it('updates dimensions when switching view back to edit', async () => {
expect(updateDimensionsSpy).not.toHaveBeenCalled();
changeViewMode(FILE_VIEW_MODE_EDITOR);
await vm.$nextTick();
return vm.$nextTick().then(() => {
expect(vm.editor.updateDimensions).toHaveBeenCalled();
});
});
expect(updateDimensionsSpy).toHaveBeenCalled();
});
});
describe('initEditor', () => {
beforeEach(() => {
vm.file.tempFile = false;
jest.spyOn(vm.editor, 'createInstance').mockImplementation();
const hideEditorAndRunFn = async () => {
jest.clearAllMocks();
jest.spyOn(vm, 'shouldHideEditor', 'get').mockReturnValue(true);
});
it('does not fetch file information for temp entries', (done) => {
vm.file.tempFile = true;
vm.initEditor();
vm.$nextTick()
.then(() => {
await vm.$nextTick();
};
it('does not fetch file information for temp entries', async () => {
await createComponent({
activeFile: createActiveFile(),
});
expect(vm.getFileData).not.toHaveBeenCalled();
})
.then(done)
.catch(done.fail);
});
it('is being initialised for files without content even if shouldHideEditor is `true`', (done) => {
vm.file.content = '';
vm.file.raw = '';
it('is being initialised for files without content even if shouldHideEditor is `true`', async () => {
await createComponent({
activeFile: dummyFile.empty,
});
vm.initEditor();
await hideEditorAndRunFn();
vm.$nextTick()
.then(() => {
expect(vm.getFileData).toHaveBeenCalled();
expect(vm.getRawFileData).toHaveBeenCalled();
})
.then(done)
.catch(done.fail);
});
it('does not initialize editor for files already with content', (done) => {
vm.file.content = 'foo';
it('does not initialize editor for files already with content when shouldHideEditor is `true`', async () => {
await createComponent({
activeFile: createActiveFile(),
});
await hideEditorAndRunFn();
vm.initEditor();
vm.$nextTick()
.then(() => {
expect(vm.getFileData).not.toHaveBeenCalled();
expect(vm.getRawFileData).not.toHaveBeenCalled();
expect(vm.editor.createInstance).not.toHaveBeenCalled();
})
.then(done)
.catch(done.fail);
expect(createInstanceSpy).not.toHaveBeenCalled();
});
});
describe('updates on file changes', () => {
beforeEach(() => {
beforeEach(async () => {
await createComponent({
activeFile: createActiveFile({
content: 'foo', // need to prevent full cycle of initEditor
}),
});
jest.spyOn(vm, 'initEditor').mockImplementation();
});
it('calls removePendingTab when old file is pending', (done) => {
it('calls removePendingTab when old file is pending', async () => {
jest.spyOn(vm, 'shouldHideEditor', 'get').mockReturnValue(true);
jest.spyOn(vm, 'removePendingTab').mockImplementation();
const origFile = vm.file;
vm.file.pending = true;
await vm.$nextTick();
vm.$nextTick()
.then(() => {
vm.file = file('testing');
wrapper.setProps({
file: file('testing'),
});
vm.file.content = 'foo'; // need to prevent full cycle of initEditor
await vm.$nextTick();
return vm.$nextTick();
})
.then(() => {
expect(vm.removePendingTab).toHaveBeenCalled();
})
.then(done)
.catch(done.fail);
expect(vm.removePendingTab).toHaveBeenCalledWith(origFile);
});
it('does not call initEditor if the file did not change', (done) => {
it('does not call initEditor if the file did not change', async () => {
Vue.set(vm, 'file', vm.file);
await vm.$nextTick();
vm.$nextTick()
.then(() => {
expect(vm.initEditor).not.toHaveBeenCalled();
})
.then(done)
.catch(done.fail);
});
it('calls initEditor when file key is changed', (done) => {
it('calls initEditor when file key is changed', async () => {
expect(vm.initEditor).not.toHaveBeenCalled();
Vue.set(vm, 'file', {
wrapper.setProps({
file: {
...vm.file,
key: 'new',
},
});
await vm.$nextTick();
vm.$nextTick()
.then(() => {
expect(vm.initEditor).toHaveBeenCalled();
})
.then(done)
.catch(done.fail);
});
});
describe('populates editor with the fetched content', () => {
beforeEach(() => {
vm.getRawFileData.mockRestore();
});
const createRemoteFile = (name) => ({
...file(name),
tmpFile: false,
});
beforeEach(async () => {
await createComponent();
vm.getRawFileData.mockRestore();
});
it('after switching viewer from edit to diff', async () => {
const f = createRemoteFile('newFile');
Vue.set(vm.$store.state.entries, f.path, f);
jest.spyOn(service, 'getRawFileData').mockImplementation(async () => {
expect(vm.file.loading).toBe(true);
// switching from edit to diff mode usually triggers editor initialization
store.state.viewer = viewerTypes.diff;
vm.$store.state.viewer = viewerTypes.diff;
// we delay returning the file to make sure editor doesn't initialize before we fetch file content
await waitUsingRealTimer(30);
return 'rawFileData123\n';
});
const f = createRemoteFile('newFile');
Vue.set(store.state.entries, f.path, f);
vm.file = f;
wrapper.setProps({
file: f,
});
await waitForEditorSetup();
expect(vm.model.getModel().getValue()).toBe('rawFileData123\n');
......@@ -580,43 +551,45 @@ describe('RepoEditor', () => {
it('after opening multiple files at the same time', async () => {
const fileA = createRemoteFile('fileA');
const aContent = 'fileA-rawContent\n';
const bContent = 'fileB-rawContent\n';
const fileB = createRemoteFile('fileB');
Vue.set(store.state.entries, fileA.path, fileA);
Vue.set(store.state.entries, fileB.path, fileB);
Vue.set(vm.$store.state.entries, fileA.path, fileA);
Vue.set(vm.$store.state.entries, fileB.path, fileB);
jest
.spyOn(service, 'getRawFileData')
.mockImplementationOnce(async () => {
.mockImplementation(async () => {
// opening fileB while the content of fileA is still being fetched
vm.file = fileB;
return 'fileA-rawContent\n';
wrapper.setProps({
file: fileB,
});
return aContent;
})
.mockImplementationOnce(async () => {
// we delay returning fileB content to make sure the editor doesn't initialize prematurely
await waitUsingRealTimer(30);
return 'fileB-rawContent\n';
return bContent;
});
vm.file = fileA;
wrapper.setProps({
file: fileA,
});
await waitForEditorSetup();
expect(vm.model.getModel().getValue()).toBe('fileB-rawContent\n');
expect(vm.model.getModel().getValue()).toBe(bContent);
});
});
describe('onPaste', () => {
const setFileName = (name) => {
Vue.set(vm, 'file', {
...vm.file,
const setFileName = (name) =>
createActiveFile({
content: 'hello world\n',
name,
path: `foo/${name}`,
key: 'new',
});
vm.$store.state.entries[vm.file.path] = vm.file;
};
const pasteImage = () => {
window.dispatchEvent(
Object.assign(new Event('paste'), {
......@@ -640,31 +613,32 @@ describe('RepoEditor', () => {
// Read more about state.watch: https://vuex.vuejs.org/api/#watch
const waitForFileContentChange = () => watchState((s) => s.entries['foo/bar.md'].content);
beforeEach(() => {
setFileName('bar.md');
vm.$store.state.trees['gitlab-org/gitlab'] = { tree: [] };
vm.$store.state.currentProjectId = 'gitlab-org';
vm.$store.state.currentBranchId = 'gitlab';
beforeEach(async () => {
await createComponent({
state: {
trees: {
'gitlab-org/gitlab': { tree: [] },
},
currentProjectId: 'gitlab-org',
currentBranchId: 'gitlab',
},
activeFile: setFileName('bar.md'),
});
// create a new model each time, otherwise tests conflict with each other
// because of same model being used in multiple tests
Editor.editorInstance.modelManager.dispose();
vm.setupEditor();
return waitForPromises().then(() => {
await waitForPromises();
// set cursor to line 2, column 1
vm.editor.instance.setSelection(new Range(2, 1, 2, 1));
vm.editor.instance.focus();
vm.editor.setSelection(new Range(2, 1, 2, 1));
vm.editor.focus();
jest.spyOn(vm.editor.instance, 'hasTextFocus').mockReturnValue(true);
});
jest.spyOn(vm.editor, 'hasTextFocus').mockReturnValue(true);
});
it('adds an image entry to the same folder for a pasted image in a markdown file', () => {
it('adds an image entry to the same folder for a pasted image in a markdown file', async () => {
pasteImage();
return waitForFileContentChange().then(() => {
await waitForFileContentChange();
expect(vm.$store.state.entries['foo/foo.png']).toMatchObject({
path: 'foo/foo.png',
type: 'blob',
......@@ -672,74 +646,94 @@ describe('RepoEditor', () => {
rawPath: 'data:image/png;base64,Zm9v',
});
});
});
it("adds a markdown image tag to the file's contents", () => {
it("adds a markdown image tag to the file's contents", async () => {
pasteImage();
return waitForFileContentChange().then(() => {
await waitForFileContentChange();
expect(vm.file.content).toBe('hello world\n![foo.png](./foo.png)');
});
});
it("does not add file to state or set markdown image syntax if the file isn't markdown", () => {
setFileName('myfile.txt');
it("does not add file to state or set markdown image syntax if the file isn't markdown", async () => {
wrapper.setProps({
file: setFileName('myfile.txt'),
});
pasteImage();
return waitForPromises().then(() => {
await waitForPromises();
expect(vm.$store.state.entries['foo/foo.png']).toBeUndefined();
expect(vm.file.content).toBe('hello world\n');
});
});
});
});
describe('fetchEditorconfigRules', () => {
beforeEach(() => {
exampleConfigs.forEach(({ path, content }) => {
store.state.entries[path] = { ...file(), path, content };
});
});
it.each(exampleFiles)(
'does not fetch content from remote for .editorconfig files present locally (case %#)',
({ path, monacoRules }) => {
createOpenFile(path);
createComponent();
async ({ path, monacoRules }) => {
await createComponent({
state: {
entries: (() => {
const res = {};
exampleConfigs.forEach(({ path: configPath, content }) => {
res[configPath] = { ...file(), path: configPath, content };
});
return res;
})(),
},
activeFile: createActiveFile({
path,
key: path,
name: 'myfile.txt',
content: 'hello world',
}),
});
return waitForEditorSetup().then(() => {
expect(vm.rules).toEqual(monacoRules);
expect(vm.model.options).toMatchObject(monacoRules);
expect(vm.getFileData).not.toHaveBeenCalled();
expect(vm.getRawFileData).not.toHaveBeenCalled();
});
},
);
it('fetches content from remote for .editorconfig files not available locally', () => {
exampleConfigs.forEach(({ path }) => {
delete store.state.entries[path].content;
delete store.state.entries[path].raw;
});
// Include a "test" directory which does not exist in store. This one should be skipped.
createOpenFile('foo/bar/baz/test/my_spec.js');
createComponent();
return waitForEditorSetup().then(() => {
expect(vm.getFileData.mock.calls.map(([args]) => args)).toEqual([
{ makeFileActive: false, path: 'foo/bar/baz/.editorconfig' },
{ makeFileActive: false, path: 'foo/bar/.editorconfig' },
{ makeFileActive: false, path: 'foo/.editorconfig' },
{ makeFileActive: false, path: '.editorconfig' },
]);
expect(vm.getRawFileData.mock.calls.map(([args]) => args)).toEqual([
{ path: 'foo/bar/baz/.editorconfig' },
{ path: 'foo/bar/.editorconfig' },
{ path: 'foo/.editorconfig' },
{ path: '.editorconfig' },
]);
it('fetches content from remote for .editorconfig files not available locally', async () => {
const activeFile = createActiveFile({
path: 'foo/bar/baz/test/my_spec.js',
key: 'foo/bar/baz/test/my_spec.js',
name: 'myfile.txt',
content: 'hello world',
});
const expectations = [
'foo/bar/baz/.editorconfig',
'foo/bar/.editorconfig',
'foo/.editorconfig',
'.editorconfig',
];
await createComponent({
state: {
entries: (() => {
const res = {
[activeFile.path]: activeFile,
};
exampleConfigs.forEach(({ path: configPath }) => {
const f = { ...file(), path: configPath };
delete f.content;
delete f.raw;
res[configPath] = f;
});
return res;
})(),
},
activeFile,
});
expect(service.getFileData.mock.calls.map(([args]) => args)).toEqual(
expectations.map((expectation) => expect.stringContaining(expectation)),
);
expect(service.getRawFileData.mock.calls.map(([args]) => args)).toEqual(
expectations.map((expectation) => expect.objectContaining({ path: expectation })),
);
});
});
});
......@@ -25,6 +25,9 @@ export const getStatusBar = () => document.querySelector('.ide-status-bar');
export const waitForMonacoEditor = () =>
new Promise((resolve) => window.monaco.editor.onDidCreateEditor(resolve));
export const waitForEditorModelChange = (instance) =>
new Promise((resolve) => instance.onDidChangeModel(resolve));
export const findMonacoEditor = () =>
screen.findAllByLabelText(/Editor content;/).then(([x]) => x.closest('.monaco-editor'));
......
/* global monaco */
import { TEST_HOST } from 'helpers/test_constants';
import { initIde } from '~/ide';
import Editor from '~/ide/lib/editor';
import extendStore from '~/ide/stores/extend';
import { IDE_DATASET } from './mock_data';
......@@ -18,13 +19,7 @@ export default (container, { isRepoEmpty = false, path = '', mrId = '' } = {}) =
const vm = initIde(el, { extendStore });
// We need to dispose of editor Singleton things or tests will bump into eachother
vm.$on('destroy', () => {
if (Editor.editorInstance) {
Editor.editorInstance.modelManager.dispose();
Editor.editorInstance.dispose();
Editor.editorInstance = null;
}
});
vm.$on('destroy', () => monaco.editor.getModels().forEach((model) => model.dispose()));
return vm;
};
......@@ -96,16 +96,6 @@ describe('WebIDE', () => {
let statusBar;
let editor;
const waitForEditor = async () => {
editor = await ideHelper.waitForMonacoEditor();
};
const changeEditorPosition = async (lineNumber, column) => {
editor.setPosition({ lineNumber, column });
await vm.$nextTick();
};
beforeEach(async () => {
vm = startWebIDE(container);
......@@ -134,16 +124,17 @@ describe('WebIDE', () => {
// Need to wait for monaco editor to load so it doesn't through errors on dispose
await ideHelper.openFile('.gitignore');
await ideHelper.waitForMonacoEditor();
await ideHelper.waitForEditorModelChange(editor);
await ideHelper.openFile('README.md');
await ideHelper.waitForMonacoEditor();
await ideHelper.waitForEditorModelChange(editor);
expect(el).toHaveText(markdownPreview);
});
describe('when editor position changes', () => {
beforeEach(async () => {
await changeEditorPosition(4, 10);
editor.setPosition({ lineNumber: 4, column: 10 });
await vm.$nextTick();
});
it('shows new line position', () => {
......@@ -153,7 +144,8 @@ describe('WebIDE', () => {
it('updates after rename', async () => {
await ideHelper.renameFile('README.md', 'READMEZ.txt');
await waitForEditor();
await ideHelper.waitForEditorModelChange(editor);
await vm.$nextTick();
expect(statusBar).toHaveText('1:1');
expect(statusBar).toHaveText('plaintext');
......@@ -161,10 +153,10 @@ describe('WebIDE', () => {
it('persists position after opening then rename', async () => {
await ideHelper.openFile('files/js/application.js');
await waitForEditor();
await ideHelper.waitForEditorModelChange(editor);
await ideHelper.renameFile('README.md', 'READING_RAINBOW.md');
await ideHelper.openFile('READING_RAINBOW.md');
await waitForEditor();
await ideHelper.waitForEditorModelChange(editor);
expect(statusBar).toHaveText('4:10');
expect(statusBar).toHaveText('markdown');
......@@ -173,7 +165,8 @@ describe('WebIDE', () => {
it('persists position after closing', async () => {
await ideHelper.closeFile('README.md');
await ideHelper.openFile('README.md');
await waitForEditor();
await ideHelper.waitForMonacoEditor();
await vm.$nextTick();
expect(statusBar).toHaveText('4:10');
expect(statusBar).toHaveText('markdown');
......
......@@ -24,11 +24,11 @@ describe('IDE: User opens Merge Request', () => {
vm = startWebIDE(container, { mrId });
await ideHelper.waitForTabToOpen(basename(changes[0].new_path));
await ideHelper.waitForMonacoEditor();
const editor = await ideHelper.waitForMonacoEditor();
await ideHelper.waitForEditorModelChange(editor);
});
afterEach(async () => {
afterEach(() => {
vm.$destroy();
vm = null;
});
......
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