Commit 04e38af4 authored by Himanshu Kapoor's avatar Himanshu Kapoor Committed by Paul Slaughter

Add support for .editorconfig in Web IDE

Developers collaborating on projects follow certain coding standards
styles to maintain consistent levels of readability and format. These
standards are usually provided to developers through configuration
files that the developer's editor might read and apply. When working in
the Web IDE, even if a project has configured this type of file, those
settings can't be enforced.

The leader in standards for this is EditorConfig which providers a file
format for defining coding styles. This change adds support for reading
.editorconfig files in the Web IDE and applying configuration settings
automatically.
parent 62329379
......@@ -15,6 +15,8 @@ import FileTemplatesBar from './file_templates/bar.vue';
import { __ } from '~/locale';
import { extractMarkdownImagesFromEntries } from '../stores/utils';
import { getPathParent, readFileAsDataURL } from '../utils';
import { getRulesWithTraversal } from '../lib/editorconfig/parser';
import mapRulesToMonaco from '../lib/editorconfig/rules_mapper';
export default {
components: {
......@@ -32,6 +34,7 @@ export default {
return {
content: '',
images: {},
rules: {},
};
},
computed: {
......@@ -195,7 +198,7 @@ export default {
this.editor.clearEditor();
this.fetchFileData()
Promise.all([this.fetchFileData(), this.fetchEditorconfigRules()])
.then(() => {
this.createEditorInstance();
})
......@@ -254,6 +257,8 @@ export default {
this.editor.attachModel(this.model);
}
this.model.updateOptions(this.rules);
this.model.onChange(model => {
const { file } = model;
if (!file.active) return;
......@@ -280,12 +285,29 @@ export default {
this.setFileLanguage({
fileLanguage: this.model.language,
});
this.$emit('editorSetup');
},
refreshEditorDimensions() {
if (this.showEditor) {
this.editor.updateDimensions();
}
},
fetchEditorconfigRules() {
return getRulesWithTraversal(this.file.path, path => {
const entry = this.entries[path];
if (!entry) return Promise.resolve(null);
const content = entry.content || entry.raw;
if (content) return Promise.resolve(content);
return this.getFileData({ path: entry.path, makeFileActive: false }).then(() =>
this.getRawFileData({ path: entry.path }),
);
}).then(rules => {
this.rules = mapRulesToMonaco(rules);
});
},
onPaste(event) {
const editor = this.editor.instance;
const reImage = /^image\/(png|jpg|jpeg|gif)$/;
......
import { parseString } from 'editorconfig/src/lib/ini';
import minimatch from 'minimatch';
import { getPathParents } from '../../utils';
const dirname = path => path.replace(/\.editorconfig$/, '');
function isRootConfig(config) {
return config.some(([pattern, rules]) => !pattern && rules?.root === 'true');
}
function getRulesForSection(path, [pattern, rules]) {
if (!pattern) {
return {};
}
if (minimatch(path, pattern, { matchBase: true })) {
return rules;
}
return {};
}
function getRulesWithConfigs(filePath, configFiles = [], rules = {}) {
if (!configFiles.length) return rules;
const [{ content, path: configPath }, ...nextConfigs] = configFiles;
const configDir = dirname(configPath);
if (!filePath.startsWith(configDir)) return rules;
const parsed = parseString(content);
const isRoot = isRootConfig(parsed);
const relativeFilePath = filePath.slice(configDir.length);
const sectionRules = parsed.reduce(
(acc, section) => Object.assign(acc, getRulesForSection(relativeFilePath, section)),
{},
);
// prefer existing rules by overwriting to section rules
const result = Object.assign(sectionRules, rules);
return isRoot ? result : getRulesWithConfigs(filePath, nextConfigs, result);
}
// eslint-disable-next-line import/prefer-default-export
export function getRulesWithTraversal(filePath, getFileContent) {
const editorconfigPaths = [
...getPathParents(filePath).map(x => `${x}/.editorconfig`),
'.editorconfig',
];
return Promise.all(
editorconfigPaths.map(path => getFileContent(path).then(content => ({ path, content }))),
).then(results => getRulesWithConfigs(filePath, results.filter(x => x.content)));
}
import { isBoolean, isNumber } from 'lodash';
const map = (key, validValues) => value =>
value in validValues ? { [key]: validValues[value] } : {};
const bool = key => value => (isBoolean(value) ? { [key]: value } : {});
const int = (key, isValid) => value =>
isNumber(value) && isValid(value) ? { [key]: Math.trunc(value) } : {};
const rulesMapper = {
indent_style: map('insertSpaces', { tab: false, space: true }),
indent_size: int('tabSize', n => n > 0),
tab_width: int('tabSize', n => n > 0),
trim_trailing_whitespace: bool('trimTrailingWhitespace'),
end_of_line: map('endOfLine', { crlf: 1, lf: 0 }),
insert_final_newline: bool('insertFinalNewline'),
};
const parseValue = x => {
let value = typeof x === 'string' ? x.toLowerCase() : x;
if (/^[0-9.-]+$/.test(value)) value = Number(value);
if (value === 'true') value = true;
if (value === 'false') value = false;
return value;
};
export default function mapRulesToMonaco(rules) {
return Object.entries(rules).reduce((obj, [key, value]) => {
return Object.assign(obj, rulesMapper[key]?.(parseValue(value)) || {});
}, {});
}
......@@ -15,22 +15,23 @@ import routerModule from './modules/router';
Vue.use(Vuex);
export const createStore = () =>
new Vuex.Store({
state: state(),
actions,
mutations,
getters,
modules: {
commit: commitModule,
pipelines,
mergeRequests,
branches,
fileTemplates: fileTemplates(),
rightPane: paneModule(),
clientside: clientsideModule(),
router: routerModule,
},
});
export const createStoreOptions = () => ({
state: state(),
actions,
mutations,
getters,
modules: {
commit: commitModule,
pipelines,
mergeRequests,
branches,
fileTemplates: fileTemplates(),
rightPane: paneModule(),
clientside: clientsideModule(),
router: routerModule,
},
});
export const createStore = () => new Vuex.Store(createStoreOptions());
export default createStore();
---
title: Support reading .editorconfig files inside of the Web IDE
merge_request: 32378
author:
type: added
......@@ -9,5 +9,8 @@ import 'monaco-editor/esm/vs/language/json/monaco.contribution';
import 'monaco-editor/esm/vs/language/html/monaco.contribution';
import 'monaco-editor/esm/vs/basic-languages/monaco.contribution';
// This language starts trying to spin up web workers which obviously breaks in Jest environment
jest.mock('monaco-editor/esm/vs/language/typescript/tsMode');
export * from 'monaco-editor/esm/vs/editor/editor.api';
export default global.monaco;
import Vuex from 'vuex';
import Vue from 'vue';
import MockAdapter from 'axios-mock-adapter';
import '~/behaviors/markdown/render_gfm';
import { Range } from 'monaco-editor';
import axios from '~/lib/utils/axios_utils';
import { createStore } from '~/ide/stores';
import repoEditor from '~/ide/components/repo_editor.vue';
import { createStoreOptions } from '~/ide/stores';
import RepoEditor from '~/ide/components/repo_editor.vue';
import Editor from '~/ide/lib/editor';
import { leftSidebarViews, FILE_VIEW_MODE_EDITOR, FILE_VIEW_MODE_PREVIEW } from '~/ide/constants';
import { createComponentWithStore } from '../../helpers/vue_mount_component_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { file } from '../helpers';
import { exampleConfigs, exampleFiles } from '../lib/editorconfig/mock_data';
describe('RepoEditor', () => {
let vm;
let store;
let mockActions;
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],
});
vm.$mount();
};
const createOpenFile = path => {
const origFile = store.state.openFiles[0];
const newFile = { ...origFile, path, key: path };
store.state.entries[path] = newFile;
store.state.openFiles = [newFile];
};
beforeEach(() => {
mockActions = {
getFileData: jest.fn().mockResolvedValue(),
getRawFileData: jest.fn().mockResolvedValue(),
};
const f = {
...file(),
viewMode: FILE_VIEW_MODE_EDITOR,
};
const RepoEditor = Vue.extend(repoEditor);
store = createStore();
vm = createComponentWithStore(RepoEditor, store, {
file: f,
});
const storeOptions = createStoreOptions();
storeOptions.actions = {
...storeOptions.actions,
...mockActions,
};
store = new Vuex.Store(storeOptions);
f.active = true;
f.tempFile = true;
vm.$store.state.openFiles.push(f);
vm.$store.state.projects = {
store.state.openFiles.push(f);
store.state.projects = {
'gitlab-org/gitlab': {
branches: {
master: {
......@@ -43,536 +76,588 @@ describe('RepoEditor', () => {
},
},
};
vm.$store.state.currentProjectId = 'gitlab-org/gitlab';
vm.$store.state.currentBranchId = 'master';
Vue.set(vm.$store.state.entries, f.path, f);
jest.spyOn(vm, 'getFileData').mockResolvedValue();
jest.spyOn(vm, 'getRawFileData').mockResolvedValue();
vm.$mount();
store.state.currentProjectId = 'gitlab-org/gitlab';
store.state.currentBranchId = 'master';
return vm.$nextTick();
Vue.set(store.state.entries, f.path, f);
});
afterEach(() => {
vm.$destroy();
vm = null;
Editor.editorInstance.dispose();
});
const findEditor = () => vm.$el.querySelector('.multi-file-editor-holder');
it('sets renderWhitespace to `all`', () => {
vm.$store.state.renderWhitespaceInCode = true;
expect(vm.editorOptions.renderWhitespace).toEqual('all');
});
it('sets renderWhitespace to `none`', () => {
vm.$store.state.renderWhitespaceInCode = false;
expect(vm.editorOptions.renderWhitespace).toEqual('none');
});
it('renders an ide container', () => {
expect(vm.shouldHideEditor).toBeFalsy();
expect(vm.showEditor).toBe(true);
expect(findEditor()).not.toHaveCss({ display: 'none' });
});
it('renders only an edit tab', done => {
Vue.nextTick(() => {
const tabs = vm.$el.querySelectorAll('.ide-mode-tabs .nav-links li');
expect(tabs.length).toBe(1);
expect(tabs[0].textContent.trim()).toBe('Edit');
describe('default', () => {
beforeEach(() => {
createComponent();
done();
return waitForEditorSetup();
});
});
describe('when file is markdown', () => {
let mock;
beforeEach(() => {
mock = new MockAdapter(axios);
mock.onPost(/(.*)\/preview_markdown/).reply(200, {
body: '<p>testing 123</p>',
});
it('sets renderWhitespace to `all`', () => {
vm.$store.state.renderWhitespaceInCode = true;
Vue.set(vm, 'file', {
...vm.file,
projectId: 'namespace/project',
path: 'sample.md',
content: 'testing 123',
});
expect(vm.editorOptions.renderWhitespace).toEqual('all');
});
vm.$store.state.entries[vm.file.path] = vm.file;
it('sets renderWhitespace to `none`', () => {
vm.$store.state.renderWhitespaceInCode = false;
return vm.$nextTick();
expect(vm.editorOptions.renderWhitespace).toEqual('none');
});
afterEach(() => {
mock.restore();
it('renders an ide container', () => {
expect(vm.shouldHideEditor).toBeFalsy();
expect(vm.showEditor).toBe(true);
expect(findEditor()).not.toHaveCss({ display: 'none' });
});
it('renders an Edit and a Preview Tab', done => {
it('renders only an edit tab', done => {
Vue.nextTick(() => {
const tabs = vm.$el.querySelectorAll('.ide-mode-tabs .nav-links li');
expect(tabs.length).toBe(2);
expect(tabs.length).toBe(1);
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);
});
describe('when file is markdown', () => {
let mock;
describe('when not in edit mode', () => {
beforeEach(async () => {
await vm.$nextTick();
beforeEach(() => {
mock = new MockAdapter(axios);
vm.$store.state.currentActivityView = leftSidebarViews.review.name;
mock.onPost(/(.*)\/preview_markdown/).reply(200, {
body: '<p>testing 123</p>',
});
Vue.set(vm, 'file', {
...vm.file,
projectId: 'namespace/project',
path: 'sample.md',
content: 'testing 123',
});
vm.$store.state.entries[vm.file.path] = vm.file;
return vm.$nextTick();
});
it('shows no tabs', () => {
expect(vm.$el.querySelectorAll('.ide-mode-tabs .nav-links a')).toHaveLength(0);
afterEach(() => {
mock.restore();
});
});
});
describe('when open file is binary and not raw', () => {
beforeEach(done => {
vm.file.binary = true;
it('renders an Edit and a Preview Tab', done => {
Vue.nextTick(() => {
const tabs = vm.$el.querySelectorAll('.ide-mode-tabs .nav-links li');
vm.$nextTick(done);
});
expect(tabs.length).toBe(2);
expect(tabs[0].textContent.trim()).toBe('Edit');
expect(tabs[1].textContent.trim()).toBe('Preview Markdown');
it('does not render the IDE', () => {
expect(vm.shouldHideEditor).toBeTruthy();
});
});
done();
});
});
describe('createEditorInstance', () => {
it('calls createInstance when viewer is editor', done => {
jest.spyOn(vm.editor, 'createInstance').mockImplementation();
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);
});
vm.createEditorInstance();
describe('when not in edit mode', () => {
beforeEach(async () => {
await vm.$nextTick();
vm.$nextTick(() => {
expect(vm.editor.createInstance).toHaveBeenCalled();
vm.$store.state.currentActivityView = leftSidebarViews.review.name;
done();
return vm.$nextTick();
});
it('shows no tabs', () => {
expect(vm.$el.querySelectorAll('.ide-mode-tabs .nav-links a')).toHaveLength(0);
});
});
});
it('calls createDiffInstance when viewer is diff', done => {
vm.$store.state.viewer = 'diff';
describe('when open file is binary and not raw', () => {
beforeEach(done => {
vm.file.binary = true;
jest.spyOn(vm.editor, 'createDiffInstance').mockImplementation();
vm.$nextTick(done);
});
vm.createEditorInstance();
it('does not render the IDE', () => {
expect(vm.shouldHideEditor).toBeTruthy();
});
});
vm.$nextTick(() => {
expect(vm.editor.createDiffInstance).toHaveBeenCalled();
describe('createEditorInstance', () => {
it('calls createInstance when viewer is editor', done => {
jest.spyOn(vm.editor, 'createInstance').mockImplementation();
done();
vm.createEditorInstance();
vm.$nextTick(() => {
expect(vm.editor.createInstance).toHaveBeenCalled();
done();
});
});
});
it('calls createDiffInstance when viewer is a merge request diff', done => {
vm.$store.state.viewer = 'mrdiff';
it('calls createDiffInstance when viewer is diff', done => {
vm.$store.state.viewer = 'diff';
jest.spyOn(vm.editor, 'createDiffInstance').mockImplementation();
jest.spyOn(vm.editor, 'createDiffInstance').mockImplementation();
vm.createEditorInstance();
vm.createEditorInstance();
vm.$nextTick(() => {
expect(vm.editor.createDiffInstance).toHaveBeenCalled();
vm.$nextTick(() => {
expect(vm.editor.createDiffInstance).toHaveBeenCalled();
done();
done();
});
});
});
});
describe('setupEditor', () => {
it('creates new model', () => {
jest.spyOn(vm.editor, 'createModel');
it('calls createDiffInstance when viewer is a merge request diff', done => {
vm.$store.state.viewer = 'mrdiff';
Editor.editorInstance.modelManager.dispose();
jest.spyOn(vm.editor, 'createDiffInstance').mockImplementation();
vm.setupEditor();
vm.createEditorInstance();
expect(vm.editor.createModel).toHaveBeenCalledWith(vm.file, null);
expect(vm.model).not.toBeNull();
vm.$nextTick(() => {
expect(vm.editor.createDiffInstance).toHaveBeenCalled();
done();
});
});
});
it('attaches model to editor', () => {
jest.spyOn(vm.editor, 'attachModel');
describe('setupEditor', () => {
it('creates new model', () => {
jest.spyOn(vm.editor, 'createModel');
Editor.editorInstance.modelManager.dispose();
Editor.editorInstance.modelManager.dispose();
vm.setupEditor();
vm.setupEditor();
expect(vm.editor.attachModel).toHaveBeenCalledWith(vm.model);
});
expect(vm.editor.createModel).toHaveBeenCalledWith(vm.file, null);
expect(vm.model).not.toBeNull();
});
it('attaches model to merge request editor', () => {
vm.$store.state.viewer = 'mrdiff';
vm.file.mrChange = true;
jest.spyOn(vm.editor, 'attachMergeRequestModel').mockImplementation();
it('attaches model to editor', () => {
jest.spyOn(vm.editor, 'attachModel');
Editor.editorInstance.modelManager.dispose();
Editor.editorInstance.modelManager.dispose();
vm.setupEditor();
vm.setupEditor();
expect(vm.editor.attachMergeRequestModel).toHaveBeenCalledWith(vm.model);
});
expect(vm.editor.attachModel).toHaveBeenCalledWith(vm.model);
});
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();
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();
Editor.editorInstance.modelManager.dispose();
vm.setupEditor();
vm.setupEditor();
expect(vm.editor.attachMergeRequestModel).not.toHaveBeenCalledWith(vm.model);
});
expect(vm.editor.attachMergeRequestModel).toHaveBeenCalledWith(vm.model);
});
it('adds callback methods', () => {
jest.spyOn(vm.editor, 'onPositionChange');
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();
Editor.editorInstance.modelManager.dispose();
vm.setupEditor();
vm.setupEditor();
expect(vm.editor.onPositionChange).toHaveBeenCalled();
expect(vm.model.events.size).toBe(2);
});
expect(vm.editor.attachMergeRequestModel).not.toHaveBeenCalledWith(vm.model);
});
it('updates state with the value of the model', () => {
vm.model.setValue('testing 1234');
it('adds callback methods', () => {
jest.spyOn(vm.editor, 'onPositionChange');
vm.setupEditor();
Editor.editorInstance.modelManager.dispose();
expect(vm.file.content).toBe('testing 1234');
});
vm.setupEditor();
it('sets head model as staged file', () => {
jest.spyOn(vm.editor, 'createModel');
expect(vm.editor.onPositionChange).toHaveBeenCalled();
expect(vm.model.events.size).toBe(2);
});
Editor.editorInstance.modelManager.dispose();
it('updates state with the value of the model', () => {
vm.model.setValue('testing 1234\n');
vm.$store.state.stagedFiles.push({ ...vm.file, key: 'staged' });
vm.file.staged = true;
vm.file.key = `unstaged-${vm.file.key}`;
vm.setupEditor();
vm.setupEditor();
expect(vm.file.content).toBe('testing 1234\n');
});
expect(vm.editor.createModel).toHaveBeenCalledWith(vm.file, vm.$store.state.stagedFiles[0]);
});
});
it('sets head model as staged file', () => {
jest.spyOn(vm.editor, 'createModel');
describe('editor updateDimensions', () => {
beforeEach(() => {
jest.spyOn(vm.editor, 'updateDimensions');
jest.spyOn(vm.editor, 'updateDiffView').mockImplementation();
});
Editor.editorInstance.modelManager.dispose();
it('calls updateDimensions when panelResizing is false', done => {
vm.$store.state.panelResizing = true;
vm.$store.state.stagedFiles.push({ ...vm.file, key: 'staged' });
vm.file.staged = true;
vm.file.key = `unstaged-${vm.file.key}`;
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);
vm.setupEditor();
expect(vm.editor.createModel).toHaveBeenCalledWith(vm.file, vm.$store.state.stagedFiles[0]);
});
});
it('does not call updateDimensions when panelResizing is true', done => {
vm.$store.state.panelResizing = true;
describe('editor updateDimensions', () => {
beforeEach(() => {
jest.spyOn(vm.editor, 'updateDimensions');
jest.spyOn(vm.editor, 'updateDiffView').mockImplementation();
});
vm.$nextTick(() => {
expect(vm.editor.updateDimensions).not.toHaveBeenCalled();
expect(vm.editor.updateDiffView).not.toHaveBeenCalled();
it('calls updateDimensions when panelResizing is false', done => {
vm.$store.state.panelResizing = true;
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);
});
done();
it('does not call updateDimensions when panelResizing is true', done => {
vm.$store.state.panelResizing = true;
vm.$nextTick(() => {
expect(vm.editor.updateDimensions).not.toHaveBeenCalled();
expect(vm.editor.updateDiffView).not.toHaveBeenCalled();
done();
});
});
});
it('calls updateDimensions when rightPane is opened', done => {
vm.$store.state.rightPane.isOpen = true;
it('calls updateDimensions when rightPane is opened', done => {
vm.$store.state.rightPane.isOpen = true;
vm.$nextTick(() => {
expect(vm.editor.updateDimensions).toHaveBeenCalled();
expect(vm.editor.updateDiffView).toHaveBeenCalled();
vm.$nextTick(() => {
expect(vm.editor.updateDimensions).toHaveBeenCalled();
expect(vm.editor.updateDiffView).toHaveBeenCalled();
done();
done();
});
});
});
});
describe('show tabs', () => {
it('shows tabs in edit mode', () => {
expect(vm.$el.querySelector('.nav-links')).not.toBe(null);
});
describe('show tabs', () => {
it('shows tabs in edit mode', () => {
expect(vm.$el.querySelector('.nav-links')).not.toBe(null);
});
it('hides tabs in review mode', done => {
vm.$store.state.currentActivityView = leftSidebarViews.review.name;
it('hides tabs in review mode', done => {
vm.$store.state.currentActivityView = leftSidebarViews.review.name;
vm.$nextTick(() => {
expect(vm.$el.querySelector('.nav-links')).toBe(null);
vm.$nextTick(() => {
expect(vm.$el.querySelector('.nav-links')).toBe(null);
done();
done();
});
});
});
it('hides tabs in commit mode', done => {
vm.$store.state.currentActivityView = leftSidebarViews.commit.name;
it('hides tabs in commit mode', done => {
vm.$store.state.currentActivityView = leftSidebarViews.commit.name;
vm.$nextTick(() => {
expect(vm.$el.querySelector('.nav-links')).toBe(null);
vm.$nextTick(() => {
expect(vm.$el.querySelector('.nav-links')).toBe(null);
done();
done();
});
});
});
});
describe('when files view mode is preview', () => {
beforeEach(done => {
jest.spyOn(vm.editor, 'updateDimensions').mockImplementation();
vm.file.viewMode = FILE_VIEW_MODE_PREVIEW;
vm.$nextTick(done);
});
describe('when files view mode is preview', () => {
beforeEach(done => {
jest.spyOn(vm.editor, 'updateDimensions').mockImplementation();
vm.file.viewMode = FILE_VIEW_MODE_PREVIEW;
vm.$nextTick(done);
});
it('should hide editor', () => {
expect(vm.showEditor).toBe(false);
expect(findEditor()).toHaveCss({ display: 'none' });
});
it('should hide editor', () => {
expect(vm.showEditor).toBe(false);
expect(findEditor()).toHaveCss({ display: 'none' });
});
describe('when file view mode changes to editor', () => {
it('should update dimensions', () => {
vm.file.viewMode = FILE_VIEW_MODE_EDITOR;
describe('when file view mode changes to editor', () => {
it('should update dimensions', () => {
vm.file.viewMode = FILE_VIEW_MODE_EDITOR;
return vm.$nextTick().then(() => {
expect(vm.editor.updateDimensions).toHaveBeenCalled();
return vm.$nextTick().then(() => {
expect(vm.editor.updateDimensions).toHaveBeenCalled();
});
});
});
});
});
describe('initEditor', () => {
beforeEach(() => {
vm.file.tempFile = false;
jest.spyOn(vm.editor, 'createInstance').mockImplementation();
jest.spyOn(vm, 'shouldHideEditor', 'get').mockReturnValue(true);
});
describe('initEditor', () => {
beforeEach(() => {
vm.file.tempFile = false;
jest.spyOn(vm.editor, 'createInstance').mockImplementation();
jest.spyOn(vm, 'shouldHideEditor', 'get').mockReturnValue(true);
});
it('does not fetch file information for temp entries', done => {
vm.file.tempFile = true;
it('does not fetch file information for temp entries', done => {
vm.file.tempFile = true;
vm.initEditor();
vm.$nextTick()
.then(() => {
expect(vm.getFileData).not.toHaveBeenCalled();
})
.then(done)
.catch(done.fail);
});
vm.initEditor();
vm.$nextTick()
.then(() => {
expect(mockActions.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 = '';
vm.initEditor();
vm.$nextTick()
.then(() => {
expect(vm.getFileData).toHaveBeenCalled();
expect(vm.getRawFileData).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 = '';
vm.initEditor();
vm.$nextTick()
.then(() => {
expect(mockActions.getFileData).toHaveBeenCalled();
expect(mockActions.getRawFileData).toHaveBeenCalled();
})
.then(done)
.catch(done.fail);
});
it('does not initialize editor for files already with content', done => {
vm.file.content = 'foo';
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);
it('does not initialize editor for files already with content', done => {
vm.file.content = 'foo';
vm.initEditor();
vm.$nextTick()
.then(() => {
expect(mockActions.getFileData).not.toHaveBeenCalled();
expect(mockActions.getRawFileData).not.toHaveBeenCalled();
expect(vm.editor.createInstance).not.toHaveBeenCalled();
})
.then(done)
.catch(done.fail);
});
});
});
describe('updates on file changes', () => {
beforeEach(() => {
jest.spyOn(vm, 'initEditor').mockImplementation();
});
describe('updates on file changes', () => {
beforeEach(() => {
jest.spyOn(vm, 'initEditor').mockImplementation();
});
it('calls removePendingTab when old file is pending', done => {
jest.spyOn(vm, 'shouldHideEditor', 'get').mockReturnValue(true);
jest.spyOn(vm, 'removePendingTab').mockImplementation();
it('calls removePendingTab when old file is pending', done => {
jest.spyOn(vm, 'shouldHideEditor', 'get').mockReturnValue(true);
jest.spyOn(vm, 'removePendingTab').mockImplementation();
vm.file.pending = true;
vm.file.pending = true;
vm.$nextTick()
.then(() => {
vm.file = file('testing');
vm.file.content = 'foo'; // need to prevent full cycle of initEditor
vm.$nextTick()
.then(() => {
vm.file = file('testing');
vm.file.content = 'foo'; // need to prevent full cycle of initEditor
return vm.$nextTick();
})
.then(() => {
expect(vm.removePendingTab).toHaveBeenCalled();
})
.then(done)
.catch(done.fail);
});
return vm.$nextTick();
})
.then(() => {
expect(vm.removePendingTab).toHaveBeenCalled();
})
.then(done)
.catch(done.fail);
});
it('does not call initEditor if the file did not change', done => {
Vue.set(vm, 'file', vm.file);
it('does not call initEditor if the file did not change', done => {
Vue.set(vm, 'file', vm.file);
vm.$nextTick()
.then(() => {
expect(vm.initEditor).not.toHaveBeenCalled();
})
.then(done)
.catch(done.fail);
});
vm.$nextTick()
.then(() => {
expect(vm.initEditor).not.toHaveBeenCalled();
})
.then(done)
.catch(done.fail);
});
it('calls initEditor when file key is changed', done => {
expect(vm.initEditor).not.toHaveBeenCalled();
it('calls initEditor when file key is changed', done => {
expect(vm.initEditor).not.toHaveBeenCalled();
Vue.set(vm, 'file', {
...vm.file,
key: 'new',
});
Vue.set(vm, 'file', {
...vm.file,
key: 'new',
});
vm.$nextTick()
.then(() => {
expect(vm.initEditor).toHaveBeenCalled();
})
.then(done)
.catch(done.fail);
vm.$nextTick()
.then(() => {
expect(vm.initEditor).toHaveBeenCalled();
})
.then(done)
.catch(done.fail);
});
});
});
describe('onPaste', () => {
const setFileName = name => {
Vue.set(vm, 'file', {
...vm.file,
content: 'hello world\n',
name,
path: `foo/${name}`,
key: 'new',
});
describe('onPaste', () => {
const setFileName = name => {
Vue.set(vm, 'file', {
...vm.file,
content: 'hello world\n',
name,
path: `foo/${name}`,
key: 'new',
});
vm.$store.state.entries[vm.file.path] = vm.file;
};
vm.$store.state.entries[vm.file.path] = vm.file;
};
const pasteImage = () => {
window.dispatchEvent(
Object.assign(new Event('paste'), {
clipboardData: {
files: [new File(['foo'], 'foo.png', { type: 'image/png' })],
},
}),
);
};
const pasteImage = () => {
window.dispatchEvent(
Object.assign(new Event('paste'), {
clipboardData: {
files: [new File(['foo'], 'foo.png', { type: 'image/png' })],
},
}),
);
};
const watchState = watched =>
new Promise(resolve => {
const unwatch = vm.$store.watch(watched, () => {
unwatch();
resolve();
});
});
beforeEach(() => {
setFileName('bar.md');
vm.$store.state.trees['gitlab-org/gitlab'] = { tree: [] };
vm.$store.state.currentProjectId = 'gitlab-org';
vm.$store.state.currentBranchId = 'gitlab';
// 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();
const watchState = watched =>
new Promise(resolve => {
const unwatch = vm.$store.watch(watched, () => {
unwatch();
resolve();
return waitForPromises().then(() => {
// set cursor to line 2, column 1
vm.editor.instance.setSelection(new Range(2, 1, 2, 1));
vm.editor.instance.focus();
});
});
beforeEach(() => {
setFileName('bar.md');
vm.$store.state.trees['gitlab-org/gitlab'] = { tree: [] };
vm.$store.state.currentProjectId = 'gitlab-org';
vm.$store.state.currentBranchId = 'gitlab';
it('adds an image entry to the same folder for a pasted image in a markdown file', () => {
pasteImage();
return waitForPromises().then(() => {
expect(vm.$store.state.entries['foo/foo.png']).toMatchObject({
path: 'foo/foo.png',
type: 'blob',
content: 'Zm9v',
binary: true,
rawPath: 'data:image/png;base64,Zm9v',
});
});
});
// 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();
it("adds a markdown image tag to the file's contents", () => {
pasteImage();
return waitForPromises().then(() => {
// set cursor to line 2, column 1
vm.editor.instance.setSelection(new Range(2, 1, 2, 1));
vm.editor.instance.focus();
// Pasting an image does a lot of things like using the FileReader API,
// so, waitForPromises isn't very reliable (and causes a flaky spec)
// Read more about state.watch: https://vuex.vuejs.org/api/#watch
return watchState(s => s.entries['foo/bar.md'].content).then(() => {
expect(vm.file.content).toBe('hello world\n![foo.png](./foo.png)');
});
});
});
it('adds an image entry to the same folder for a pasted image in a markdown file', () => {
pasteImage();
it("does not add file to state or set markdown image syntax if the file isn't markdown", () => {
setFileName('myfile.txt');
pasteImage();
return waitForPromises().then(() => {
expect(vm.$store.state.entries['foo/foo.png']).toMatchObject({
path: 'foo/foo.png',
type: 'blob',
content: 'Zm9v',
binary: true,
rawPath: 'data:image/png;base64,Zm9v',
return waitForPromises().then(() => {
expect(vm.$store.state.entries['foo/foo.png']).toBeUndefined();
expect(vm.file.content).toBe('hello world\n');
});
});
});
});
it("adds a markdown image tag to the file's contents", () => {
pasteImage();
// Pasting an image does a lot of things like using the FileReader API,
// so, waitForPromises isn't very reliable (and causes a flaky spec)
// Read more about state.watch: https://vuex.vuejs.org/api/#watch
return watchState(s => s.entries['foo/bar.md'].content).then(() => {
expect(vm.file.content).toBe('hello world\n![foo.png](./foo.png)');
describe('fetchEditorconfigRules', () => {
beforeEach(() => {
exampleConfigs.forEach(({ path, content }) => {
store.state.entries[path] = { ...file(), path, content };
});
});
it("does not add file to state or set markdown image syntax if the file isn't markdown", () => {
setFileName('myfile.txt');
pasteImage();
it.each(exampleFiles)(
'does not fetch content from remote for .editorconfig files present locally (case %#)',
({ path, monacoRules }) => {
createOpenFile(path);
createComponent();
return waitForEditorSetup().then(() => {
expect(vm.rules).toEqual(monacoRules);
expect(vm.model.options).toMatchObject(monacoRules);
expect(mockActions.getFileData).not.toHaveBeenCalled();
expect(mockActions.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;
});
return waitForPromises().then(() => {
expect(vm.$store.state.entries['foo/foo.png']).toBeUndefined();
expect(vm.file.content).toBe('hello world\n');
// 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(mockActions.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(mockActions.getRawFileData.mock.calls.map(([, args]) => args)).toEqual([
{ path: 'foo/bar/baz/.editorconfig' },
{ path: 'foo/bar/.editorconfig' },
{ path: 'foo/.editorconfig' },
{ path: '.editorconfig' },
]);
});
});
});
......
export const exampleConfigs = [
{
path: 'foo/bar/baz/.editorconfig',
content: `
[*]
tab_width = 6
indent_style = tab
`,
},
{
path: 'foo/bar/.editorconfig',
content: `
root = false
[*]
indent_size = 5
indent_style = space
trim_trailing_whitespace = true
[*_spec.{js,py}]
end_of_line = crlf
`,
},
{
path: 'foo/.editorconfig',
content: `
[*]
tab_width = 4
indent_style = tab
`,
},
{
path: '.editorconfig',
content: `
root = true
[*]
indent_size = 3
indent_style = space
end_of_line = lf
insert_final_newline = true
[*.js]
indent_size = 2
indent_style = space
trim_trailing_whitespace = true
[*.txt]
end_of_line = crlf
`,
},
{
path: 'foo/bar/root/.editorconfig',
content: `
root = true
[*]
tab_width = 1
indent_style = tab
`,
},
];
export const exampleFiles = [
{
path: 'foo/bar/root/README.md',
rules: {
indent_style: 'tab', // foo/bar/root/.editorconfig
tab_width: '1', // foo/bar/root/.editorconfig
},
monacoRules: {
insertSpaces: false,
tabSize: 1,
},
},
{
path: 'foo/bar/baz/my_spec.js',
rules: {
end_of_line: 'crlf', // foo/bar/.editorconfig (for _spec.js files)
indent_size: '5', // foo/bar/.editorconfig
indent_style: 'tab', // foo/bar/baz/.editorconfig
insert_final_newline: 'true', // .editorconfig
tab_width: '6', // foo/bar/baz/.editorconfig
trim_trailing_whitespace: 'true', // .editorconfig (for .js files)
},
monacoRules: {
endOfLine: 1,
insertFinalNewline: true,
insertSpaces: false,
tabSize: 6,
trimTrailingWhitespace: true,
},
},
{
path: 'foo/my_file.js',
rules: {
end_of_line: 'lf', // .editorconfig
indent_size: '2', // .editorconfig (for .js files)
indent_style: 'tab', // foo/.editorconfig
insert_final_newline: 'true', // .editorconfig
tab_width: '4', // foo/.editorconfig
trim_trailing_whitespace: 'true', // .editorconfig (for .js files)
},
monacoRules: {
endOfLine: 0,
insertFinalNewline: true,
insertSpaces: false,
tabSize: 4,
trimTrailingWhitespace: true,
},
},
{
path: 'foo/my_file.md',
rules: {
end_of_line: 'lf', // .editorconfig
indent_size: '3', // .editorconfig
indent_style: 'tab', // foo/.editorconfig
insert_final_newline: 'true', // .editorconfig
tab_width: '4', // foo/.editorconfig
},
monacoRules: {
endOfLine: 0,
insertFinalNewline: true,
insertSpaces: false,
tabSize: 4,
},
},
{
path: 'foo/bar/my_file.txt',
rules: {
end_of_line: 'crlf', // .editorconfig (for .txt files)
indent_size: '5', // foo/bar/.editorconfig
indent_style: 'space', // foo/bar/.editorconfig
insert_final_newline: 'true', // .editorconfig
tab_width: '4', // foo/.editorconfig
trim_trailing_whitespace: 'true', // foo/bar/.editorconfig
},
monacoRules: {
endOfLine: 1,
insertFinalNewline: true,
insertSpaces: true,
tabSize: 4,
trimTrailingWhitespace: true,
},
},
];
import { getRulesWithTraversal } from '~/ide/lib/editorconfig/parser';
import { exampleConfigs, exampleFiles } from './mock_data';
describe('~/ide/lib/editorconfig/parser', () => {
const getExampleConfigContent = path =>
Promise.resolve(exampleConfigs.find(x => x.path === path)?.content);
describe('getRulesWithTraversal', () => {
it.each(exampleFiles)(
'traverses through all editorconfig files in parent directories (until root=true is hit) and finds rules for this file (case %#)',
({ path, rules }) => {
return getRulesWithTraversal(path, getExampleConfigContent).then(result => {
expect(result).toEqual(rules);
});
},
);
});
});
import mapRulesToMonaco from '~/ide/lib/editorconfig/rules_mapper';
describe('mapRulesToMonaco', () => {
const multipleEntries = {
input: { indent_style: 'tab', indent_size: '4', insert_final_newline: 'true' },
output: { insertSpaces: false, tabSize: 4, insertFinalNewline: true },
};
// tab width takes precedence
const tabWidthAndIndent = {
input: { indent_style: 'tab', indent_size: '4', tab_width: '3' },
output: { insertSpaces: false, tabSize: 3 },
};
it.each`
rule | monacoOption
${{ indent_style: 'tab' }} | ${{ insertSpaces: false }}
${{ indent_style: 'space' }} | ${{ insertSpaces: true }}
${{ indent_style: 'unset' }} | ${{}}
${{ indent_size: '4' }} | ${{ tabSize: 4 }}
${{ indent_size: '4.4' }} | ${{ tabSize: 4 }}
${{ indent_size: '0' }} | ${{}}
${{ indent_size: '-10' }} | ${{}}
${{ indent_size: 'NaN' }} | ${{}}
${{ tab_width: '4' }} | ${{ tabSize: 4 }}
${{ tab_width: '5.4' }} | ${{ tabSize: 5 }}
${{ tab_width: '-10' }} | ${{}}
${{ trim_trailing_whitespace: 'true' }} | ${{ trimTrailingWhitespace: true }}
${{ trim_trailing_whitespace: 'false' }} | ${{ trimTrailingWhitespace: false }}
${{ trim_trailing_whitespace: 'unset' }} | ${{}}
${{ end_of_line: 'lf' }} | ${{ endOfLine: 0 }}
${{ end_of_line: 'crlf' }} | ${{ endOfLine: 1 }}
${{ end_of_line: 'cr' }} | ${{}}
${{ end_of_line: 'unset' }} | ${{}}
${{ insert_final_newline: 'true' }} | ${{ insertFinalNewline: true }}
${{ insert_final_newline: 'false' }} | ${{ insertFinalNewline: false }}
${{ insert_final_newline: 'unset' }} | ${{}}
${multipleEntries.input} | ${multipleEntries.output}
${tabWidthAndIndent.input} | ${tabWidthAndIndent.output}
`('correctly maps editorconfig rule to monaco option: $rule', ({ rule, monacoOption }) => {
expect(mapRulesToMonaco(rule)).toEqual(monacoOption);
});
});
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