Commit 641bb13b authored by Phil Hughes's avatar Phil Hughes

Merge branch 'tz-ide-markdown-preview' into 'master'

Web IDE markdown preview

Closes #44843

See merge request gitlab-org/gitlab-ce!18059
parents 21488c74 c88cc0c0
...@@ -3,7 +3,6 @@ import { mapState, mapGetters } from 'vuex'; ...@@ -3,7 +3,6 @@ import { mapState, mapGetters } from 'vuex';
import ideSidebar from './ide_side_bar.vue'; import ideSidebar from './ide_side_bar.vue';
import ideContextbar from './ide_context_bar.vue'; import ideContextbar from './ide_context_bar.vue';
import repoTabs from './repo_tabs.vue'; import repoTabs from './repo_tabs.vue';
import repoFileButtons from './repo_file_buttons.vue';
import ideStatusBar from './ide_status_bar.vue'; import ideStatusBar from './ide_status_bar.vue';
import repoEditor from './repo_editor.vue'; import repoEditor from './repo_editor.vue';
...@@ -12,7 +11,6 @@ export default { ...@@ -12,7 +11,6 @@ export default {
ideSidebar, ideSidebar,
ideContextbar, ideContextbar,
repoTabs, repoTabs,
repoFileButtons,
ideStatusBar, ideStatusBar,
repoEditor, repoEditor,
}, },
...@@ -70,9 +68,6 @@ export default { ...@@ -70,9 +68,6 @@ export default {
class="multi-file-edit-pane-content" class="multi-file-edit-pane-content"
:file="activeFile" :file="activeFile"
/> />
<repo-file-buttons
:file="activeFile"
/>
<ide-status-bar <ide-status-bar
:file="activeFile" :file="activeFile"
/> />
......
<script>
import { __ } from '~/locale';
import tooltip from '~/vue_shared/directives/tooltip';
import Icon from '~/vue_shared/components/icon.vue';
export default {
components: {
Icon,
},
directives: {
tooltip,
},
props: {
file: {
type: Object,
required: true,
},
},
computed: {
showButtons() {
return (
this.file.rawPath || this.file.blamePath || this.file.commitsPath || this.file.permalink
);
},
rawDownloadButtonLabel() {
return this.file.binary ? __('Download') : __('Raw');
},
},
};
</script>
<template>
<div
v-if="showButtons"
class="pull-right ide-btn-group"
>
<a
v-tooltip
:href="file.blamePath"
:title="__('Blame')"
class="btn btn-xs btn-transparent blame"
>
<icon
name="blame"
:size="16"
/>
</a>
<a
v-tooltip
:href="file.commitsPath"
:title="__('History')"
class="btn btn-xs btn-transparent history"
>
<icon
name="history"
:size="16"
/>
</a>
<a
v-tooltip
:href="file.permalink"
:title="__('Permalink')"
class="btn btn-xs btn-transparent permalink"
>
<icon
name="link"
:size="16"
/>
</a>
<a
v-tooltip
:href="file.rawPath"
target="_blank"
class="btn btn-xs btn-transparent prepend-left-10 raw"
rel="noopener noreferrer"
:title="rawDownloadButtonLabel">
<icon
name="download"
:size="16"
/>
</a>
</div>
</template>
...@@ -2,10 +2,16 @@ ...@@ -2,10 +2,16 @@
/* global monaco */ /* global monaco */
import { mapState, mapGetters, mapActions } from 'vuex'; import { mapState, mapGetters, mapActions } from 'vuex';
import flash from '~/flash'; import flash from '~/flash';
import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue';
import monacoLoader from '../monaco_loader'; import monacoLoader from '../monaco_loader';
import Editor from '../lib/editor'; import Editor from '../lib/editor';
import IdeFileButtons from './ide_file_buttons.vue';
export default { export default {
components: {
ContentViewer,
IdeFileButtons,
},
props: { props: {
file: { file: {
type: Object, type: Object,
...@@ -18,6 +24,16 @@ export default { ...@@ -18,6 +24,16 @@ export default {
shouldHideEditor() { shouldHideEditor() {
return this.file && this.file.binary && !this.file.raw; return this.file && this.file.binary && !this.file.raw;
}, },
editTabCSS() {
return {
active: this.file.viewMode === 'edit',
};
},
previewTabCSS() {
return {
active: this.file.viewMode === 'preview',
};
},
}, },
watch: { watch: {
file(oldVal, newVal) { file(oldVal, newVal) {
...@@ -56,6 +72,7 @@ export default { ...@@ -56,6 +72,7 @@ export default {
'changeFileContent', 'changeFileContent',
'setFileLanguage', 'setFileLanguage',
'setEditorPosition', 'setEditorPosition',
'setFileViewMode',
'setFileEOL', 'setFileEOL',
'updateViewer', 'updateViewer',
'updateDelayViewerUpdated', 'updateDelayViewerUpdated',
...@@ -153,15 +170,47 @@ export default { ...@@ -153,15 +170,47 @@ export default {
class="blob-viewer-container blob-editor-container" class="blob-viewer-container blob-editor-container"
> >
<div <div
v-if="shouldHideEditor" class="ide-mode-tabs clearfix"
v-html="file.html" v-if="!shouldHideEditor">
> <ul class="nav-links pull-left">
<li :class="editTabCSS">
<a
href="javascript:void(0);"
role="button"
@click.prevent="setFileViewMode({ file, viewMode: 'edit' })">
<template v-if="viewer === 'editor'">
{{ __('Edit') }}
</template>
<template v-else>
{{ __('Review') }}
</template>
</a>
</li>
<li
v-if="file.previewMode"
:class="previewTabCSS">
<a
href="javascript:void(0);"
role="button"
@click.prevent="setFileViewMode({ file, viewMode:'preview' })">
{{ file.previewMode.previewTitle }}
</a>
</li>
</ul>
<ide-file-buttons
:file="file"
/>
</div> </div>
<div <div
v-show="!shouldHideEditor" v-show="!shouldHideEditor && file.viewMode === 'edit'"
ref="editor" ref="editor"
class="multi-file-editor-holder" class="multi-file-editor-holder"
> >
</div> </div>
<content-viewer
v-if="!shouldHideEditor && file.viewMode === 'preview'"
:content="file.content || file.raw"
:path="file.path"
:project-path="file.projectId"/>
</div> </div>
</template> </template>
<script>
export default {
props: {
file: {
type: Object,
required: true,
},
},
computed: {
showButtons() {
return this.file.rawPath ||
this.file.blamePath ||
this.file.commitsPath ||
this.file.permalink;
},
rawDownloadButtonLabel() {
return this.file.binary ? 'Download' : 'Raw';
},
},
};
</script>
<template>
<div
v-if="showButtons"
class="multi-file-editor-btn-group"
>
<a
:href="file.rawPath"
target="_blank"
class="btn btn-default btn-sm raw"
rel="noopener noreferrer">
{{ rawDownloadButtonLabel }}
</a>
<div
class="btn-group"
role="group"
aria-label="File actions"
>
<a
:href="file.blamePath"
class="btn btn-default btn-sm blame"
>
Blame
</a>
<a
:href="file.commitsPath"
class="btn btn-default btn-sm history"
>
History
</a>
<a
:href="file.permalink"
class="btn btn-default btn-sm permalink"
>
Permalink
</a>
</div>
</div>
</template>
...@@ -149,6 +149,10 @@ export const setEditorPosition = ({ getters, commit }, { editorRow, editorColumn ...@@ -149,6 +149,10 @@ export const setEditorPosition = ({ getters, commit }, { editorRow, editorColumn
} }
}; };
export const setFileViewMode = ({ state, commit }, { file, viewMode }) => {
commit(types.SET_FILE_VIEWMODE, { file, viewMode });
};
export const discardFileChanges = ({ state, commit }, path) => { export const discardFileChanges = ({ state, commit }, path) => {
const file = state.entries[path]; const file = state.entries[path];
......
...@@ -38,6 +38,7 @@ export const SET_FILE_BASE_RAW_DATA = 'SET_FILE_BASE_RAW_DATA'; ...@@ -38,6 +38,7 @@ export const SET_FILE_BASE_RAW_DATA = 'SET_FILE_BASE_RAW_DATA';
export const UPDATE_FILE_CONTENT = 'UPDATE_FILE_CONTENT'; export const UPDATE_FILE_CONTENT = 'UPDATE_FILE_CONTENT';
export const SET_FILE_LANGUAGE = 'SET_FILE_LANGUAGE'; export const SET_FILE_LANGUAGE = 'SET_FILE_LANGUAGE';
export const SET_FILE_POSITION = 'SET_FILE_POSITION'; export const SET_FILE_POSITION = 'SET_FILE_POSITION';
export const SET_FILE_VIEWMODE = 'SET_FILE_VIEWMODE';
export const SET_FILE_EOL = 'SET_FILE_EOL'; export const SET_FILE_EOL = 'SET_FILE_EOL';
export const DISCARD_FILE_CHANGES = 'DISCARD_FILE_CHANGES'; export const DISCARD_FILE_CHANGES = 'DISCARD_FILE_CHANGES';
export const ADD_FILE_TO_CHANGED = 'ADD_FILE_TO_CHANGED'; export const ADD_FILE_TO_CHANGED = 'ADD_FILE_TO_CHANGED';
......
...@@ -42,6 +42,7 @@ export default { ...@@ -42,6 +42,7 @@ export default {
renderError: data.render_error, renderError: data.render_error,
raw: null, raw: null,
baseRaw: null, baseRaw: null,
html: data.html,
}); });
}, },
[types.SET_FILE_RAW_DATA](state, { file, raw }) { [types.SET_FILE_RAW_DATA](state, { file, raw }) {
...@@ -83,6 +84,11 @@ export default { ...@@ -83,6 +84,11 @@ export default {
mrChange, mrChange,
}); });
}, },
[types.SET_FILE_VIEWMODE](state, { file, viewMode }) {
Object.assign(state.entries[file.path], {
viewMode,
});
},
[types.DISCARD_FILE_CHANGES](state, path) { [types.DISCARD_FILE_CHANGES](state, path) {
Object.assign(state.entries[path], { Object.assign(state.entries[path], {
content: state.entries[path].raw, content: state.entries[path].raw,
......
...@@ -38,6 +38,8 @@ export const dataStructure = () => ({ ...@@ -38,6 +38,8 @@ export const dataStructure = () => ({
editorColumn: 1, editorColumn: 1,
fileLanguage: '', fileLanguage: '',
eol: '', eol: '',
viewMode: 'edit',
previewMode: null,
}); });
export const decorateData = entity => { export const decorateData = entity => {
...@@ -57,8 +59,9 @@ export const decorateData = entity => { ...@@ -57,8 +59,9 @@ export const decorateData = entity => {
changed = false, changed = false,
parentTreeUrl = '', parentTreeUrl = '',
base64 = false, base64 = false,
previewMode,
file_lock, file_lock,
html,
} = entity; } = entity;
return { return {
...@@ -79,8 +82,9 @@ export const decorateData = entity => { ...@@ -79,8 +82,9 @@ export const decorateData = entity => {
renderError, renderError,
content, content,
base64, base64,
previewMode,
file_lock, file_lock,
html,
}; };
}; };
......
import { viewerInformationForPath } from '~/vue_shared/components/content_viewer/lib/viewer_utils';
import { decorateData, sortTree } from '../utils'; import { decorateData, sortTree } from '../utils';
self.addEventListener('message', e => { self.addEventListener('message', e => {
const { const { data, projectId, branchId, tempFile = false, content = '', base64 = false } = e.data;
data,
projectId,
branchId,
tempFile = false,
content = '',
base64 = false,
} = e.data;
const treeList = []; const treeList = [];
let file; let file;
...@@ -19,9 +13,7 @@ self.addEventListener('message', e => { ...@@ -19,9 +13,7 @@ self.addEventListener('message', e => {
if (pathSplit.length > 0) { if (pathSplit.length > 0) {
pathSplit.reduce((pathAcc, folderName) => { pathSplit.reduce((pathAcc, folderName) => {
const parentFolder = acc[pathAcc[pathAcc.length - 1]]; const parentFolder = acc[pathAcc[pathAcc.length - 1]];
const folderPath = `${ const folderPath = `${parentFolder ? `${parentFolder.path}/` : ''}${folderName}`;
parentFolder ? `${parentFolder.path}/` : ''
}${folderName}`;
const foundEntry = acc[folderPath]; const foundEntry = acc[folderPath];
if (!foundEntry) { if (!foundEntry) {
...@@ -33,9 +25,7 @@ self.addEventListener('message', e => { ...@@ -33,9 +25,7 @@ self.addEventListener('message', e => {
path: folderPath, path: folderPath,
url: `/${projectId}/tree/${branchId}/${folderPath}/`, url: `/${projectId}/tree/${branchId}/${folderPath}/`,
type: 'tree', type: 'tree',
parentTreeUrl: parentFolder parentTreeUrl: parentFolder ? parentFolder.url : `/${projectId}/tree/${branchId}/`,
? parentFolder.url
: `/${projectId}/tree/${branchId}/`,
tempFile, tempFile,
changed: tempFile, changed: tempFile,
opened: tempFile, opened: tempFile,
...@@ -70,13 +60,12 @@ self.addEventListener('message', e => { ...@@ -70,13 +60,12 @@ self.addEventListener('message', e => {
path, path,
url: `/${projectId}/blob/${branchId}/${path}`, url: `/${projectId}/blob/${branchId}/${path}`,
type: 'blob', type: 'blob',
parentTreeUrl: fileFolder parentTreeUrl: fileFolder ? fileFolder.url : `/${projectId}/blob/${branchId}`,
? fileFolder.url
: `/${projectId}/blob/${branchId}`,
tempFile, tempFile,
changed: tempFile, changed: tempFile,
content, content,
base64, base64,
previewMode: viewerInformationForPath(blobName),
}); });
Object.assign(acc, { Object.assign(acc, {
......
<script>
import { viewerInformationForPath } from './lib/viewer_utils';
import MarkdownViewer from './viewers/markdown_viewer.vue';
export default {
props: {
content: {
type: String,
required: true,
},
path: {
type: String,
required: true,
},
projectPath: {
type: String,
required: false,
default: '',
},
},
computed: {
viewer() {
const previewInfo = viewerInformationForPath(this.path);
switch (previewInfo.id) {
case 'markdown':
return MarkdownViewer;
default:
return null;
}
},
},
};
</script>
<template>
<div class="preview-container">
<component
:is="viewer"
:project-path="projectPath"
:content="content"
/>
</div>
</template>
const viewers = {
markdown: {
id: 'markdown',
previewTitle: 'Preview Markdown',
},
};
const fileNameViewers = {};
const fileExtensionViewers = {
md: 'markdown',
markdown: 'markdown',
};
export function viewerInformationForPath(path) {
if (!path) return null;
const name = path.split('/').pop();
const viewerName =
fileNameViewers[name] || fileExtensionViewers[name ? name.split('.').pop() : ''] || '';
return viewers[viewerName];
}
export default viewers;
<script>
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import $ from 'jquery';
import SkeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue';
const CancelToken = axios.CancelToken;
let axiosSource;
export default {
components: {
SkeletonLoadingContainer,
},
props: {
content: {
type: String,
required: true,
},
projectPath: {
type: String,
required: true,
},
},
data() {
return {
previewContent: null,
isLoading: false,
};
},
watch: {
content() {
this.previewContent = null;
},
},
created() {
axiosSource = CancelToken.source();
this.fetchMarkdownPreview();
},
updated() {
this.fetchMarkdownPreview();
},
destroyed() {
if (this.isLoading) axiosSource.cancel('Cancelling Preview');
},
methods: {
fetchMarkdownPreview() {
if (this.content && this.previewContent === null) {
this.isLoading = true;
const postBody = {
text: this.content,
};
const postOptions = {
cancelToken: axiosSource.token,
};
axios
.post(
`${gon.relative_url_root}/${this.projectPath}/preview_markdown`,
postBody,
postOptions,
)
.then(({ data }) => {
this.previewContent = data.body;
this.isLoading = false;
this.$nextTick(() => {
$(this.$refs['markdown-preview']).renderGFM();
});
})
.catch(() => {
this.previewContent = __('An error occurred while fetching markdown preview');
this.isLoading = false;
});
}
},
},
};
</script>
<template>
<div
ref="markdown-preview"
class="md md-previewer">
<skeleton-loading-container v-if="isLoading" />
<div
v-else
v-html="previewContent">
</div>
</div>
</template>
...@@ -308,14 +308,34 @@ ...@@ -308,14 +308,34 @@
height: 100%; height: 100%;
} }
.multi-file-editor-btn-group { .preview-container {
padding: $gl-bar-padding $gl-padding; height: 100%;
border-top: 1px solid $white-dark; overflow: auto;
.md-previewer {
padding: $gl-padding;
}
}
.ide-mode-tabs {
border-bottom: 1px solid $white-dark; border-bottom: 1px solid $white-dark;
background: $white-light;
.nav-links {
border-bottom: 0;
li a {
padding: $gl-padding-8 $gl-padding;
line-height: $gl-btn-line-height;
}
}
}
.ide-btn-group {
padding: $gl-padding-4 $gl-vert-padding;
} }
.ide-status-bar { .ide-status-bar {
border-top: 1px solid $white-dark;
padding: $gl-bar-padding $gl-padding; padding: $gl-bar-padding $gl-padding;
background: $white-light; background: $white-light;
display: flex; display: flex;
......
import Vue from 'vue'; import Vue from 'vue';
import repoFileButtons from '~/ide/components/repo_file_buttons.vue'; import repoFileButtons from '~/ide/components/ide_file_buttons.vue';
import createVueComponent from '../../helpers/vue_mount_component_helper'; import createVueComponent from '../../helpers/vue_mount_component_helper';
import { file } from '../helpers'; import { file } from '../helpers';
...@@ -23,7 +23,7 @@ describe('RepoFileButtons', () => { ...@@ -23,7 +23,7 @@ describe('RepoFileButtons', () => {
vm.$destroy(); vm.$destroy();
}); });
it('renders Raw, Blame, History, Permalink and Preview toggle', done => { it('renders Raw, Blame, History and Permalink', done => {
vm = createComponent(); vm = createComponent();
vm.$nextTick(() => { vm.$nextTick(() => {
...@@ -32,16 +32,30 @@ describe('RepoFileButtons', () => { ...@@ -32,16 +32,30 @@ describe('RepoFileButtons', () => {
const history = vm.$el.querySelector('.history'); const history = vm.$el.querySelector('.history');
expect(raw.href).toMatch(`/${activeFile.rawPath}`); expect(raw.href).toMatch(`/${activeFile.rawPath}`);
expect(raw.textContent.trim()).toEqual('Raw'); expect(raw.getAttribute('data-original-title')).toEqual('Raw');
expect(blame.href).toMatch(`/${activeFile.blamePath}`); expect(blame.href).toMatch(`/${activeFile.blamePath}`);
expect(blame.textContent.trim()).toEqual('Blame'); expect(blame.getAttribute('data-original-title')).toEqual('Blame');
expect(history.href).toMatch(`/${activeFile.commitsPath}`); expect(history.href).toMatch(`/${activeFile.commitsPath}`);
expect(history.textContent.trim()).toEqual('History'); expect(history.getAttribute('data-original-title')).toEqual('History');
expect(vm.$el.querySelector('.permalink').textContent.trim()).toEqual( expect(vm.$el.querySelector('.permalink').getAttribute('data-original-title')).toEqual(
'Permalink', 'Permalink',
); );
done(); done();
}); });
}); });
it('renders Download', done => {
activeFile.binary = true;
vm = createComponent();
vm.$nextTick(() => {
const raw = vm.$el.querySelector('.raw');
expect(raw.href).toMatch(`/${activeFile.rawPath}`);
expect(raw.getAttribute('data-original-title')).toEqual('Download');
done();
});
});
}); });
...@@ -19,7 +19,6 @@ describe('RepoEditor', () => { ...@@ -19,7 +19,6 @@ describe('RepoEditor', () => {
f.active = true; f.active = true;
f.tempFile = true; f.tempFile = true;
f.html = 'testing';
vm.$store.state.openFiles.push(f); vm.$store.state.openFiles.push(f);
vm.$store.state.entries[f.path] = f; vm.$store.state.entries[f.path] = f;
vm.monaco = true; vm.monaco = true;
...@@ -47,6 +46,61 @@ describe('RepoEditor', () => { ...@@ -47,6 +46,61 @@ describe('RepoEditor', () => {
}); });
}); });
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');
done();
});
});
describe('when file is markdown', () => {
beforeEach(done => {
vm.file.previewMode = {
id: 'markdown',
previewTitle: 'Preview Markdown',
};
vm.$nextTick(done);
});
it('renders an Edit and a Preview Tab', done => {
Vue.nextTick(() => {
const tabs = vm.$el.querySelectorAll('.ide-mode-tabs .nav-links li');
expect(tabs.length).toBe(2);
expect(tabs[0].textContent.trim()).toBe('Edit');
expect(tabs[1].textContent.trim()).toBe('Preview Markdown');
done();
});
});
});
describe('when file is markdown and viewer mode is review', () => {
beforeEach(done => {
vm.file.previewMode = {
id: 'markdown',
previewTitle: 'Preview Markdown',
};
vm.$store.state.viewer = 'diff';
vm.$nextTick(done);
});
it('renders an Edit and a Preview Tab', done => {
Vue.nextTick(() => {
const tabs = vm.$el.querySelectorAll('.ide-mode-tabs .nav-links li');
expect(tabs.length).toBe(2);
expect(tabs[0].textContent.trim()).toBe('Review');
expect(tabs[1].textContent.trim()).toBe('Preview Markdown');
done();
});
});
});
describe('when open file is binary and not raw', () => { describe('when open file is binary and not raw', () => {
beforeEach(done => { beforeEach(done => {
vm.file.binary = true; vm.file.binary = true;
...@@ -57,10 +111,6 @@ describe('RepoEditor', () => { ...@@ -57,10 +111,6 @@ describe('RepoEditor', () => {
it('does not render the IDE', () => { it('does not render the IDE', () => {
expect(vm.shouldHideEditor).toBeTruthy(); expect(vm.shouldHideEditor).toBeTruthy();
}); });
it('shows activeFile html', () => {
expect(vm.$el.textContent).toContain('testing');
});
}); });
describe('createEditorInstance', () => { describe('createEditorInstance', () => {
......
...@@ -194,6 +194,17 @@ describe('IDE store file mutations', () => { ...@@ -194,6 +194,17 @@ describe('IDE store file mutations', () => {
}); });
}); });
describe('SET_FILE_VIEWMODE', () => {
it('updates file view mode', () => {
mutations.SET_FILE_VIEWMODE(localState, {
file: localFile,
viewMode: 'preview',
});
expect(localFile.viewMode).toBe('preview');
});
});
describe('ADD_PENDING_TAB', () => { describe('ADD_PENDING_TAB', () => {
beforeEach(() => { beforeEach(() => {
const f = { const f = {
......
import Vue from 'vue';
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import contentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('ContentViewer', () => {
let vm;
let mock;
function createComponent(props) {
const ContentViewer = Vue.extend(contentViewer);
vm = mountComponent(ContentViewer, props);
}
afterEach(() => {
vm.$destroy();
if (mock) mock.restore();
});
it('markdown preview renders + loads rendered markdown from server', done => {
mock = new MockAdapter(axios);
mock.onPost(`${gon.relative_url_root}/testproject/preview_markdown`).reply(200, {
body: '<b>testing</b>',
});
createComponent({
path: 'test.md',
content: '* Test',
projectPath: 'testproject',
});
const previewContainer = vm.$el.querySelector('.md-previewer');
setTimeout(() => {
expect(previewContainer.textContent).toContain('testing');
done();
});
});
});
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