Commit 1dc2c7e2 authored by Savas Vedova's avatar Savas Vedova

Merge branch '280798-weide-live' into 'master'

Updating the Source Editor to behave in WebIDE

See merge request gitlab-org/gitlab!68274
parents f21bda6c 2e9203df
...@@ -33,3 +33,4 @@ export const EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS = 'md'; ...@@ -33,3 +33,4 @@ export const EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS = 'md';
export const EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS = 'source-editor-preview'; export const EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS = 'source-editor-preview';
export const EXTENSION_MARKDOWN_PREVIEW_ACTION_ID = 'markdown-preview'; export const EXTENSION_MARKDOWN_PREVIEW_ACTION_ID = 'markdown-preview';
export const EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH = 0.5; // 50% of the width export const EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH = 0.5; // 50% of the width
export const EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY = 250; // ms
...@@ -10,6 +10,7 @@ import { ...@@ -10,6 +10,7 @@ import {
EXTENSION_MARKDOWN_PREVIEW_ACTION_ID, EXTENSION_MARKDOWN_PREVIEW_ACTION_ID,
EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH, EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH,
EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS, EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS,
EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY,
} from '../constants'; } from '../constants';
import { SourceEditorExtension } from './source_editor_extension_base'; import { SourceEditorExtension } from './source_editor_extension_base';
...@@ -50,9 +51,29 @@ export class EditorMarkdownExtension extends SourceEditorExtension { ...@@ -50,9 +51,29 @@ export class EditorMarkdownExtension extends SourceEditorExtension {
el: undefined, el: undefined,
action: undefined, action: undefined,
shown: false, shown: false,
modelChangeListener: undefined,
}, },
}); });
this.setupPreviewAction.call(instance); this.setupPreviewAction.call(instance);
instance.getModel().onDidChangeLanguage(({ newLanguage, oldLanguage } = {}) => {
if (newLanguage === 'markdown' && oldLanguage !== newLanguage) {
instance.setupPreviewAction();
} else {
instance.cleanup();
}
});
instance.onDidChangeModel(() => {
const model = instance.getModel();
if (model) {
const { language } = model.getLanguageIdentifier();
instance.cleanup();
if (language === 'markdown') {
instance.setupPreviewAction();
}
}
});
} }
static togglePreviewLayout() { static togglePreviewLayout() {
...@@ -78,6 +99,9 @@ export class EditorMarkdownExtension extends SourceEditorExtension { ...@@ -78,6 +99,9 @@ export class EditorMarkdownExtension extends SourceEditorExtension {
} }
cleanup() { cleanup() {
if (this.preview.modelChangeListener) {
this.preview.modelChangeListener.dispose();
}
this.preview.action.dispose(); this.preview.action.dispose();
if (this.preview.shown) { if (this.preview.shown) {
EditorMarkdownExtension.togglePreviewPanel.call(this); EditorMarkdownExtension.togglePreviewPanel.call(this);
...@@ -126,22 +150,14 @@ export class EditorMarkdownExtension extends SourceEditorExtension { ...@@ -126,22 +150,14 @@ export class EditorMarkdownExtension extends SourceEditorExtension {
EditorMarkdownExtension.togglePreviewPanel.call(this); EditorMarkdownExtension.togglePreviewPanel.call(this);
if (!this.preview?.shown) { if (!this.preview?.shown) {
this.modelChangeListener = this.onDidChangeModelContent( this.preview.modelChangeListener = this.onDidChangeModelContent(
debounce(this.fetchPreview.bind(this), 250), debounce(this.fetchPreview.bind(this), EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY),
); );
} else { } else {
this.modelChangeListener.dispose(); this.preview.modelChangeListener.dispose();
} }
this.preview.shown = !this.preview?.shown; this.preview.shown = !this.preview?.shown;
this.getModel().onDidChangeLanguage(({ newLanguage, oldLanguage } = {}) => {
if (newLanguage === 'markdown' && oldLanguage !== newLanguage) {
this.setupPreviewAction();
} else {
this.cleanup();
}
});
} }
getSelectedText(selection = this.getSelection()) { getSelectedText(selection = this.getSelection()) {
......
...@@ -16,12 +16,18 @@ export const setupEditorTheme = () => { ...@@ -16,12 +16,18 @@ export const setupEditorTheme = () => {
monacoEditor.setTheme(theme ? themeName : DEFAULT_THEME); monacoEditor.setTheme(theme ? themeName : DEFAULT_THEME);
}; };
export const getBlobLanguage = (path) => { export const getBlobLanguage = (blobPath) => {
const ext = `.${path.split('.').pop()}`; const defaultLanguage = 'plaintext';
if (!blobPath) {
return defaultLanguage;
}
const ext = `.${blobPath.split('.').pop()}`;
const language = monacoLanguages const language = monacoLanguages
.getLanguages() .getLanguages()
.find((lang) => lang.extensions.indexOf(ext) !== -1); .find((lang) => lang.extensions.indexOf(ext) !== -1);
return language ? language.id : 'plaintext'; return language ? language.id : defaultLanguage;
}; };
export const setupCodeSnippet = (el) => { export const setupCodeSnippet = (el) => {
......
...@@ -38,6 +38,8 @@ import { getPathParent, readFileAsDataURL, registerSchema, isTextFile } from '.. ...@@ -38,6 +38,8 @@ import { getPathParent, readFileAsDataURL, registerSchema, isTextFile } from '..
import FileAlert from './file_alert.vue'; import FileAlert from './file_alert.vue';
import FileTemplatesBar from './file_templates/bar.vue'; import FileTemplatesBar from './file_templates/bar.vue';
const MARKDOWN_FILE_TYPE = 'markdown';
export default { export default {
name: 'RepoEditor', name: 'RepoEditor',
components: { components: {
...@@ -201,7 +203,7 @@ export default { ...@@ -201,7 +203,7 @@ export default {
showContentViewer(val) { showContentViewer(val) {
if (!val) return; if (!val) return;
if (this.fileType === 'markdown') { if (this.fileType === MARKDOWN_FILE_TYPE) {
const { content, images } = extractMarkdownImagesFromEntries(this.file, this.entries); const { content, images } = extractMarkdownImagesFromEntries(this.file, this.entries);
this.content = content; this.content = content;
this.images = images; this.images = images;
...@@ -309,6 +311,23 @@ export default { ...@@ -309,6 +311,23 @@ export default {
}), }),
); );
if (this.fileType === MARKDOWN_FILE_TYPE) {
import('~/editor/extensions/source_editor_markdown_ext')
.then(({ EditorMarkdownExtension: MarkdownExtension } = {}) => {
this.editor.use(
new MarkdownExtension({
instance: this.editor,
projectPath: this.currentProjectId,
}),
);
})
.catch((e) =>
createFlash({
message: e,
}),
);
}
this.$nextTick(() => { this.$nextTick(() => {
this.setupEditor(); this.setupEditor();
}); });
...@@ -406,7 +425,11 @@ export default { ...@@ -406,7 +425,11 @@ export default {
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];
if (editor.hasTextFocus() && this.fileType === 'markdown' && reImage.test(file?.type)) { if (
editor.hasTextFocus() &&
this.fileType === MARKDOWN_FILE_TYPE &&
reImage.test(file?.type)
) {
// don't let the event be passed on to Monaco. // don't let the event be passed on to Monaco.
event.preventDefault(); event.preventDefault();
event.stopImmediatePropagation(); event.stopImmediatePropagation();
......
...@@ -34,6 +34,10 @@ ...@@ -34,6 +34,10 @@
@include gl-py-4; @include gl-py-4;
@include gl-w-full; @include gl-w-full;
} }
.gl-source-editor {
@include gl-order-n1;
}
} }
.monaco-editor.gl-source-editor { .monaco-editor.gl-source-editor {
......
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import { Range, Position } from 'monaco-editor'; import { Range, Position, editor as monacoEditor } from 'monaco-editor';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import { import {
EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS, EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS,
EXTENSION_MARKDOWN_PREVIEW_ACTION_ID, EXTENSION_MARKDOWN_PREVIEW_ACTION_ID,
EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH, EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH,
EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS, EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS,
EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY,
} from '~/editor/constants'; } from '~/editor/constants';
import { EditorMarkdownExtension } from '~/editor/extensions/source_editor_markdown_ext'; import { EditorMarkdownExtension } from '~/editor/extensions/source_editor_markdown_ext';
import SourceEditor from '~/editor/source_editor'; import SourceEditor from '~/editor/source_editor';
...@@ -27,7 +28,8 @@ describe('Markdown Extension for Source Editor', () => { ...@@ -27,7 +28,8 @@ describe('Markdown Extension for Source Editor', () => {
const secondLine = 'multiline'; const secondLine = 'multiline';
const thirdLine = 'string with some **markup**'; const thirdLine = 'string with some **markup**';
const text = `${firstLine}\n${secondLine}\n${thirdLine}`; const text = `${firstLine}\n${secondLine}\n${thirdLine}`;
const filePath = 'foo.md'; const plaintextPath = 'foo.txt';
const markdownPath = 'foo.md';
const responseData = '<div>FooBar</div>'; const responseData = '<div>FooBar</div>';
const setSelection = (startLineNumber = 1, startColumn = 1, endLineNumber = 1, endColumn = 1) => { const setSelection = (startLineNumber = 1, startColumn = 1, endLineNumber = 1, endColumn = 1) => {
...@@ -52,7 +54,7 @@ describe('Markdown Extension for Source Editor', () => { ...@@ -52,7 +54,7 @@ describe('Markdown Extension for Source Editor', () => {
editor = new SourceEditor(); editor = new SourceEditor();
instance = editor.createInstance({ instance = editor.createInstance({
el: editorEl, el: editorEl,
blobPath: filePath, blobPath: markdownPath,
blobContent: text, blobContent: text,
}); });
editor.use(new EditorMarkdownExtension({ instance, projectPath })); editor.use(new EditorMarkdownExtension({ instance, projectPath }));
...@@ -70,16 +72,107 @@ describe('Markdown Extension for Source Editor', () => { ...@@ -70,16 +72,107 @@ describe('Markdown Extension for Source Editor', () => {
el: undefined, el: undefined,
action: expect.any(Object), action: expect.any(Object),
shown: false, shown: false,
modelChangeListener: undefined,
}); });
expect(instance.projectPath).toBe(projectPath); expect(instance.projectPath).toBe(projectPath);
}); });
describe('model language changes listener', () => {
let cleanupSpy;
let actionSpy;
beforeEach(async () => {
cleanupSpy = jest.spyOn(instance, 'cleanup');
actionSpy = jest.spyOn(instance, 'setupPreviewAction');
await togglePreview();
});
it('cleans up when switching away from markdown', () => {
expect(instance.cleanup).not.toHaveBeenCalled();
expect(instance.setupPreviewAction).not.toHaveBeenCalled();
instance.updateModelLanguage(plaintextPath);
expect(cleanupSpy).toHaveBeenCalled();
expect(actionSpy).not.toHaveBeenCalled();
});
it.each`
oldLanguage | newLanguage | setupCalledTimes
${'plaintext'} | ${'markdown'} | ${1}
${'markdown'} | ${'markdown'} | ${0}
${'markdown'} | ${'plaintext'} | ${0}
${'markdown'} | ${undefined} | ${0}
${undefined} | ${'markdown'} | ${1}
`(
'correctly handles re-enabling of the action when switching from $oldLanguage to $newLanguage',
({ oldLanguage, newLanguage, setupCalledTimes } = {}) => {
expect(actionSpy).not.toHaveBeenCalled();
instance.updateModelLanguage(oldLanguage);
instance.updateModelLanguage(newLanguage);
expect(actionSpy).toHaveBeenCalledTimes(setupCalledTimes);
},
);
});
describe('model change listener', () => {
let cleanupSpy;
let actionSpy;
beforeEach(() => {
cleanupSpy = jest.spyOn(instance, 'cleanup');
actionSpy = jest.spyOn(instance, 'setupPreviewAction');
instance.togglePreview();
});
afterEach(() => {
jest.clearAllMocks();
});
it('does not do anything if there is no model', () => {
instance.setModel(null);
expect(cleanupSpy).not.toHaveBeenCalled();
expect(actionSpy).not.toHaveBeenCalled();
});
it('cleans up the preview when the model changes', () => {
instance.setModel(monacoEditor.createModel('foo'));
expect(cleanupSpy).toHaveBeenCalled();
});
it.each`
language | setupCalledTimes
${'markdown'} | ${1}
${'plaintext'} | ${0}
${undefined} | ${0}
`(
'correctly handles actions when the new model is $language',
({ language, setupCalledTimes } = {}) => {
instance.setModel(monacoEditor.createModel('foo', language));
expect(actionSpy).toHaveBeenCalledTimes(setupCalledTimes);
},
);
});
describe('cleanup', () => { describe('cleanup', () => {
beforeEach(async () => { beforeEach(async () => {
mockAxios.onPost().reply(200, { body: responseData }); mockAxios.onPost().reply(200, { body: responseData });
await togglePreview(); await togglePreview();
}); });
it('disposes the modelChange listener and does not fetch preview on content changes', () => {
expect(instance.preview.modelChangeListener).toBeDefined();
jest.spyOn(instance, 'fetchPreview');
instance.cleanup();
instance.setValue('Foo Bar');
jest.advanceTimersByTime(EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY);
expect(instance.fetchPreview).not.toHaveBeenCalled();
});
it('removes the contextual menu action', () => { it('removes the contextual menu action', () => {
expect(instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID)).toBeDefined(); expect(instance.getAction(EXTENSION_MARKDOWN_PREVIEW_ACTION_ID)).toBeDefined();
...@@ -219,47 +312,6 @@ describe('Markdown Extension for Source Editor', () => { ...@@ -219,47 +312,6 @@ describe('Markdown Extension for Source Editor', () => {
expect(instance.preview.shown).toBe(false); expect(instance.preview.shown).toBe(false);
}); });
describe('model language changes', () => {
const plaintextPath = 'foo.txt';
const markdownPath = 'foo.md';
let cleanupSpy;
let actionSpy;
beforeEach(() => {
cleanupSpy = jest.spyOn(instance, 'cleanup');
actionSpy = jest.spyOn(instance, 'setupPreviewAction');
instance.togglePreview();
});
it('cleans up when switching away from markdown', async () => {
expect(instance.cleanup).not.toHaveBeenCalled();
expect(instance.setupPreviewAction).not.toHaveBeenCalled();
instance.updateModelLanguage(plaintextPath);
expect(cleanupSpy).toHaveBeenCalled();
expect(actionSpy).not.toHaveBeenCalled();
});
it('re-enables the action when switching back to markdown', () => {
instance.updateModelLanguage(plaintextPath);
jest.clearAllMocks();
instance.updateModelLanguage(markdownPath);
expect(cleanupSpy).not.toHaveBeenCalled();
expect(actionSpy).toHaveBeenCalled();
});
it('does not re-enable the action if we do not change the language', () => {
instance.updateModelLanguage(markdownPath);
expect(cleanupSpy).not.toHaveBeenCalled();
expect(actionSpy).not.toHaveBeenCalled();
});
});
describe('panel DOM element set up', () => { describe('panel DOM element set up', () => {
it('sets up an element to contain the preview and stores it on instance', () => { it('sets up an element to contain the preview and stores it on instance', () => {
expect(instance.preview.el).toBeUndefined(); expect(instance.preview.el).toBeUndefined();
...@@ -335,9 +387,9 @@ describe('Markdown Extension for Source Editor', () => { ...@@ -335,9 +387,9 @@ describe('Markdown Extension for Source Editor', () => {
}); });
it('stores disposable listener for model changes', async () => { it('stores disposable listener for model changes', async () => {
expect(instance.modelChangeListener).toBeUndefined(); expect(instance.preview.modelChangeListener).toBeUndefined();
await togglePreview(); await togglePreview();
expect(instance.modelChangeListener).toBeDefined(); expect(instance.preview.modelChangeListener).toBeDefined();
}); });
}); });
...@@ -354,7 +406,7 @@ describe('Markdown Extension for Source Editor', () => { ...@@ -354,7 +406,7 @@ describe('Markdown Extension for Source Editor', () => {
it('disposes the model change event listener', () => { it('disposes the model change event listener', () => {
const disposeSpy = jest.fn(); const disposeSpy = jest.fn();
instance.modelChangeListener = { instance.preview.modelChangeListener = {
dispose: disposeSpy, dispose: disposeSpy,
}; };
instance.togglePreview(); instance.togglePreview();
......
...@@ -53,6 +53,7 @@ describe('Source Editor utils', () => { ...@@ -53,6 +53,7 @@ describe('Source Editor utils', () => {
${'foo.js'} | ${'javascript'} ${'foo.js'} | ${'javascript'}
${'foo.js.rb'} | ${'ruby'} ${'foo.js.rb'} | ${'ruby'}
${'foo.bar'} | ${'plaintext'} ${'foo.bar'} | ${'plaintext'}
${undefined} | ${'plaintext'}
`( `(
'sets the $expectedThemeName theme when $themeName is set in the user preference', 'sets the $expectedThemeName theme when $themeName is set in the user preference',
({ path, expectedLanguage }) => { ({ path, expectedLanguage }) => {
......
...@@ -166,6 +166,11 @@ describe('RepoEditor', () => { ...@@ -166,6 +166,11 @@ describe('RepoEditor', () => {
expect(tabs).toHaveLength(1); expect(tabs).toHaveLength(1);
expect(tabs.at(0).text()).toBe('Edit'); expect(tabs.at(0).text()).toBe('Edit');
}); });
it('does not get markdown extension by default', async () => {
await createComponent();
expect(vm.editor.projectPath).toBeUndefined();
});
}); });
describe('when file is markdown', () => { describe('when file is markdown', () => {
...@@ -213,6 +218,11 @@ describe('RepoEditor', () => { ...@@ -213,6 +218,11 @@ describe('RepoEditor', () => {
}); });
expect(findTabs()).toHaveLength(0); expect(findTabs()).toHaveLength(0);
}); });
it('uses the markdown extension and sets it up correctly', async () => {
await createComponent({ activeFile });
expect(vm.editor.projectPath).toBe(vm.currentProjectId);
});
}); });
describe('when file is binary and not raw', () => { describe('when file is binary and not raw', () => {
......
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