Commit 4228bd99 authored by David O'Regan's avatar David O'Regan

Merge branch '292498/webid-extension' into 'master'

Introduce WebIDE as an extension for Editor Lite

See merge request gitlab-org/gitlab!51527
parents 03ba8c67 ceed1934
......@@ -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
......@@ -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