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'; ...@@ -16,6 +16,9 @@ export const EDITOR_READY_EVENT = 'editor-ready';
export const EDITOR_TYPE_CODE = 'vs.editor.ICodeEditor'; export const EDITOR_TYPE_CODE = 'vs.editor.ICodeEditor';
export const EDITOR_TYPE_DIFF = 'vs.editor.IDiffEditor'; export const EDITOR_TYPE_DIFF = 'vs.editor.IDiffEditor';
export const EDITOR_CODE_INSTANCE_FN = 'createInstance';
export const EDITOR_DIFF_INSTANCE_FN = 'createDiffInstance';
// //
// EXTENSIONS' CONSTANTS // 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 { ...@@ -49,7 +49,9 @@ export default {
</script> </script>
<template> <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> <strong class="gl-mr-3"> {{ __('File templates') }} </strong>
<dropdown <dropdown
:data="templateTypes" :data="templateTypes"
......
<script> <script>
import { mapState, mapGetters, mapActions } from 'vuex'; 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 { 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 { __ } from '~/locale';
import { import {
WEBIDE_MARK_FILE_CLICKED, WEBIDE_MARK_FILE_CLICKED,
...@@ -20,7 +29,6 @@ import { ...@@ -20,7 +29,6 @@ import {
FILE_VIEW_MODE_PREVIEW, FILE_VIEW_MODE_PREVIEW,
} from '../constants'; } from '../constants';
import eventHub from '../eventhub'; import eventHub from '../eventhub';
import Editor from '../lib/editor';
import { getRulesWithTraversal } from '../lib/editorconfig/parser'; import { getRulesWithTraversal } from '../lib/editorconfig/parser';
import mapRulesToMonaco from '../lib/editorconfig/rules_mapper'; import mapRulesToMonaco from '../lib/editorconfig/rules_mapper';
import { getFileEditorOrDefault } from '../stores/modules/editor/utils'; import { getFileEditorOrDefault } from '../stores/modules/editor/utils';
...@@ -46,6 +54,9 @@ export default { ...@@ -46,6 +54,9 @@ export default {
content: '', content: '',
images: {}, images: {},
rules: {}, rules: {},
globalEditor: null,
modelManager: new ModelManager(),
isEditorLoading: true,
}; };
}, },
computed: { computed: {
...@@ -132,6 +143,7 @@ export default { ...@@ -132,6 +143,7 @@ export default {
// Compare key to allow for files opened in review mode to be cached differently // Compare key to allow for files opened in review mode to be cached differently
if (oldVal.key !== this.file.key) { if (oldVal.key !== this.file.key) {
this.isEditorLoading = true;
this.initEditor(); this.initEditor();
if (this.currentActivityView !== leftSidebarViews.edit.name) { if (this.currentActivityView !== leftSidebarViews.edit.name) {
...@@ -149,6 +161,7 @@ export default { ...@@ -149,6 +161,7 @@ export default {
} }
}, },
viewer() { viewer() {
this.isEditorLoading = false;
if (!this.file.pending) { if (!this.file.pending) {
this.createEditorInstance(); this.createEditorInstance();
} }
...@@ -181,11 +194,11 @@ export default { ...@@ -181,11 +194,11 @@ export default {
}, },
}, },
beforeDestroy() { beforeDestroy() {
this.editor.dispose(); this.globalEditor.dispose();
}, },
mounted() { mounted() {
if (!this.editor) { if (!this.globalEditor) {
this.editor = Editor.create(this.$store, this.editorOptions); this.globalEditor = new EditorLite();
} }
this.initEditor(); this.initEditor();
...@@ -211,8 +224,6 @@ export default { ...@@ -211,8 +224,6 @@ export default {
return; return;
} }
this.editor.clearEditor();
this.registerSchemaForFile(); this.registerSchemaForFile();
Promise.all([this.fetchFileData(), this.fetchEditorconfigRules()]) Promise.all([this.fetchFileData(), this.fetchEditorconfigRules()])
...@@ -251,20 +262,45 @@ export default { ...@@ -251,20 +262,45 @@ export default {
return; return;
} }
this.editor.dispose(); const isDiff = this.viewer !== viewerTypes.edit;
const shouldDisposeEditor = isDiff !== (this.editor?.getEditorType() === EDITOR_TYPE_DIFF);
this.$nextTick(() => { if (this.editor && !shouldDisposeEditor) {
if (this.viewer === viewerTypes.edit) { this.setupEditor();
this.editor.createInstance(this.$refs.editor); } else {
} else { if (this.editor && shouldDisposeEditor) {
this.editor.createDiffInstance(this.$refs.editor); this.editor.dispose();
} }
const instanceOptions = isDiff ? defaultDiffEditorOptions : defaultEditorOptions;
const method = isDiff ? EDITOR_DIFF_INSTANCE_FN : EDITOR_CODE_INSTANCE_FN;
this.setupEditor(); 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() { 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); const head = this.getStagedFile(this.file.path);
...@@ -279,6 +315,8 @@ export default { ...@@ -279,6 +315,8 @@ export default {
this.editor.attachModel(this.model); this.editor.attachModel(this.model);
} }
this.isEditorLoading = false;
this.model.updateOptions(this.rules); this.model.updateOptions(this.rules);
this.model.onChange((model) => { this.model.onChange((model) => {
...@@ -298,7 +336,7 @@ export default { ...@@ -298,7 +336,7 @@ export default {
}); });
}); });
this.editor.setPosition({ this.editor.setPos({
lineNumber: this.fileEditor.editorRow, lineNumber: this.fileEditor.editorRow,
column: this.fileEditor.editorColumn, column: this.fileEditor.editorColumn,
}); });
...@@ -308,6 +346,10 @@ export default { ...@@ -308,6 +346,10 @@ export default {
fileLanguage: this.model.language, fileLanguage: this.model.language,
}); });
this.$nextTick(() => {
this.editor.updateDimensions();
});
this.$emit('editorSetup'); this.$emit('editorSetup');
if (performance.getEntriesByName(WEBIDE_MARK_FILE_CLICKED).length) { if (performance.getEntriesByName(WEBIDE_MARK_FILE_CLICKED).length) {
eventHub.$emit(WEBIDE_MEASURE_FILE_AFTER_INTERACTION); eventHub.$emit(WEBIDE_MEASURE_FILE_AFTER_INTERACTION);
...@@ -344,7 +386,7 @@ export default { ...@@ -344,7 +386,7 @@ export default {
}); });
}, },
onPaste(event) { onPaste(event) {
const editor = this.editor.instance; const { editor } = this;
const reImage = /^image\/(png|jpg|jpeg|gif)$/; const reImage = /^image\/(png|jpg|jpeg|gif)$/;
const file = event.clipboardData.files[0]; const file = event.clipboardData.files[0];
...@@ -395,6 +437,7 @@ export default { ...@@ -395,6 +437,7 @@ export default {
<a <a
href="javascript:void(0);" href="javascript:void(0);"
role="button" role="button"
data-testid="edit-tab"
@click.prevent="updateEditor({ viewMode: $options.FILE_VIEW_MODE_EDITOR })" @click.prevent="updateEditor({ viewMode: $options.FILE_VIEW_MODE_EDITOR })"
> >
{{ __('Edit') }} {{ __('Edit') }}
...@@ -404,6 +447,7 @@ export default { ...@@ -404,6 +447,7 @@ export default {
<a <a
href="javascript:void(0);" href="javascript:void(0);"
role="button" role="button"
data-testid="preview-tab"
@click.prevent="updateEditor({ viewMode: $options.FILE_VIEW_MODE_PREVIEW })" @click.prevent="updateEditor({ viewMode: $options.FILE_VIEW_MODE_PREVIEW })"
>{{ previewMode.previewTitle }}</a >{{ previewMode.previewTitle }}</a
> >
...@@ -414,6 +458,7 @@ export default { ...@@ -414,6 +458,7 @@ export default {
<div <div
v-show="showEditor" v-show="showEditor"
ref="editor" ref="editor"
:key="`content-editor`"
:class="{ :class="{
'is-readonly': isCommitModeActive, 'is-readonly': isCommitModeActive,
'is-deleted': file.deleted, 'is-deleted': file.deleted,
...@@ -421,6 +466,8 @@ export default { ...@@ -421,6 +466,8 @@ export default {
}" }"
class="multi-file-editor-holder" class="multi-file-editor-holder"
data-qa-selector="editor_container" data-qa-selector="editor_container"
data-testid="editor-container"
:data-editor-loading="isEditorLoading"
@focusout="triggerFilesChange" @focusout="triggerFilesChange"
></div> ></div>
<content-viewer <content-viewer
......
...@@ -3,6 +3,11 @@ ...@@ -3,6 +3,11 @@
@include gl-display-flex; @include gl-display-flex;
@include gl-justify-content-center; @include gl-justify-content-center;
@include gl-align-items-center; @include gl-align-items-center;
@include gl-z-index-0;
> * {
filter: blur(5px);
}
&::before { &::before {
content: ''; content: '';
......
---
title: Introduce WebIDE as an extension for Editor Lite
merge_request: 51527
author:
type: changed
...@@ -25,6 +25,9 @@ export const getStatusBar = () => document.querySelector('.ide-status-bar'); ...@@ -25,6 +25,9 @@ export const getStatusBar = () => document.querySelector('.ide-status-bar');
export const waitForMonacoEditor = () => export const waitForMonacoEditor = () =>
new Promise((resolve) => window.monaco.editor.onDidCreateEditor(resolve)); new Promise((resolve) => window.monaco.editor.onDidCreateEditor(resolve));
export const waitForEditorModelChange = (instance) =>
new Promise((resolve) => instance.onDidChangeModel(resolve));
export const findMonacoEditor = () => export const findMonacoEditor = () =>
screen.findAllByLabelText(/Editor content;/).then(([x]) => x.closest('.monaco-editor')); screen.findAllByLabelText(/Editor content;/).then(([x]) => x.closest('.monaco-editor'));
......
/* global monaco */
import { TEST_HOST } from 'helpers/test_constants'; import { TEST_HOST } from 'helpers/test_constants';
import { initIde } from '~/ide'; import { initIde } from '~/ide';
import Editor from '~/ide/lib/editor';
import extendStore from '~/ide/stores/extend'; import extendStore from '~/ide/stores/extend';
import { IDE_DATASET } from './mock_data'; import { IDE_DATASET } from './mock_data';
...@@ -18,13 +19,7 @@ export default (container, { isRepoEmpty = false, path = '', mrId = '' } = {}) = ...@@ -18,13 +19,7 @@ export default (container, { isRepoEmpty = false, path = '', mrId = '' } = {}) =
const vm = initIde(el, { extendStore }); const vm = initIde(el, { extendStore });
// We need to dispose of editor Singleton things or tests will bump into eachother // We need to dispose of editor Singleton things or tests will bump into eachother
vm.$on('destroy', () => { vm.$on('destroy', () => monaco.editor.getModels().forEach((model) => model.dispose()));
if (Editor.editorInstance) {
Editor.editorInstance.modelManager.dispose();
Editor.editorInstance.dispose();
Editor.editorInstance = null;
}
});
return vm; return vm;
}; };
...@@ -96,16 +96,6 @@ describe('WebIDE', () => { ...@@ -96,16 +96,6 @@ describe('WebIDE', () => {
let statusBar; let statusBar;
let editor; let editor;
const waitForEditor = async () => {
editor = await ideHelper.waitForMonacoEditor();
};
const changeEditorPosition = async (lineNumber, column) => {
editor.setPosition({ lineNumber, column });
await vm.$nextTick();
};
beforeEach(async () => { beforeEach(async () => {
vm = startWebIDE(container); vm = startWebIDE(container);
...@@ -134,16 +124,17 @@ describe('WebIDE', () => { ...@@ -134,16 +124,17 @@ describe('WebIDE', () => {
// Need to wait for monaco editor to load so it doesn't through errors on dispose // Need to wait for monaco editor to load so it doesn't through errors on dispose
await ideHelper.openFile('.gitignore'); await ideHelper.openFile('.gitignore');
await ideHelper.waitForMonacoEditor(); await ideHelper.waitForEditorModelChange(editor);
await ideHelper.openFile('README.md'); await ideHelper.openFile('README.md');
await ideHelper.waitForMonacoEditor(); await ideHelper.waitForEditorModelChange(editor);
expect(el).toHaveText(markdownPreview); expect(el).toHaveText(markdownPreview);
}); });
describe('when editor position changes', () => { describe('when editor position changes', () => {
beforeEach(async () => { beforeEach(async () => {
await changeEditorPosition(4, 10); editor.setPosition({ lineNumber: 4, column: 10 });
await vm.$nextTick();
}); });
it('shows new line position', () => { it('shows new line position', () => {
...@@ -153,7 +144,8 @@ describe('WebIDE', () => { ...@@ -153,7 +144,8 @@ describe('WebIDE', () => {
it('updates after rename', async () => { it('updates after rename', async () => {
await ideHelper.renameFile('README.md', 'READMEZ.txt'); await ideHelper.renameFile('README.md', 'READMEZ.txt');
await waitForEditor(); await ideHelper.waitForEditorModelChange(editor);
await vm.$nextTick();
expect(statusBar).toHaveText('1:1'); expect(statusBar).toHaveText('1:1');
expect(statusBar).toHaveText('plaintext'); expect(statusBar).toHaveText('plaintext');
...@@ -161,10 +153,10 @@ describe('WebIDE', () => { ...@@ -161,10 +153,10 @@ describe('WebIDE', () => {
it('persists position after opening then rename', async () => { it('persists position after opening then rename', async () => {
await ideHelper.openFile('files/js/application.js'); await ideHelper.openFile('files/js/application.js');
await waitForEditor(); await ideHelper.waitForEditorModelChange(editor);
await ideHelper.renameFile('README.md', 'READING_RAINBOW.md'); await ideHelper.renameFile('README.md', 'READING_RAINBOW.md');
await ideHelper.openFile('READING_RAINBOW.md'); await ideHelper.openFile('READING_RAINBOW.md');
await waitForEditor(); await ideHelper.waitForEditorModelChange(editor);
expect(statusBar).toHaveText('4:10'); expect(statusBar).toHaveText('4:10');
expect(statusBar).toHaveText('markdown'); expect(statusBar).toHaveText('markdown');
...@@ -173,7 +165,8 @@ describe('WebIDE', () => { ...@@ -173,7 +165,8 @@ describe('WebIDE', () => {
it('persists position after closing', async () => { it('persists position after closing', async () => {
await ideHelper.closeFile('README.md'); await ideHelper.closeFile('README.md');
await ideHelper.openFile('README.md'); await ideHelper.openFile('README.md');
await waitForEditor(); await ideHelper.waitForMonacoEditor();
await vm.$nextTick();
expect(statusBar).toHaveText('4:10'); expect(statusBar).toHaveText('4:10');
expect(statusBar).toHaveText('markdown'); expect(statusBar).toHaveText('markdown');
......
...@@ -24,11 +24,11 @@ describe('IDE: User opens Merge Request', () => { ...@@ -24,11 +24,11 @@ describe('IDE: User opens Merge Request', () => {
vm = startWebIDE(container, { mrId }); vm = startWebIDE(container, { mrId });
await ideHelper.waitForTabToOpen(basename(changes[0].new_path)); const editor = await ideHelper.waitForMonacoEditor();
await ideHelper.waitForMonacoEditor(); await ideHelper.waitForEditorModelChange(editor);
}); });
afterEach(async () => { afterEach(() => {
vm.$destroy(); vm.$destroy();
vm = null; 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