Commit c88cc0c0 authored by Tim Zallmann's avatar Tim Zallmann Committed by Phil Hughes

Web IDE markdown preview

parent 21488c74
......@@ -3,7 +3,6 @@ import { mapState, mapGetters } from 'vuex';
import ideSidebar from './ide_side_bar.vue';
import ideContextbar from './ide_context_bar.vue';
import repoTabs from './repo_tabs.vue';
import repoFileButtons from './repo_file_buttons.vue';
import ideStatusBar from './ide_status_bar.vue';
import repoEditor from './repo_editor.vue';
......@@ -12,7 +11,6 @@ export default {
ideSidebar,
ideContextbar,
repoTabs,
repoFileButtons,
ideStatusBar,
repoEditor,
},
......@@ -70,9 +68,6 @@ export default {
class="multi-file-edit-pane-content"
:file="activeFile"
/>
<repo-file-buttons
:file="activeFile"
/>
<ide-status-bar
: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 @@
/* global monaco */
import { mapState, mapGetters, mapActions } from 'vuex';
import flash from '~/flash';
import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue';
import monacoLoader from '../monaco_loader';
import Editor from '../lib/editor';
import IdeFileButtons from './ide_file_buttons.vue';
export default {
components: {
ContentViewer,
IdeFileButtons,
},
props: {
file: {
type: Object,
......@@ -18,6 +24,16 @@ export default {
shouldHideEditor() {
return this.file && this.file.binary && !this.file.raw;
},
editTabCSS() {
return {
active: this.file.viewMode === 'edit',
};
},
previewTabCSS() {
return {
active: this.file.viewMode === 'preview',
};
},
},
watch: {
file(oldVal, newVal) {
......@@ -56,6 +72,7 @@ export default {
'changeFileContent',
'setFileLanguage',
'setEditorPosition',
'setFileViewMode',
'setFileEOL',
'updateViewer',
'updateDelayViewerUpdated',
......@@ -153,15 +170,47 @@ export default {
class="blob-viewer-container blob-editor-container"
>
<div
v-if="shouldHideEditor"
v-html="file.html"
>
class="ide-mode-tabs clearfix"
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
v-show="!shouldHideEditor"
v-show="!shouldHideEditor && file.viewMode === 'edit'"
ref="editor"
class="multi-file-editor-holder"
>
</div>
<content-viewer
v-if="!shouldHideEditor && file.viewMode === 'preview'"
:content="file.content || file.raw"
:path="file.path"
:project-path="file.projectId"/>
</div>
</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
}
};
export const setFileViewMode = ({ state, commit }, { file, viewMode }) => {
commit(types.SET_FILE_VIEWMODE, { file, viewMode });
};
export const discardFileChanges = ({ state, commit }, path) => {
const file = state.entries[path];
......
......@@ -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 SET_FILE_LANGUAGE = 'SET_FILE_LANGUAGE';
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 DISCARD_FILE_CHANGES = 'DISCARD_FILE_CHANGES';
export const ADD_FILE_TO_CHANGED = 'ADD_FILE_TO_CHANGED';
......
......@@ -42,6 +42,7 @@ export default {
renderError: data.render_error,
raw: null,
baseRaw: null,
html: data.html,
});
},
[types.SET_FILE_RAW_DATA](state, { file, raw }) {
......@@ -83,6 +84,11 @@ export default {
mrChange,
});
},
[types.SET_FILE_VIEWMODE](state, { file, viewMode }) {
Object.assign(state.entries[file.path], {
viewMode,
});
},
[types.DISCARD_FILE_CHANGES](state, path) {
Object.assign(state.entries[path], {
content: state.entries[path].raw,
......
......@@ -38,6 +38,8 @@ export const dataStructure = () => ({
editorColumn: 1,
fileLanguage: '',
eol: '',
viewMode: 'edit',
previewMode: null,
});
export const decorateData = entity => {
......@@ -57,8 +59,9 @@ export const decorateData = entity => {
changed = false,
parentTreeUrl = '',
base64 = false,
previewMode,
file_lock,
html,
} = entity;
return {
......@@ -79,8 +82,9 @@ export const decorateData = entity => {
renderError,
content,
base64,
previewMode,
file_lock,
html,
};
};
......
import { viewerInformationForPath } from '~/vue_shared/components/content_viewer/lib/viewer_utils';
import { decorateData, sortTree } from '../utils';
self.addEventListener('message', e => {
const {
data,
projectId,
branchId,
tempFile = false,
content = '',
base64 = false,
} = e.data;
const { data, projectId, branchId, tempFile = false, content = '', base64 = false } = e.data;
const treeList = [];
let file;
......@@ -19,9 +13,7 @@ self.addEventListener('message', e => {
if (pathSplit.length > 0) {
pathSplit.reduce((pathAcc, folderName) => {
const parentFolder = acc[pathAcc[pathAcc.length - 1]];
const folderPath = `${
parentFolder ? `${parentFolder.path}/` : ''
}${folderName}`;
const folderPath = `${parentFolder ? `${parentFolder.path}/` : ''}${folderName}`;
const foundEntry = acc[folderPath];
if (!foundEntry) {
......@@ -33,9 +25,7 @@ self.addEventListener('message', e => {
path: folderPath,
url: `/${projectId}/tree/${branchId}/${folderPath}/`,
type: 'tree',
parentTreeUrl: parentFolder
? parentFolder.url
: `/${projectId}/tree/${branchId}/`,
parentTreeUrl: parentFolder ? parentFolder.url : `/${projectId}/tree/${branchId}/`,
tempFile,
changed: tempFile,
opened: tempFile,
......@@ -70,13 +60,12 @@ self.addEventListener('message', e => {
path,
url: `/${projectId}/blob/${branchId}/${path}`,
type: 'blob',
parentTreeUrl: fileFolder
? fileFolder.url
: `/${projectId}/blob/${branchId}`,
parentTreeUrl: fileFolder ? fileFolder.url : `/${projectId}/blob/${branchId}`,
tempFile,
changed: tempFile,
content,
base64,
previewMode: viewerInformationForPath(blobName),
});
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 @@
height: 100%;
}
.multi-file-editor-btn-group {
padding: $gl-bar-padding $gl-padding;
border-top: 1px solid $white-dark;
.preview-container {
height: 100%;
overflow: auto;
.md-previewer {
padding: $gl-padding;
}
}
.ide-mode-tabs {
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 {
border-top: 1px solid $white-dark;
padding: $gl-bar-padding $gl-padding;
background: $white-light;
display: flex;
......
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 { file } from '../helpers';
......@@ -23,7 +23,7 @@ describe('RepoFileButtons', () => {
vm.$destroy();
});
it('renders Raw, Blame, History, Permalink and Preview toggle', done => {
it('renders Raw, Blame, History and Permalink', done => {
vm = createComponent();
vm.$nextTick(() => {
......@@ -32,16 +32,30 @@ describe('RepoFileButtons', () => {
const history = vm.$el.querySelector('.history');
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.textContent.trim()).toEqual('Blame');
expect(blame.getAttribute('data-original-title')).toEqual('Blame');
expect(history.href).toMatch(`/${activeFile.commitsPath}`);
expect(history.textContent.trim()).toEqual('History');
expect(vm.$el.querySelector('.permalink').textContent.trim()).toEqual(
expect(history.getAttribute('data-original-title')).toEqual('History');
expect(vm.$el.querySelector('.permalink').getAttribute('data-original-title')).toEqual(
'Permalink',
);
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', () => {
f.active = true;
f.tempFile = true;
f.html = 'testing';
vm.$store.state.openFiles.push(f);
vm.$store.state.entries[f.path] = f;
vm.monaco = true;
......@@ -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', () => {
beforeEach(done => {
vm.file.binary = true;
......@@ -57,10 +111,6 @@ describe('RepoEditor', () => {
it('does not render the IDE', () => {
expect(vm.shouldHideEditor).toBeTruthy();
});
it('shows activeFile html', () => {
expect(vm.$el.textContent).toContain('testing');
});
});
describe('createEditorInstance', () => {
......
......@@ -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', () => {
beforeEach(() => {
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