Commit 0dcc35a9 authored by Paul Slaughter's avatar Paul Slaughter

Merge branch '23352-editorconfig' into 'master'

Support EditorConfig inside of the Web IDE

See merge request gitlab-org/gitlab!32378
parents f88f40ee 04e38af4
......@@ -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